diff --git a/.github/workflows/api-acceptance.yml b/.github/workflows/api-acceptance.yml index 1db726e16..97e01f758 100644 --- a/.github/workflows/api-acceptance.yml +++ b/.github/workflows/api-acceptance.yml @@ -64,7 +64,7 @@ jobs: run: | python services/core/tests/official_schema_test.py python services/core/tests/official_client.py - go test ./services/core/tests/integration -run '^(TestFunctionStateOfficialClientReadsAndLiveEvents|TestSavedReferenceRetryOfficialClient|TestAgentUpdateOfficialClient|TestAgentDeletionOfficialClient|TestSessionAgentFilterOfficialClient|TestSessionDeletionOfficialClient|TestEnvironmentInitialFailureOfficialClient|TestSelfHostedInitialCreationOfficialClient|TestSelfHostedCancellationOfficialClient)$' -count=1 + go test ./services/core/tests/integration -run '^(TestFunctionStateOfficialClientReadsAndLiveEvents|TestSavedReferenceRetryOfficialClient|TestAgentUpdateOfficialClient|TestAgentDeletionOfficialClient|TestAutomationsOfficialClient|TestSessionAgentFilterOfficialClient|TestSessionDeletionOfficialClient|TestEnvironmentInitialFailureOfficialClient|TestSelfHostedInitialCreationOfficialClient|TestSelfHostedCancellationOfficialClient)$' -count=1 - uses: ./.github/actions/e2b-provider if: inputs.container - name: Verify the distribution's Core image diff --git a/apps/web/src/i18n/locales/en/metrics.ts b/apps/web/src/i18n/locales/en/metrics.ts index 66e191a70..748febb2a 100644 --- a/apps/web/src/i18n/locales/en/metrics.ts +++ b/apps/web/src/i18n/locales/en/metrics.ts @@ -172,7 +172,7 @@ export const metrics = { processed: "Processed", failed: "Failed", empty: "Core reports no background jobs.", - names: { scheduler: "Execution scheduling", runtime_sampler: "Runtime sampling", history_cleanup: "History cleanup", audit_cleanup: "Audit cleanup" }, + names: { scheduler: "Execution scheduling", runtime_sampler: "Runtime sampling", automation_delivery: "Automation delivery", history_cleanup: "History cleanup", audit_cleanup: "Audit cleanup" }, health: { ok: "Healthy", failing: "Failing", stopped: "Stopped", unknown: "Unknown" }, }, }, diff --git a/apps/web/src/i18n/locales/zh-CN/metrics.ts b/apps/web/src/i18n/locales/zh-CN/metrics.ts index b17eccf8e..eb8cb8975 100644 --- a/apps/web/src/i18n/locales/zh-CN/metrics.ts +++ b/apps/web/src/i18n/locales/zh-CN/metrics.ts @@ -172,7 +172,7 @@ export const metrics = { processed: "处理", failed: "失败", empty: "Core 没有报告后台任务。", - names: { scheduler: "执行调度", runtime_sampler: "Runtime 采样", history_cleanup: "历史清理", audit_cleanup: "审计清理" }, + names: { scheduler: "执行调度", runtime_sampler: "Runtime 采样", automation_delivery: "Automation 投递", history_cleanup: "历史清理", audit_cleanup: "审计清理" }, health: { ok: "正常", failing: "失败", stopped: "已停止", unknown: "未知" }, }, }, diff --git a/contracts/agents-api/automations.md b/contracts/agents-api/automations.md new file mode 100644 index 000000000..f5b18b315 --- /dev/null +++ b/contracts/agents-api/automations.md @@ -0,0 +1,79 @@ +--- +title: "Automations" +--- + +An Automation saves a schedule and a target. When the schedule fires, Core delivers the target: it creates a new Session, or sends instructions to an existing one. All ten operations of the pinned SDK are implemented under `/v1/agents/automations` with the same Project authentication and `OpenAI-Beta: agents=v1` as other Beta routes. An Automation belongs to the Project of its key; a missing ID and another Project's ID both return 404. + +| Operation | Result | +| --- | --- | +| `POST /agents/automations` | 201, an active Automation; nothing is delivered | +| `GET /agents/automations` | A page; `session_id` keeps Automations whose target is that Session. Lists clamp `limit` to 1–100, and an unresolved `after` returns 404 | +| `GET /agents/automations/{automation_id}` | The Automation | +| `POST /agents/automations/{automation_id}` | Replaces the fields in the body; `target` and `metadata` replace the whole field | +| `DELETE /agents/automations/{automation_id}` | `agent.automation.deleted`; the target Session and the Sessions it created stay | +| `POST …/pause`, `POST …/resume` | The Automation | +| `POST …/invocations` | 200, a queued manual invocation | +| `GET …/invocations`, `GET …/invocations/{invocation_id}` | A page, or one invocation | + +## Resource + +`type` is `periodic` or `once`. + +- `periodic` has `expression` and `timezone`. Status is `active` or `paused`. +- `once` has `run_at`, a Unix timestamp in seconds, at most 253402300799. Status is `active`, `paused` or `completed`; completed means the schedule has finished, not that the agent's work has. +- `name` holds 1–256 characters, `expression` 1–256, `timezone` 1–128. `metadata` follows the [validation errors](./wire-semantics.md#validation-errors). +- `created_by` is always `{"type": "developer"}`: the `automations` agent tool is not supported, so an agent cannot create one ([Session admission](./wire-semantics.md#session-admission)). + +### Schedules + +`expression` is a five-field crontab: minute, hour, day of month, month and day of week. A field is `*` or a comma list of `n`, `a-b`, `*/s` and `a-b/s`; months and weekdays also take three-letter names, and weekday 7 is Sunday. When neither day field starts with `*`, a day matches either. Descriptors such as `@daily`, a seconds field, `Local` and an empty zone are rejected, and so is an expression with no occurrence in the next five years. + +`timezone` is an IANA name, resolved from the system zoneinfo (or the directory in `ZONEINFO`) first and from the tzdata Core embeds otherwise. A job whose minute and hour fields both start with something other than `*` is fixed-time. On a daylight saving change: + +- Spring forward: a fixed-time job whose time falls in the gap runs at the first instant after the gap. A job with a `*` minute or hour runs only at wall times that exist. +- Fall back: a fixed-time job in the repeated hour runs once, at its first instant. A job with a `*` minute or hour runs on both passes. + +If Core was down when occurrences were due, it records only the latest one. + +## Targets + +`target.type` is `new_session` or `existing_session`. An update keeps the type; another type returns 400 with param `target.type`. + +`new_session` takes the fields of the Session create body, plus `type`: `environment` and `input` (an array of user messages) are required, exactly one of `agent` and `agent_id` must be given, and `metadata` and `vault_ids` are optional. Create and update run the checks Session create runs before it creates anything: the request shape, the Environment Template, the saved Agent, the model and its provider, the Vault bindings, and the Harness's support for the configuration, so an unsupported tool such as `automations` returns 400 at create. The rejections are Session create's, with their params prefixed `target.`: `environment.environment_id`, `container_size`, `desktop.enabled`, `packages.system` and `blocked_domains` are rejected. Deployment readiness, admission and leases are checked at each delivery. Beyond that: + +- An inline `agent` follows Session create: `model` is required unless a deployment default model applies. +- `x_agents_core` is rejected anywhere in the target. +- The saved resource shows the inline agent without `id` and `name`, MCP headers removed. `environment` shows the members the request supplied and `null` for the rest, which means inherit; a `self_hosted` environment always lists `capability_directories`. `env` and `setup_commands` are never shown. + +`existing_session` takes `session_id` and `instructions` (1 to 1,048,576 characters). The Session must exist when the Automation is created or updated, and Core saves its canonical ID whatever form the request used; a Session deleted later fails each delivery with the input path's 404. Core sends `instructions` as one user message with one `input_text` part, exactly as `agent.session.input.message` would. + +Create and update save the target; references inside it, such as a Skill, a File or an Agent, resolve at each invocation. A queued invocation delivers the target as it was when it was recorded or invoked, so an update applies to later invocations. + +The target request may hold values that reads never return, so Core stores it sealed with the credential key, bound to the Project and the Automation. + +## Invocations + +A scheduled occurrence and a manual invocation each produce one invocation record. `source` is `manual`, or `automation` with the Automation as it was at the occurrence. + +| Status | Meaning | +| --- | --- | +| `queued` | Recorded, not yet delivered | +| `completed` | Delivered; `session_id` is the created Session, or the target | +| `failed` | Delivery was rejected for good; `error` holds the `code` and `message` the same request would return | +| `skipped` | `skip_reason` is `pending_delivery`: the Automation already had a queued invocation | + +Core records due occurrences and delivers queued invocations once a second, and runs at most 16 deliveries at the same time, each for up to 5 s. A delivery uses the code of `POST /v1/agents/sessions` or `POST /v1/agents/sessions/{id}/events`, with the invocation ID as its `Idempotency-Key`, so a retry after a crash finds the Session or the input it already made. Core retries a delivery that gets a status of 500 or above, finds a Turn that cannot take input, or times out; a 4xx status fails the invocation. One Automation delivers one invocation at a time, oldest first, and the Automation attempted least recently goes first, so a stuck invocation does not hold every slot. An invocation whose saved target cannot be opened fails with `internal_error`. Invocations are kept until the Automation is deleted. + +A `once` Automation completes when its scheduled invocation reaches `completed`, `failed` or `skipped`. A manual invocation never changes the schedule. + +## State changes + +| Request | Rule | +| --- | --- | +| Pause | Stops scheduled delivery, holds a queued scheduled invocation until resume, and stops no Session. Manual invocations still deliver. Pausing a paused Automation returns it unchanged | +| Resume | A periodic Automation takes its next occurrence from now. A `once` Automation resumes if its scheduled invocation is pending or `run_at` is in the future; otherwise 409 `conflict_error`. Resuming an active Automation returns it unchanged | +| Pause or resume a completed Automation | 409 `conflict_error` | +| Update | `expression` and `timezone` apply only to periodic Automations and `run_at` only to `once`; another type's field returns 400 naming it. A past `run_at` returns 400. Changing `run_at` after the scheduled invocation exists returns 409 | +| Manual invoke | Allowed in every status | + +Core's choices where the official sources are silent are listed in the [ledger](./index.md#unverified-against-the-official-service). Invocation creation writes the audit entry `create` on `automation_invocation`; scheduled recording writes none ([write provenance](./admin-api.md#write-provenance)). diff --git a/contracts/agents-api/core-metrics.md b/contracts/agents-api/core-metrics.md index 088ae46e9..e5c76fab4 100644 --- a/contracts/agents-api/core-metrics.md +++ b/contracts/agents-api/core-metrics.md @@ -57,9 +57,10 @@ Core resolves its own cgroup, including nested and subtree mounts, and reports t ## Background jobs -`jobs` lists `scheduler`, `runtime_sampler`, `history_cleanup` and `audit_cleanup`. Each has `status` (`unknown` before its first run, `ok`, `failing`, or `stopped` when its loop is disabled or ended), `last_run_at` (when the last pass finished), `processed` and `failed`, both describing the last pass. +`jobs` lists `scheduler`, `runtime_sampler`, `automation_delivery`, `history_cleanup` and `audit_cleanup`. Each has `status` (`unknown` before its first run, `ok`, `failing`, or `stopped` when its loop is disabled or ended), `last_run_at` (when the last pass finished), `processed` and `failed`, both describing the last pass. - The scheduler's `processed` counts the Turn and Environment work it selected in its last poll. - The Runtime sampler's `processed` and `failed` count the targets its last sweep observed and failed to observe. +- The Automation delivery job's `processed` counts the occurrences it recorded and the invocations it settled in its last pass; `failed` counts storage failures and panicked deliveries. An invocation that failed for good is a settled record and does not count. - A cleanup job's `processed` counts the rows it removed. -- For the scheduler and the cleanup jobs, a failed pass sets `processed` to null and `failed` to 1; `failed` never estimates lost rows or failed Turns. +- For the scheduler, the Automation delivery job and the cleanup jobs, a failed pass sets `processed` to null and `failed` to 1; `failed` never estimates lost rows or failed Turns. diff --git a/contracts/agents-api/go-bindings.json b/contracts/agents-api/go-bindings.json index e1a6670ec..605695b91 100644 --- a/contracts/agents-api/go-bindings.json +++ b/contracts/agents-api/go-bindings.json @@ -20,6 +20,74 @@ "AgentMessageItem": { "sources": ["#/components/schemas/AgentMessageItemResource"] }, + "Automation": { + "marshal_union": true, + "sources": ["#/components/schemas/AutomationResource"], + "fields": { + "target": {"type": "json.RawMessage"}, + "created_by": {"type": "AutomationCreatedBy"} + }, + "order": ["type", "id", "object", "created_at", "name", "target", "metadata", "created_by", "expression", "timezone", "run_at", "status"] + }, + "AutomationAgentConfig": { + "sources": ["#/components/schemas/AutomationAgentConfigResource"], + "fields": { + "reasoning": {"type": "ReasoningResource"} + }, + "order": ["model", "reasoning", "text", "service_tier", "instructions", "tools", "multi_agent", "compaction", "experimental"] + }, + "AutomationCreatedBy": { + "marshal_union": true, + "sources": ["#/components/schemas/AutomationCreatedByResource"] + }, + "AutomationDeleted": { + "sources": ["#/components/schemas/DeletedAutomationResource"] + }, + "AutomationEnvironment": { + "marshal_union": true, + "sources": ["#/components/schemas/AutomationEnvironmentResource"], + "fields": { + "container_size": {"type": "json.RawMessage"}, + "packages": {"type": "*EnvironmentPackagesResponse"}, + "network": {"type": "*EnvironmentNetwork"}, + "desktop": {"type": "json.RawMessage"} + }, + "order": ["type", "environment_id", "environment_template_id", "container_size", "packages", "network", "desktop", "capability_directories", "skills", "plugins", "files", "workspace_directory"] + }, + "AutomationInvocation": { + "sources": ["#/components/schemas/AutomationInvocationResource"], + "fields": { + "error": {"type": "*AutomationInvocationError"} + }, + "order": ["id", "object", "automation_id", "created_at", "source", "scheduled_at", "session_id", "status", "skip_reason", "error"] + }, + "AutomationInvocationError": { + "sources": ["#/components/schemas/AutomationInvocationErrorResource"] + }, + "AutomationInvocationList": { + "sources": ["#/paths/~1agents~1automations~1{automation_id}~1invocations/get/responses/200/content/application~1json/schema"], + "order": ["object", "first_id", "last_id", "data", "has_more"] + }, + "AutomationInvocationSource": { + "marshal_union": true, + "sources": ["#/components/schemas/AutomationInvocationSourceResource"], + "fields": { + "automation": {"type": "json.RawMessage"} + } + }, + "AutomationList": { + "sources": ["#/paths/~1agents~1automations/get/responses/200/content/application~1json/schema"], + "order": ["object", "first_id", "last_id", "data", "has_more"] + }, + "AutomationTarget": { + "marshal_union": true, + "sources": ["#/components/schemas/AutomationTargetResource"], + "fields": { + "agent": {"type": "*AutomationAgentConfig"}, + "input": {"type": "[]InputMessage"} + }, + "order": ["type", "agent_id", "agent", "environment", "input", "vault_ids", "metadata", "session_id", "instructions"] + }, "CreateAgentRequest": { "sources": ["#/components/schemas/CreateAgentParams"], "fields": { @@ -281,6 +349,9 @@ "Reasoning": { "sources": ["#/components/schemas/ReasoningParam"] }, + "ReasoningResource": { + "sources": ["#/components/schemas/ReasoningResource"] + }, "ReasoningItem": { "sources": ["#/components/schemas/ReasoningItemResource"], "order": ["id", "turn_id", "type", "status", "summary"] diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index e38f51edf..ca1002210 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -40,7 +40,7 @@ Evidence for a status comes from the pinned official SDK and raw HTTP against th | Vault Credentials | create, retrieve, update, list, delete | Implemented for `static_bearer` and `mcp_oauth`, with `metadata` | [Vaults and Credentials](./vaults.md) | | Files | create, retrieve, list, delete, content | Implemented for `purpose=user_data`; content download is rejected | [Files and Skills](./source-files.md) | | Skills and Skill versions | create, retrieve, update, list, delete, content | Implemented | [Files and Skills](./source-files.md) | -| Automations | all 10 operations | Not supported: 404 `unsupported_operation` | [Unsupported operations](./wire-semantics.md#unsupported-operations) | +| Automations and invocations | create, retrieve, update, list, delete, pause, resume; invocation create, retrieve, list | Implemented; `created_by` is always `developer` | [Automations](./automations.md) | | Environments collection | create, list (`/agents/environments`) | Not supported: 404 `unsupported_operation`; Core's Environments are Session-scoped | [Unsupported operations](./wire-semantics.md#unsupported-operations) | | Session traces | list | Not supported: 404 `unsupported_operation` | [Unsupported operations](./wire-semantics.md#unsupported-operations) | @@ -127,6 +127,11 @@ Each item is Core's deliberate or native behavior where the official service beh - File names hold 1–1,024 bytes; the official service accepts empty and longer names. - A `SKILL.md` front-matter key other than `name`, `description`, `license`, `compatibility` and `metadata` is rejected; the official service accepts unknown keys ([upload a bundle](./source-files.md#upload-a-bundle)). +**Automations** ([Automations](./automations.md)) + +- An empty `instructions` in an `existing_session` target returns 400 at create and update; the pinned schema allows 0 bytes, and Core cannot send an empty message. +- A `run_at` above 253402300799 (year 9999) returns 400; the pinned schema sets no maximum. + **Vaults and Credentials** ([Vaults and Credentials](./vaults.md)) - MiniMax Code runs public function calls one at a time, so the application sees one `function_call` at a time. @@ -195,6 +200,13 @@ Each item is Core's deliberate or native behavior where the official service beh - When a Session update binds to a queued Turn: Core binds each root Turn's settings when it accepts the input that creates the Turn, so a Turn queued before an update keeps the earlier values. - The meaning of "automatic review is not enabled" in a Session response: Core omits `review` unless it is `auto_review`. +- Automations, where the pinned sources are silent ([Automations](./automations.md)): + - [Instructions delivery](./automations.md#targets) as one input message. + - [Cron grammar and daylight saving](./automations.md#schedules). + - [Downtime, skipping, completion and pause](./automations.md#invocations). + - [Conflict and rejection cases](./automations.md#state-changes). + - [Target validation and saved references](./automations.md#targets). + - [Paging](./automations.md): `limit` clamping and unresolved `after`. - The order of errors when one request has several faults, and error, default and payload-limit parity in general. - A Subagent's function call: Core makes it a required action of the root Turn that runs the child, with `turn_id` set to that Turn, and publishes the child's `function_call` Item once as `agent.session.turn.item.added` with the child Turn ID and a null `output_index`; a result may name either Turn. The Turn is `waiting` and the Session `requires_action` while root output may continue, and the `call_id` equals the `call_id` of the child's `function_call` Item. Claude Code publishes the child's Items when the root Turn settles, after the required action. - Whether the official service emits Environment `pending` and when: Core emits it when a hosted Environment's initialization starts, and the one official sample showed only `ready`, about 29 s after creation. diff --git a/contracts/agents-api/v1/official.gen.go b/contracts/agents-api/v1/official.gen.go index f95766d7c..0fb796a9f 100644 --- a/contracts/agents-api/v1/official.gen.go +++ b/contracts/agents-api/v1/official.gen.go @@ -54,6 +54,309 @@ type AgentMessageItem struct { Content []AgentContent `json:"content" binding:"required"` } +// Automation projects AutomationResource. +type Automation struct { + Type string `json:"type" binding:"required" enums:"periodic,once"` + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.automation"` + CreatedAt int64 `json:"created_at" binding:"required"` + Name string `json:"name" binding:"required"` + Target json.RawMessage `json:"target" binding:"required" swaggertype:"object"` + Metadata map[string]string `json:"metadata" binding:"required"` + CreatedBy AutomationCreatedBy `json:"created_by" binding:"required"` + Expression *string `json:"expression,omitempty"` + Timezone *string `json:"timezone,omitempty"` + RunAt *int64 `json:"run_at,omitempty"` + Status string `json:"status" binding:"required" enums:"active,paused,completed"` +} + +func (value Automation) MarshalJSON() ([]byte, error) { + switch value.Type { + case "periodic": + return json.Marshal(struct { + Type string `json:"type"` + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int64 `json:"created_at"` + Name string `json:"name"` + Target json.RawMessage `json:"target"` + Metadata map[string]string `json:"metadata"` + CreatedBy AutomationCreatedBy `json:"created_by"` + Expression *string `json:"expression"` + Timezone *string `json:"timezone"` + Status string `json:"status"` + }{ + Type: value.Type, + ID: value.ID, + Object: value.Object, + CreatedAt: value.CreatedAt, + Name: value.Name, + Target: value.Target, + Metadata: value.Metadata, + CreatedBy: value.CreatedBy, + Expression: value.Expression, + Timezone: value.Timezone, + Status: value.Status, + }) + case "once": + return json.Marshal(struct { + Type string `json:"type"` + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int64 `json:"created_at"` + Name string `json:"name"` + Target json.RawMessage `json:"target"` + Metadata map[string]string `json:"metadata"` + CreatedBy AutomationCreatedBy `json:"created_by"` + RunAt *int64 `json:"run_at"` + Status string `json:"status"` + }{ + Type: value.Type, + ID: value.ID, + Object: value.Object, + CreatedAt: value.CreatedAt, + Name: value.Name, + Target: value.Target, + Metadata: value.Metadata, + CreatedBy: value.CreatedBy, + RunAt: value.RunAt, + Status: value.Status, + }) + default: + return nil, fmt.Errorf("invalid Automation type %q", value.Type) + } +} + +// AutomationAgentConfig projects AutomationAgentConfigResource. +type AutomationAgentConfig struct { + Model string `json:"model" binding:"required"` + Reasoning ReasoningResource `json:"reasoning" binding:"required"` + Text TextConfig `json:"text" binding:"required"` + ServiceTier string `json:"service_tier" binding:"required" enums:"auto,default,flex,priority,fast,ultrafast"` + Instructions *string `json:"instructions" binding:"required" extensions:"x-nullable"` + Tools []json.RawMessage `json:"tools" binding:"required" swaggertype:"array,object"` + MultiAgent MultiAgentConfig `json:"multi_agent" binding:"required"` + Compaction json.RawMessage `json:"compaction,omitempty" swaggertype:"object"` + Experimental json.RawMessage `json:"experimental,omitempty" swaggertype:"object"` +} + +// AutomationCreatedBy projects AutomationCreatedByResource. +type AutomationCreatedBy struct { + Type string `json:"type" binding:"required" enums:"developer,agent"` + SessionID *string `json:"session_id,omitempty"` + ToolCallID *string `json:"tool_call_id,omitempty"` +} + +func (value AutomationCreatedBy) MarshalJSON() ([]byte, error) { + switch value.Type { + case "developer": + return json.Marshal(struct { + Type string `json:"type"` + }{ + Type: value.Type, + }) + case "agent": + return json.Marshal(struct { + Type string `json:"type"` + SessionID *string `json:"session_id"` + ToolCallID *string `json:"tool_call_id"` + }{ + Type: value.Type, + SessionID: value.SessionID, + ToolCallID: value.ToolCallID, + }) + default: + return nil, fmt.Errorf("invalid AutomationCreatedBy type %q", value.Type) + } +} + +// AutomationDeleted projects DeletedAutomationResource. +type AutomationDeleted struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.automation.deleted"` + Deleted bool `json:"deleted" binding:"required"` +} + +// AutomationEnvironment projects AutomationEnvironmentResource. +type AutomationEnvironment struct { + Type string `json:"type" binding:"required" enums:"none,openai_hosted,self_hosted"` + EnvironmentID *string `json:"environment_id,omitempty" extensions:"x-nullable"` + EnvironmentTemplateID *string `json:"environment_template_id,omitempty" extensions:"x-nullable"` + ContainerSize json.RawMessage `json:"container_size,omitempty" extensions:"x-nullable" swaggertype:"object"` + Packages *EnvironmentPackagesResponse `json:"packages,omitempty" extensions:"x-nullable"` + Network *EnvironmentNetwork `json:"network,omitempty" extensions:"x-nullable"` + Desktop json.RawMessage `json:"desktop,omitempty" extensions:"x-nullable" swaggertype:"object"` + CapabilityDirectories []string `json:"capability_directories,omitempty" extensions:"x-nullable"` + Skills []json.RawMessage `json:"skills,omitempty" extensions:"x-nullable" swaggertype:"array,object"` + Plugins []json.RawMessage `json:"plugins,omitempty" extensions:"x-nullable" swaggertype:"array,object"` + Files []json.RawMessage `json:"files,omitempty" extensions:"x-nullable" swaggertype:"array,object"` + WorkspaceDirectory *string `json:"workspace_directory,omitempty"` +} + +func (value AutomationEnvironment) MarshalJSON() ([]byte, error) { + switch value.Type { + case "none": + return json.Marshal(struct { + Type string `json:"type"` + }{ + Type: value.Type, + }) + case "openai_hosted": + return json.Marshal(struct { + Type string `json:"type"` + EnvironmentID *string `json:"environment_id"` + EnvironmentTemplateID *string `json:"environment_template_id"` + ContainerSize json.RawMessage `json:"container_size"` + Packages *EnvironmentPackagesResponse `json:"packages"` + Network *EnvironmentNetwork `json:"network"` + Desktop json.RawMessage `json:"desktop"` + CapabilityDirectories []string `json:"capability_directories"` + Skills []json.RawMessage `json:"skills"` + Plugins []json.RawMessage `json:"plugins"` + Files []json.RawMessage `json:"files"` + }{ + Type: value.Type, + EnvironmentID: value.EnvironmentID, + EnvironmentTemplateID: value.EnvironmentTemplateID, + ContainerSize: value.ContainerSize, + Packages: value.Packages, + Network: value.Network, + Desktop: value.Desktop, + CapabilityDirectories: value.CapabilityDirectories, + Skills: value.Skills, + Plugins: value.Plugins, + Files: value.Files, + }) + case "self_hosted": + return json.Marshal(struct { + Type string `json:"type"` + EnvironmentID *string `json:"environment_id"` + WorkspaceDirectory *string `json:"workspace_directory"` + CapabilityDirectories []string `json:"capability_directories"` + }{ + Type: value.Type, + EnvironmentID: value.EnvironmentID, + WorkspaceDirectory: value.WorkspaceDirectory, + CapabilityDirectories: value.CapabilityDirectories, + }) + default: + return nil, fmt.Errorf("invalid AutomationEnvironment type %q", value.Type) + } +} + +// AutomationInvocation projects AutomationInvocationResource. +type AutomationInvocation struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.automation.invocation"` + AutomationID string `json:"automation_id" binding:"required"` + CreatedAt int64 `json:"created_at" binding:"required"` + Source AutomationInvocationSource `json:"source" binding:"required"` + ScheduledAt *int64 `json:"scheduled_at" binding:"required" extensions:"x-nullable"` + SessionID *string `json:"session_id" binding:"required" extensions:"x-nullable"` + Status string `json:"status" binding:"required" enums:"queued,completed,failed,skipped"` + SkipReason *string `json:"skip_reason" binding:"required" extensions:"x-nullable" enums:"pending_delivery"` + Error *AutomationInvocationError `json:"error" binding:"required" extensions:"x-nullable"` +} + +// AutomationInvocationError projects AutomationInvocationErrorResource. +type AutomationInvocationError struct { + Code string `json:"code" binding:"required"` + Message string `json:"message" binding:"required"` +} + +// AutomationInvocationList projects schema. +type AutomationInvocationList struct { + Object string `json:"object" binding:"required" enums:"list"` + FirstID *string `json:"first_id" binding:"required" extensions:"x-nullable"` + LastID *string `json:"last_id" binding:"required" extensions:"x-nullable"` + Data []AutomationInvocation `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` +} + +// AutomationInvocationSource projects AutomationInvocationSourceResource. +type AutomationInvocationSource struct { + Type string `json:"type" binding:"required" enums:"manual,automation"` + Automation json.RawMessage `json:"automation,omitempty" swaggertype:"object"` +} + +func (value AutomationInvocationSource) MarshalJSON() ([]byte, error) { + switch value.Type { + case "manual": + return json.Marshal(struct { + Type string `json:"type"` + }{ + Type: value.Type, + }) + case "automation": + return json.Marshal(struct { + Type string `json:"type"` + Automation json.RawMessage `json:"automation"` + }{ + Type: value.Type, + Automation: value.Automation, + }) + default: + return nil, fmt.Errorf("invalid AutomationInvocationSource type %q", value.Type) + } +} + +// AutomationList projects schema. +type AutomationList struct { + Object string `json:"object" binding:"required" enums:"list"` + FirstID *string `json:"first_id" binding:"required" extensions:"x-nullable"` + LastID *string `json:"last_id" binding:"required" extensions:"x-nullable"` + Data []Automation `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` +} + +// AutomationTarget projects AutomationTargetResource. +type AutomationTarget struct { + Type string `json:"type" binding:"required" enums:"new_session,existing_session"` + AgentID *string `json:"agent_id,omitempty" extensions:"x-nullable"` + Agent *AutomationAgentConfig `json:"agent,omitempty" extensions:"x-nullable"` + Environment *AutomationEnvironment `json:"environment,omitempty"` + Input []InputMessage `json:"input,omitempty"` + VaultIDs []string `json:"vault_ids,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + SessionID *string `json:"session_id,omitempty"` + Instructions *string `json:"instructions,omitempty"` +} + +func (value AutomationTarget) MarshalJSON() ([]byte, error) { + switch value.Type { + case "new_session": + return json.Marshal(struct { + Type string `json:"type"` + AgentID *string `json:"agent_id"` + Agent *AutomationAgentConfig `json:"agent"` + Environment *AutomationEnvironment `json:"environment"` + Input []InputMessage `json:"input"` + VaultIDs []string `json:"vault_ids"` + Metadata map[string]string `json:"metadata"` + }{ + Type: value.Type, + AgentID: value.AgentID, + Agent: value.Agent, + Environment: value.Environment, + Input: value.Input, + VaultIDs: value.VaultIDs, + Metadata: value.Metadata, + }) + case "existing_session": + return json.Marshal(struct { + Type string `json:"type"` + SessionID *string `json:"session_id"` + Instructions *string `json:"instructions"` + }{ + Type: value.Type, + SessionID: value.SessionID, + Instructions: value.Instructions, + }) + default: + return nil, fmt.Errorf("invalid AutomationTarget type %q", value.Type) + } +} + // CreateAgentRequest projects CreateAgentParams. type CreateAgentRequest struct { XAgentsCore *SavedAgentCoreInput `json:"x_agents_core,omitempty"` @@ -514,6 +817,12 @@ type ReasoningItem struct { Summary []SummaryText `json:"summary" binding:"required"` } +// ReasoningResource projects ReasoningResource. +type ReasoningResource struct { + Effort *string `json:"effort" binding:"required" extensions:"x-nullable" enums:"none,minimal,low,medium,high,xhigh,max"` + Summary *string `json:"summary" binding:"required" extensions:"x-nullable" enums:"concise,detailed,auto"` +} + // RequiredAction projects SessionRequiredActionResource. type RequiredAction struct { Type string `json:"type" binding:"required" enums:"computer_use_approval_request,function_call,environment_connection"` diff --git a/contracts/agents-api/wire-semantics.md b/contracts/agents-api/wire-semantics.md index b3795b5d7..caf2d4c56 100644 --- a/contracts/agents-api/wire-semantics.md +++ b/contracts/agents-api/wire-semantics.md @@ -23,7 +23,7 @@ The pinned OpenAI Python SDK ([upstream.json](https://github.com/MiniMax-AI/Open ### Unsupported operations -The Automations routes (all 10), `POST` and `GET /agents/environments` and `GET /agents/sessions/{session_id}/traces` are registered, so they pass the Beta and authentication checks like every pinned route, then answer 404 with code `unsupported_operation` and the message "This API operation is not supported." Environments exist only inside a Session ([ownership](./environments.md#resources-and-states)), and Core has no Automations or trace store. +`POST` and `GET /agents/environments` and `GET /agents/sessions/{session_id}/traces` are registered, so they pass the Beta and authentication checks like every pinned route, then answer 404 with code `unsupported_operation` and the message "This API operation is not supported." Environments exist only inside a Session ([ownership](./environments.md#resources-and-states)), and Core has no trace store. Creating an Agent, Vault, Credential, Environment Template, Environment file or Session returns 201, also for a streamed Session creation. An empty update body on an Agent or Environment Template advances `updated_at` and changes nothing else; timestamps have one-second precision. @@ -59,7 +59,7 @@ Every `/v1` JSON route passes one body gate before route decoding, validation or | Order | Case | Response | | --- | --- | --- | | 1 | `Content-Type` missing, not JSON or malformed, including a bodyless `POST`. `application/json` and `application/*+json` are accepted case-insensitively, with parameters | "expected request with Content-Type: application/json" | -| 2 | Body over the route limit: 16 MiB for Session create and Environment Templates, the [Environment file](./environment-files.md) bound for file create, 1 MiB elsewhere | 413, code `request_too_large` | +| 2 | Body over the route limit: 16 MiB for Session create, Environment Templates and Automation create and update, the [Environment file](./environment-files.md) bound for file create, 1 MiB elsewhere | 413, code `request_too_large` | | 3 | Invalid UTF-8 | "Invalid body: encountered a unicode decode error when parsing this JSON value. Please check the value to ensure it is valid unicode." | | 4 | Malformed JSON, trailing data, two values, a byte order mark, a whitespace-only body, or an escape that forms a lone UTF-16 surrogate | "Invalid body: failed to parse JSON value. Please check the value to ensure it is valid JSON. (Common errors include trailing commas, missing closing brackets, missing quotation marks, etc.)" | | 5 | A key repeated in one object, at any depth | `"Invalid body: duplicate JSON key '' at ''. Duplicate JSON keys are not supported."` The path joins object keys with `.` and omits array indices, such as `metadata.k` or `tools.type`. Keys compare after unescaping and case-sensitively. The first repeat in document order is reported. | @@ -123,7 +123,7 @@ Vault and Credential lists accept `status` as a scalar, as `status[]` entries, o | Lists | Default | Accepted | Other values | | --- | --- | --- | --- | -| Agents, Sessions, Items, Turn Items, Environment Templates, Vaults, Credentials, Subagent Items, Subagent Turn Items | 20 | 1–100 | 0 becomes 1; above 100 becomes 100 | +| Agents, Sessions, Items, Turn Items, Environment Templates, Automations, Automation invocations, Vaults, Credentials, Subagent Items, Subagent Turn Items | 20 | 1–100 | 0 becomes 1; above 100 becomes 100 | | Turns, Subagents, Subagent Turns, Artifacts | 20 | 1–100 | 400 `invalid_request_error`, "limit must be between 1 and 100" | | Skills, Skill versions | 20 | 0–100 | 0 returns an empty page whose `has_more` reports whether a resource follows the cursor. Negative: 400 `integer_below_min_value`, param `limit`. Above 100: 400 `integer_above_max_value`, param `limit` | | Files | 10000 | 1–10000 | 400 with a null code, "limit must be between 1 and 10000." | diff --git a/contracts/agents-api/zh/automations.md b/contracts/agents-api/zh/automations.md new file mode 100644 index 000000000..14f1661b6 --- /dev/null +++ b/contracts/agents-api/zh/automations.md @@ -0,0 +1,81 @@ +--- +title: "Automations" +source: contracts/agents-api/automations.md +source_hash: 4644f72c63abb5381898a794a5421bbe968bf5ab418237ea40d111dee850a515 +--- + +Automation 保存一个计划和一个目标。计划触发时,Core 投递目标:创建新 Session,或向已有 Session 发送指令。锁定版本 SDK 的全部十个操作都在 `/v1/agents/automations` 下实现,并与其他 Beta 路由一样需要项目身份验证和 `OpenAI-Beta: agents=v1`。Automation 属于其密钥所在的项目;不存在的 ID 与其他项目的 ID 都返回 404。 + +| 操作 | 结果 | +| --- | --- | +| `POST /agents/automations` | 201,一个 active 的 Automation;不会投递任何内容 | +| `GET /agents/automations` | 一页结果;`session_id` 只保留目标为该 Session 的 Automation。列表把 `limit` 截断到 1–100,无法解析的 `after` 返回 404 | +| `GET /agents/automations/{automation_id}` | 该 Automation | +| `POST /agents/automations/{automation_id}` | 替换请求体中的字段;`target` 和 `metadata` 整体替换 | +| `DELETE /agents/automations/{automation_id}` | `agent.automation.deleted`;目标 Session 和它创建的 Session 保留 | +| `POST …/pause`、`POST …/resume` | 该 Automation | +| `POST …/invocations` | 200,一个排队中的手动 invocation | +| `GET …/invocations`、`GET …/invocations/{invocation_id}` | 一页结果,或单个 invocation | + +## 资源 {#resource} + +`type` 为 `periodic` 或 `once`。 + +- `periodic` 带有 `expression` 和 `timezone`。状态为 `active` 或 `paused`。 +- `once` 带有 `run_at`,即以秒为单位的 Unix 时间戳,最大为 253402300799。状态为 `active`、`paused` 或 `completed`;completed 表示计划已结束,而不是 agent 的工作已结束。 +- `name` 为 1–256 个字符,`expression` 为 1–256,`timezone` 为 1–128。`metadata` 遵循[校验错误](wire-semantics.md#validation-errors)中的规则。 +- `created_by` 始终为 `{"type": "developer"}`:不支持 `automations` agent 工具,因此 agent 无法创建 Automation([Session 准入](wire-semantics.md#session-admission))。 + +### 计划 {#schedules} + +`expression` 是五字段 crontab:分钟、小时、日、月和星期。每个字段为 `*`,或由 `n`、`a-b`、`*/s` 和 `a-b/s` 组成的逗号列表;月和星期还接受三字母名称,星期 7 表示周日。日和星期两个字段都不以 `*` 开头时,满足任一个即匹配该日。`@daily` 这类描述符、秒字段、`Local` 和空时区会被拒绝,未来五年内没有触发时间的表达式也会被拒绝。 + +`timezone` 是 IANA 名称,先由系统 zoneinfo(或 `ZONEINFO` 指向的目录)解析,找不到时再用 Core 内嵌的 tzdata。分钟和小时字段都不以 `*` 开头的任务是定点任务。夏令时切换时: + +- 向前拨:落在空档里的定点任务在空档结束后的第一个时刻运行。分钟或小时字段为 `*` 的任务只在存在的墙上时间运行。 +- 向后拨:落在重复小时里的定点任务只在第一个时刻运行一次。分钟或小时字段为 `*` 的任务在两遍中都运行。 + +如果 Core 停机期间有多次触发到期,它只记录最近一次。 + +## 目标 {#targets} + +`target.type` 为 `new_session` 或 `existing_session`。更新保持类型不变;另一种类型返回 400,param 为 `target.type`。 + +`new_session` 采用 Session 创建请求体的字段,外加 `type`:`environment` 和 `input`(用户消息数组)必填,`agent` 与 `agent_id` 必须恰好提供其中一个,`metadata` 和 `vault_ids` 可选。创建和更新会执行 Session 创建在创建任何东西之前执行的检查:请求形状、Environment Template、已保存的 Agent、模型及其 provider、Vault 绑定,以及 Harness 对该配置的支持,因此不受支持的工具(如 `automations`)在创建时返回 400。拒绝规则与 Session 创建相同,param 加上前缀 `target.`:`environment.environment_id`、`container_size`、`desktop.enabled`、`packages.system` 和 `blocked_domains` 会被拒绝。部署就绪、准入和租约在每次投递时检查。此外: + +- 内联 `agent` 与 Session 创建一致:除非适用部署默认模型,否则 `model` 必填。 +- 目标中任何位置的 `x_agents_core` 都会被拒绝。 +- 保存后的资源展示的内联 agent 不含 `id` 和 `name`,并去掉 MCP headers。`environment` 展示请求提供的成员,其余为 `null`,表示继承;`self_hosted` 环境总会列出 `capability_directories`。`env` 和 `setup_commands` 从不展示。 + +`existing_session` 采用 `session_id` 和 `instructions`(1 到 1,048,576 个字符)。创建或更新 Automation 时,该 Session 必须存在,Core 保存其规范 ID,无论请求使用哪种形式;之后被删除会让每次投递以输入路径的 404 失败。Core 把 `instructions` 作为一条带有一个 `input_text` 部分的用户消息发送,与 `agent.session.input.message` 完全一样。 + +创建和更新只保存目标;目标中的引用,如 Skill、File 或 Agent,在每次 invocation 时解析。排队中的 invocation 投递的是它被记录或触发时的目标,因此更新只作用于之后的 invocation。 + +目标请求可能包含读取时不会返回的值,因此 Core 用凭据密钥将其密封存储,并绑定到项目和 Automation。 + +## Invocations {#invocations} + +每次计划触发和每次手动 invocation 都产生一条 invocation 记录。`source` 为 `manual`,或为 `automation` 并带有触发时的 Automation。 + +| 状态 | 含义 | +| --- | --- | +| `queued` | 已记录,尚未投递 | +| `completed` | 已投递;`session_id` 是创建的 Session,或目标 Session | +| `failed` | 投递被永久拒绝;`error` 包含相同请求会返回的 `code` 和 `message` | +| `skipped` | `skip_reason` 为 `pending_delivery`:该 Automation 已有排队中的 invocation | + +Core 每秒记录到期的触发并投递排队中的 invocation,最多同时运行 16 个投递,每个最长 5 s。投递使用 `POST /v1/agents/sessions` 或 `POST /v1/agents/sessions/{id}/events` 的代码,并以 invocation ID 作为 `Idempotency-Key`,因此崩溃后的重试会找到已经创建的 Session 或输入。投递得到 500 及以上的状态、遇到无法接收输入的 Turn 或超时,Core 会重试;4xx 状态会让 invocation 失败。同一个 Automation 一次只投递一个 invocation,先到先投递,最久未尝试的 Automation 先投递,因此卡住的 invocation 不会占满所有名额。已保存的目标无法打开的 invocation 以 `internal_error` 失败。invocation 保留到 Automation 被删除。 + +`once` Automation 在其计划 invocation 到达 `completed`、`failed` 或 `skipped` 时完成。手动 invocation 从不改变计划。 + +## 状态变化 {#state-changes} + +| 请求 | 规则 | +| --- | --- | +| 暂停 | 停止计划投递,保留排队中的计划 invocation 直到恢复,不停止任何 Session。手动 invocation 仍会投递。暂停已暂停的 Automation 会原样返回 | +| 恢复 | periodic Automation 从现在起取下一次触发。`once` Automation 在其计划 invocation 待处理或 `run_at` 在未来时恢复;否则返回 409 `conflict_error`。恢复已激活的 Automation 会原样返回 | +| 暂停或恢复已完成的 Automation | 409 `conflict_error` | +| 更新 | `expression` 和 `timezone` 只适用于 periodic Automation,`run_at` 只适用于 `once`;另一类型的字段返回 400 并指明该字段。过去的 `run_at` 返回 400。计划 invocation 产生后修改 `run_at` 返回 409 | +| 手动 invoke | 任何状态下都允许 | + +官方来源未说明、由 Core 自行选择的行为列在[清单](index.md#unverified-against-the-official-service)中。创建 invocation 会写入 `automation_invocation` 上的 `create` 审计条目;计划记录不写审计([写入来源](admin-api.md#write-provenance))。 diff --git a/contracts/agents-api/zh/core-metrics.md b/contracts/agents-api/zh/core-metrics.md index 1921f3526..63998d6d7 100644 --- a/contracts/agents-api/zh/core-metrics.md +++ b/contracts/agents-api/zh/core-metrics.md @@ -1,7 +1,7 @@ --- title: "Core 指标" source: contracts/agents-api/core-metrics.md -source_hash: bb6125b122a9cb46664db9d9536c0e799a3e1b789daed0f62941c0bc2bed6405 +source_hash: 8be639e4dfa07567ea3c79873f6b7f3aee5ffb9889dc506ac334a2ceb5f686b9 --- `GET /core/v1/metrics?range=1h|6h|24h|7d` 报告 Core 自身的健康状况:进程、执行队列与槽位、PostgreSQL 和后台任务。它要求 Core 密钥([Core 管理 API](admin-api.md))。 @@ -59,9 +59,10 @@ Core 解析自身的 cgroup,包括嵌套与子树挂载,并报告该 cgroup ## 后台任务 {#background-jobs} -`jobs` 列出 `scheduler`、`runtime_sampler`、`history_cleanup` 和 `audit_cleanup`。每项包含 `status`(首次运行前为 `unknown`,之后为 `ok`、`failing`,循环禁用或结束时为 `stopped`)、`last_run_at`(上次一轮结束时间)、`processed` 和 `failed`,后两者描述上次一轮。 +`jobs` 列出 `scheduler`、`runtime_sampler`、`automation_delivery`、`history_cleanup` 和 `audit_cleanup`。每项包含 `status`(首次运行前为 `unknown`,之后为 `ok`、`failing`,循环禁用或结束时为 `stopped`)、`last_run_at`(上次一轮结束时间)、`processed` 和 `failed`,后两者描述上次一轮。 - scheduler 的 `processed` 统计上次轮询选中的 Turn 和 Environment 工作。 - Runtime sampler 的 `processed` 和 `failed` 统计上次扫描成功观察和未能观察的目标。 +- Automation 投递任务的 `processed` 统计上一轮记录的触发和结算的 invocation 数;`failed` 统计存储失败和 panic 的投递。永久失败的 invocation 是已结算的记录,不计入。 - 清理任务的 `processed` 统计删除的行数。 -- scheduler 和清理任务失败时,`processed` 为 null,`failed` 为 1;`failed` 不估算丢失行数或失败 Turn 数量。 +- scheduler、Automation 投递任务和清理任务失败时,`processed` 为 null,`failed` 为 1;`failed` 不估算丢失行数或失败 Turn 数量。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index ac529d86a..c793a13fe 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 198b4deb88832ac7cdb28e67a0781469e44621b1baa2cccc54b7a2b9bceea36e +source_hash: 1bd8a9d1f30c1986d41a2f0dffe0dcd8bdf3570fbc62d777570ef6750c8a4022 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -43,7 +43,7 @@ Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源 | Vault Credentials | create, retrieve, update, list, delete | 已支持 `static_bearer` 和 `mcp_oauth`,含 `metadata` | [Vaults and Credentials](vaults.md) | | Files | create, retrieve, list, delete, content | 已支持 `purpose=user_data`;内容下载会被拒绝 | [Files and Skills](source-files.md) | | Skills and Skill versions | create, retrieve, update, list, delete, content | 已实现 | [Files and Skills](source-files.md) | -| Automations | 全部 10 个操作 | 不支持:404 `unsupported_operation` | [不支持的操作](wire-semantics.md#unsupported-operations) | +| Automations 和 invocations | 创建、检索、更新、列出、删除、暂停、恢复;invocation 的创建、检索、列出 | 已实现;`created_by` 始终为 `developer` | [Automations](automations.md) | | Environment 集合 | create, list(`/agents/environments`) | 不支持:404 `unsupported_operation`;Core 的 Environment 以 Session 为范围 | [不支持的操作](wire-semantics.md#unsupported-operations) | | Session traces | list | 不支持:404 `unsupported_operation` | [不支持的操作](wire-semantics.md#unsupported-operations) | @@ -130,6 +130,11 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 文件名长度为 1–1,024 字节;官方服务接受空文件名和更长的文件名。 - `SKILL.md` front matter 中 `name`、`description`、`license`、`compatibility` 和 `metadata` 以外的键会被拒绝;官方服务接受未知键([上传 bundle](source-files.md#upload-a-bundle))。 +**Automations**([Automations](automations.md)) + +- `existing_session` 目标的 `instructions` 为空时,创建和更新返回 400;固定版本 schema 允许 0 字节,而 Core 无法发送空消息。 +- `run_at` 大于 253402300799(9999 年)时返回 400;固定版本 schema 没有设上限。 + **Vaults 和 Credentials**([Vaults and Credentials](vaults.md)) - MiniMax Code 逐个运行公共函数调用,因此应用每次只看到一个 `function_call`。 @@ -198,6 +203,13 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - Session 更新何时绑定到排队中的 Turn:Core 在接受创建根 Turn 的输入时绑定该 Turn 的设置,因此更新之前已排队的 Turn 保持原有取值。 - Session 响应中“automatic review is not enabled”的含义:除 `auto_review` 外,Core 省略 `review`。 +- Automations 中固定版本来源未说明的部分([Automations](automations.md)): + - [指令投递](automations.md#targets)为一条输入消息。 + - [cron 语法与夏令时](automations.md#schedules)。 + - [停机、跳过、完成与暂停](automations.md#invocations)。 + - [冲突与拒绝的情形](automations.md#state-changes)。 + - [目标校验与保存的引用](automations.md#targets)。 + - [分页](automations.md):`limit` 截断与无法解析的 `after`。 - 当一个请求存在多项故障时的错误顺序,以及错误处理、默认值和载荷限制的总体一致性。 - Subagent 的函数调用:Core 将其作为运行该子级的根 Turn 的待执行操作,`turn_id` 为该 Turn,并把子级的 `function_call` Item 以 `agent.session.turn.item.added` 发布一次,`turn_id` 为子级 Turn ID,`output_index` 为 null;结果可以指定其中任一 Turn。该 Turn 为 `waiting`、Session 为 `requires_action`,根输出可继续,`call_id` 等于该子级 `function_call` Item 的 `call_id`。Claude Code 在根 Turn 收敛时才发布子级的 Item,晚于该待执行操作。 - 官方服务是否发出 Environment `pending` 以及何时发出:Core 在 hosted Environment 初始化开始时发出,而仅有的一个官方样本只显示了创建约 29 秒后的 `ready`。 diff --git a/contracts/agents-api/zh/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md index 1a0e27889..5e7b91f3c 100644 --- a/contracts/agents-api/zh/wire-semantics.md +++ b/contracts/agents-api/zh/wire-semantics.md @@ -1,7 +1,7 @@ --- title: "API 行为" source: contracts/agents-api/wire-semantics.md -source_hash: 73dcaa859ac6e54b4b51950922b8cb781daff8e159e718dddde363a8e089168a +source_hash: b0e9bfc53d015b5daacfc846ff8e1a27d3f6021f89f84131afdb1a892f2c868e --- 已锁定版本的 OpenAI Python SDK([upstream.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))定义了 `/v1` 路由、字段和类型。本页面说明这些类型未作规定之处 Core 的行为,例如状态码、错误字段、默认值和列表边界,以及 Core 与官方服务存在差异的地方。[OpenAI 兼容性](index.md) 列出了这些差异和尚存缺口;[Sessions, events and history](sessions-events.md)、[message content](message-content.md)、[Vaults](vaults.md)、[source Files and Skills](source-files.md) 和 [Environment files and Artifacts](environment-files.md) 分别负责各自资源的规则。 @@ -25,7 +25,7 @@ source_hash: 73dcaa859ac6e54b4b51950922b8cb781daff8e159e718dddde363a8e089168a ### 不支持的操作 {#unsupported-operations} -Automations 路由(共 10 个)、`POST` 和 `GET /agents/environments`,以及 `GET /agents/sessions/{session_id}/traces` 都已注册,因此与其他固定版本路由一样先通过 Beta 和身份验证检查,然后返回 404、代码 `unsupported_operation`、消息 "This API operation is not supported."。Environment 只存在于 Session 内部([归属](environments.md#resources-and-states)),Core 没有 Automations 或 trace 存储。 +`POST` 和 `GET /agents/environments`,以及 `GET /agents/sessions/{session_id}/traces` 都已注册,因此与其他固定版本路由一样先通过 Beta 和身份验证检查,然后返回 404、代码 `unsupported_operation`、消息 "This API operation is not supported."。Environment 只存在于 Session 内部([归属](environments.md#resources-and-states)),Core 没有 trace 存储。 创建 Agent、Vault、Credential、Environment 模板、Environment 文件或 Session 均返回 201,流式 Session 创建也是如此。对 Agent 或 Environment 模板发送空更新正文会推进 `updated_at`,但不会更改其他内容;时间戳精确到秒。 @@ -61,7 +61,7 @@ Beta 路由要求 `OpenAI-Beta` 标头中恰好有一个值,且该值必须等 | 顺序 | 情况 | 响应 | | --- | --- | --- | | 1 | `Content-Type` 缺失、不是 JSON 或格式错误,包括无正文的 `POST`。`application/json` 和 `application/*+json` 不区分大小写,并允许附带参数 | "expected request with Content-Type: application/json" | -| 2 | 正文超过路由限制:Session 创建和 Environment 模板为 16 MiB,文件创建采用 [Environment file](environment-files.md) 的限制,其他路由为 1 MiB | 413,代码 `request_too_large` | +| 2 | 正文超过路由限制:Session 创建、Environment 模板以及 Automation 创建和更新为 16 MiB,文件创建采用 [Environment file](environment-files.md) 的限制,其他路由为 1 MiB | 413,代码 `request_too_large` | | 3 | 无效 UTF-8 | "Invalid body: encountered a unicode decode error when parsing this JSON value. Please check the value to ensure it is valid unicode." | | 4 | JSON 格式错误、尾随数据、两个值、字节顺序标记、仅含空白字符的正文,或形成孤立 UTF-16 代理项的转义 | "Invalid body: failed to parse JSON value. Please check the value to ensure it is valid JSON. (Common errors include trailing commas, missing closing brackets, missing quotation marks, etc.)" | | 5 | 一个对象内任意深度出现重复键 | `"Invalid body: duplicate JSON key '' at ''. Duplicate JSON keys are not supported."` 路径使用 `.` 连接对象键并省略数组索引,例如 `metadata.k` 或 `tools.type`。键在反转义后按大小写敏感方式比较。报告文档顺序中首次出现的重复键。 | @@ -125,7 +125,7 @@ Vault 和 Credential 列表接受标量 `status`、`status[]` 条目或两者, | 列表 | 默认值 | 接受范围 | 其他值 | | --- | --- | --- | --- | -| Agents、Sessions、Items、Turn Items、Environment 模板、Vaults、Credentials、Subagent Items、Subagent Turn Items | 20 | 1–100 | 0 变为 1;大于 100 的值变为 100 | +| Agents、Sessions、Items、Turn Items、Environment 模板、Automations、Automation invocations、Vaults、Credentials、Subagent Items、Subagent Turn Items | 20 | 1–100 | 0 变为 1;大于 100 的值变为 100 | | Turns、Subagents、Subagent Turns、Artifacts | 20 | 1–100 | 400 `invalid_request_error`,"limit must be between 1 and 100" | | Skills、Skill 版本 | 20 | 0–100 | 0 返回空页面,其 `has_more` 表示游标后是否还有资源。负数:400 `integer_below_min_value`,param 为 `limit`。大于 100:400 `integer_above_max_value`,param 为 `limit` | | Files | 10000 | 1–10000 | 400,code 为 null,"limit must be between 1 and 10000." | diff --git a/docs.json b/docs.json index d6809997c..c603f4e10 100644 --- a/docs.json +++ b/docs.json @@ -61,6 +61,7 @@ "contracts/agents-api/subagents", "contracts/agents-api/environments", "contracts/agents-api/environment-files", + "contracts/agents-api/automations", "contracts/agents-api/source-files", "contracts/agents-api/vaults" ] diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index 7ff95356c..b06389a46 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -51,6 +51,7 @@ On a shared host, `-H @<(printf 'Authorization: Bearer %s\n' "$OPENAI_API_KEY")` | [Files](#files) | `/files`, `/agents/environments/{id}/files`, `/agents/sessions/{id}/artifacts` | Uploads, workspace files and outputs | | [Skills](#skills) | `/skills` | Versioned capability bundles | | [Environment Templates](#environment-templates) | `/agents/environments/templates` | Reusable workspace setup | +| [Automations](#automations) | `/agents/automations` | Scheduled Session creation or instructions | | [Vaults](#vaults) | `/vaults` | Write-only credentials for MCP servers | | Subagents | `/agents/sessions/{id}/subagents` | Read-only child work; see [Subagents](../../contracts/agents-api/subagents.md) | @@ -65,6 +66,7 @@ Core has the routes of the pinned SDK, listed in [upstream-routes.json](https:// | Stop the current Turn, or recover after a lost response | [Cancel](#cancel), [idempotency](#idempotency) | | Call your own code from the agent | [Function tools](#function-tools) | | Give the agent Skills, packages, files and setup commands | [Skills](#skills), [Environment Templates](#environment-templates) | +| Start a Session or send instructions on a schedule | [Automations](#automations) | | Connect an MCP server with credentials | [Vaults](#vaults) and [Execution tools](../../contracts/agents-api/execution-tools.md) | | Use Skill or Plugin directories on your own machine | [Local capability directories](../getting-started/self-hosted.md#local-capability-directories) | | Put files in the workspace, or download what the agent wrote | [Files](#files) | @@ -557,6 +559,26 @@ template = client.beta.agents.environments.templates.create( A Session freezes the template when it starts. Details: [Environment Templates](../../contracts/agents-api/environments.md#templates). +## Automations + +```python +automation = client.beta.agents.automations.create( + type="periodic", + name="Weekday report", + expression="0 9 * * 1-5", + timezone="Asia/Shanghai", + target={ + "type": "new_session", + "agent_id": agent.id, + "environment": {"type": "none"}, + "input": [{"role": "user", "content": [{"type": "input_text", "text": "Write the daily report."}]}], + }, +) +invocation = client.beta.agents.automations.invocations.create(automation.id) # deliver once now +``` + +Pause, resume and delete stop or end the schedule without touching Sessions. Each delivery is an invocation record whose `status` moves from `queued` to `completed` or `failed`; `session_id` names the Session it reached. Details and limits: [Automations](../../contracts/agents-api/automations.md). + ## Vaults Vaults hold credentials for HTTP MCP servers: a `static_bearer` token or an `mcp_oauth` token with optional refresh. Tokens are write-only. diff --git a/docs/zh/api/public-agent-api.md b/docs/zh/api/public-agent-api.md index 045faa3d1..f980de19f 100644 --- a/docs/zh/api/public-agent-api.md +++ b/docs/zh/api/public-agent-api.md @@ -1,7 +1,7 @@ --- title: "Agents API 指南" source: docs/api/public-agent-api.md -source_hash: 8c09bb8ae2cc51057298e4f6524e2145d897fcaaa7155a15c233a5267fe2768b +source_hash: 8846a755d119bc752f903715629d7be7c6aefef4498766c00889a714ac11d4b5 --- Core 在 `/v1` 提供 OpenAI Agents API。本指南用官方 OpenAI SDK 和普通 HTTP 两种方式演示每项常见任务,并说明 Core 与 OpenAI 的差异。如果尚未启动过 Session,请先阅读[运行第一个 Session](../getting-started/quickstart.md)。 @@ -53,6 +53,7 @@ agents /agents | [Files](#files) | `/files`、`/agents/environments/{id}/files`、`/agents/sessions/{id}/artifacts` | 上传内容、工作区文件和输出 | | [Skills](#skills) | `/skills` | 带版本的能力捆绑包 | | [Environment 模板](#environment-templates) | `/agents/environments/templates` | 可复用的工作区设置 | +| [Automations](#automations) | `/agents/automations` | 按计划创建 Session 或发送指令 | | [Vaults](#vaults) | `/vaults` | MCP 服务器的只写凭据 | | Subagents | `/agents/sessions/{id}/subagents` | 只读的子任务;请参阅 [子 Agent](../../../contracts/agents-api/zh/subagents.md) | @@ -67,6 +68,7 @@ Core 的路由与固定版本 SDK 中的路由一致,完整列表见 [upstream | 停止当前 Turn,或在响应丢失后恢复 | [取消](#cancel)、[幂等性](#idempotency) | | 从 Agent 调用你自己的代码 | [函数工具](#function-tools) | | 为 Agent 提供 Skills、软件包、文件和设置命令 | [Skills](#skills)、[Environment 模板](#environment-templates) | +| 按计划启动 Session 或发送指令 | [Automations](#automations) | | 连接需要凭据的 MCP 服务器 | [Vaults](#vaults) 和 [执行工具](../../../contracts/agents-api/zh/execution-tools.md) | | 在自己的计算机上使用 Skill 或 Plugin 目录 | [本地能力目录](../getting-started/self-hosted.md#local-capability-directories) | | 将文件放入工作区,或下载 Agent 生成的内容 | [Files](#files) | @@ -559,6 +561,26 @@ template = client.beta.agents.environments.templates.create( Session 启动时会冻结模板。详细信息请参阅 [Environment 模板](../../../contracts/agents-api/zh/environments.md#templates)。 +## Automations {#automations} + +```python +automation = client.beta.agents.automations.create( + type="periodic", + name="Weekday report", + expression="0 9 * * 1-5", + timezone="Asia/Shanghai", + target={ + "type": "new_session", + "agent_id": agent.id, + "environment": {"type": "none"}, + "input": [{"role": "user", "content": [{"type": "input_text", "text": "Write the daily report."}]}], + }, +) +invocation = client.beta.agents.automations.invocations.create(automation.id) # deliver once now +``` + +暂停、恢复和删除只会停止或结束计划,不影响 Session。每次投递都是一条 invocation 记录,其 `status` 从 `queued` 变为 `completed` 或 `failed`;`session_id` 是它到达的 Session。详细信息和限制:[Automations](../../../contracts/agents-api/zh/automations.md)。 + ## Vaults {#vaults} Vaults 保存 HTTP MCP 服务器的凭据:`static_bearer` 令牌,或支持可选刷新的 `mcp_oauth` 令牌。令牌为只写字段。 diff --git a/scripts/generate-public-api.py b/scripts/generate-public-api.py index 73b737652..bab980913 100644 --- a/scripts/generate-public-api.py +++ b/scripts/generate-public-api.py @@ -25,6 +25,8 @@ 'CreateEnvironmentTemplateParams': ('network',), 'UpdateEnvironmentTemplateParams': ('network',), 'CreateSessionEventsParams': (), + 'CreateAutomationParamsPeriodic': ('target',), 'CreateAutomationParamsOnce': ('target',), 'UpdateAutomationParams': ('target',), + 'AutomationTargetParamNewSession': ('agent', 'environment'), 'AutomationTargetParamExistingSession': (), } HTTP_METHODS = {'get', 'post', 'put', 'patch', 'delete', 'head', 'options'} @@ -231,7 +233,7 @@ def go_shape(document, schema, required=False): elif kind == 'array': fields = ['kind: arrayValue', 'items: &' + go_shape(document, value['items'])] elif kind == 'integer': - fields = ['kind: integerValue', f'minimum: {value["minimum"]}'] + fields = ['kind: integerValue', f'minimum: {value.get("minimum", "-1 << 63")}'] else: fields = [{'string': 'kind: stringValue', 'boolean': 'kind: booleanValue'}[kind]] if required: diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index cb54c39ea..10ff1ad47 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -29,6 +29,7 @@ Adapters' execution repositories require a `*pgunit.Lease` at construction, and Domain owners, each with its PostgreSQL adapter under `internal/persistence/postgres`: - `agents` (`agentpg`): saved Agents, their configuration merge and bounds, and the encrypted model-provider bundle bound to each Agent. +- `automations` (`automationpg`): Automations, schedules and invocation records; see [Automations](../../contracts/agents-api/automations.md). - `files` (`filepg`): source Files. - `projects` (`projectpg`): Projects, Project API keys and key resolution for authentication. `projects` validates Project and key names by rune count, separately from the byte limits on node names, and decides key issuance over the share-locked Project so an archive and an issuance never both commit. - `vaults` (`vaultpg`): Vaults and Credentials, the encryption of Credential secrets, OAuth access-token refresh, and the MCP credential selection that Session creation freezes and the Dispatcher's `Credentials` resolves into a bearer token. diff --git a/services/core/cmd/server/http_routes_test.go b/services/core/cmd/server/http_routes_test.go index 7ac68adbe..028db6ffd 100644 --- a/services/core/cmd/server/http_routes_test.go +++ b/services/core/cmd/server/http_routes_test.go @@ -16,6 +16,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/projects" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" @@ -87,7 +88,7 @@ func daemonComposition(t testing.TB) http.Handler { if err != nil { t.Fatal(err) } - apiHandler, err := api.NewHandler(api.Dependencies{ + apiHandler, _, err := api.NewHandler(api.Dependencies{ Engine: "codex", CoreKeys: admin, InstallationBindings: struct{ api.InstallationBindings }{}, Projects: struct{ api.Projects }{}, ProjectsReader: trapProjectsReader{keys: keys}, ModelProviders: struct{ api.ModelProviders }{}, ModelProvidersReader: struct{ api.ModelProvidersReader }{}, @@ -95,6 +96,7 @@ func daemonComposition(t testing.TB) http.Handler { Files: struct{ api.Files }{}, FilesReader: struct{ api.FilesReader }{}, Skills: struct{ api.Skills }{}, SkillsReader: struct{ api.SkillsReader }{}, Agents: struct{ api.Agents }{}, AgentsReader: struct{ api.AgentsReader }{}, + Automations: struct{ api.Automations }{}, AutomationsReader: struct{ automations.Reader }{}, EnvironmentTemplates: struct{ api.EnvironmentTemplates }{}, EnvironmentTemplatesReader: struct{ api.EnvironmentTemplatesReader }{}, Sessions: struct{ api.Sessions }{}, SessionsReader: struct{ api.SessionsReader }{}, diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index 799a2a834..afa79654a 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -31,10 +31,12 @@ import ( "os/signal" "syscall" "time" + _ "time/tzdata" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agents" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" @@ -46,6 +48,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/agentpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/automationpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/coremetricspg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/deploymentpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/filepg" @@ -317,8 +320,16 @@ func run(config processconfig.Config) error { } return metricPtr(int64(result.Observed)), int64(result.Failed), err }} + automationStore := automationpg.New(units, credentialKey) + automationService, err := automations.NewService(automationStore) + if err != nil { + return err + } + // The pass needs the handler, which needs the metrics that run it. + deliverAutomations := func(context.Context) (*int64, int64, error) { return nil, 0, nil } + automationDelivery := coremetrics.Periodic{ID: "automation_delivery", Every: time.Second, Run: func(ctx context.Context) (*int64, int64, error) { return deliverAutomations(ctx) }} metricsSource := &coreMetricsSource{store: coremetricspg.New(units), pool: pool, worker: worker, registry: registry} - metrics, err := coremetrics.New(processStartedAt, buildRevision, metricsSource, sampling, + metrics, err := coremetrics.New(processStartedAt, buildRevision, metricsSource, sampling, automationDelivery, prune("history_cleanup", 2*time.Second, history.Prune), prune("audit_cleanup", 5*time.Second, func(ctx context.Context) (int64, error) { return auditStore.DeleteExpiredWriteOperations(ctx, time.Now().Add(-config.WriteAuditRetention), 1000) @@ -326,10 +337,6 @@ func run(config processconfig.Config) error { if err != nil { return err } - metricsCtx, cancelMetrics := context.WithCancel(ctx) - metricsDone := make(chan struct{}) - go func() { defer close(metricsDone); metrics.Run(metricsCtx) }() - defer func() { cancelMetrics(); <-metricsDone }() fileStore := filepg.New(units) fileService, err := files.NewService(fileStore) if err != nil { @@ -345,6 +352,7 @@ func run(config processconfig.Config) error { EnvironmentTemplates: environmentTemplates, EnvironmentTemplatesReader: templateStore, Files: fileService, FilesReader: fileStore, Agents: agentService, AgentsReader: agentStore, + Automations: automationService, AutomationsReader: automationStore, Sessions: sessionService, SessionsReader: sessionStore, SessionCreation: sessionService, @@ -375,10 +383,14 @@ func run(config processconfig.Config) error { ConfigurationDiscovery: managedNodes.setup, }, } - apiHandler, err := api.NewHandler(deps) + apiHandler, deliverAutomations, err := api.NewHandler(deps) if err != nil { return err } + metricsCtx, cancelMetrics := context.WithCancel(ctx) + metricsDone := make(chan struct{}) + go func() { defer close(metricsDone); metrics.Run(metricsCtx) }() + defer func() { cancelMetrics(); <-metricsDone }() handler := serverHandler(apiHandler, &daemonRoutes{gateway: daemonHandler, enrollment: runtimeenrollment.EnrollmentHandler(sessionService), connection: runtimeenrollment.ConnectionHandler(sessionStore, registry), diff --git a/services/core/internal/api/automation_delivery.go b/services/core/internal/api/automation_delivery.go new file mode 100644 index 000000000..9c05c0635 --- /dev/null +++ b/services/core/internal/api/automation_delivery.go @@ -0,0 +1,131 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +const ( + // automationBatch bounds the deliveries one pass runs at once. + automationBatch = 16 + // automationDeliveryTimeout bounds one delivery. + automationDeliveryTimeout = 5 * time.Second +) + +// deliverAutomations is one pass: it records the due occurrences and delivers +// the queued invocations. A delivery that cannot finish yet stays queued for +// the next pass. It reports what it recorded and settled, and counts only +// storage failures as failed; an invocation that failed for good is a settled +// record, not a job failure. +func (h *Handler) deliverAutomations(ctx context.Context) (*int64, int64, error) { + var settled, failed atomic.Int64 + recorded, err := h.Automations.Record(ctx, time.Now()) + if err != nil { + log.Ctx(ctx).Error("Automation occurrences could not be recorded", "error", err) + failed.Add(1) + } + deliveries, err := h.Automations.Deliverable(ctx, automationBatch) + if err != nil { + return nil, 1, err + } + var wg sync.WaitGroup + for _, d := range deliveries { + wg.Add(1) + go func() { + defer wg.Done() + defer func() { + if p := recover(); p != nil { + log.Ctx(ctx).Error("Automation delivery panicked", "invocation_id", d.InvocationID, "panic", p) + failed.Add(1) + } + }() + outcome, done := h.deliverAutomation(ctx, d) + if !done { + return + } + if err := h.Automations.Settle(ctx, outcome); err != nil { + log.Ctx(ctx).Error("Automation invocation could not be settled", "error", err) + failed.Add(1) + return + } + settled.Add(1) + }() + } + wg.Wait() + processed := int64(recorded) + settled.Load() + return &processed, failed.Load(), nil +} + +// deliverAutomation runs one delivery through the code the public routes run. +// It returns the outcome to settle and false when the delivery stays queued. +func (h *Handler) deliverAutomation(ctx context.Context, d automations.Delivery) (automations.Outcome, bool) { + ctx, cancel := context.WithTimeout(context.WithValue(ctx, principalContextKey{}, d.Scope), automationDeliveryTimeout) + defer cancel() + response := httptest.NewRecorder() + outcome := automations.Outcome{InvocationID: d.InvocationID, Status: "completed"} + request, _ := http.NewRequestWithContext(ctx, http.MethodPost, "/v1/agents/sessions", bytes.NewReader(d.Request)) + if d.TargetType == "new_session" { + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Idempotency-Key", d.InvocationID) + h.createSession(response, request) + if response.Code == http.StatusCreated { + var session struct { + ID string `json:"id"` + } + if json.Unmarshal(response.Body.Bytes(), &session) != nil || session.ID == "" { + return outcome, false + } + outcome.SessionID = session.ID + return outcome, true + } + } else { + var target struct { + SessionID string `json:"session_id"` + Instructions string `json:"instructions"` + } + _ = json.Unmarshal(d.Request, &target) + inputs, err := executionInputs([]json.RawMessage{instructionsEvent(target.Instructions)}) + if err == nil { + _, err = h.Execution.InputAdmission.SubmitInputs(ctx, d.Scope.TenantID, target.SessionID, d.InvocationID, inputs) + } + if err == nil { + outcome.SessionID = target.SessionID + return outcome, true + } + if errors.Is(err, sessions.ErrTurnConflict) { + return outcome, false + } + writeInputError(response, request, err) + } + // A server error, a busy Turn or a timeout is temporary; only a client error is permanent. + if response.Code >= 500 || ctx.Err() != nil { + return outcome, false + } + var envelope struct { + Error struct { + Type string `json:"type"` + Code *string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + _ = json.Unmarshal(response.Body.Bytes(), &envelope) + outcome.Status, outcome.Code, outcome.Message = "failed", envelope.Error.Type, envelope.Error.Message + if envelope.Error.Code != nil { + outcome.Code = *envelope.Error.Code + } + if outcome.Code == "" { + outcome.Code, outcome.Message = "internal_error", "The delivery could not be completed." + } + return outcome, true +} diff --git a/services/core/internal/api/automation_delivery_test.go b/services/core/internal/api/automation_delivery_test.go new file mode 100644 index 000000000..3dda4e0fc --- /dev/null +++ b/services/core/internal/api/automation_delivery_test.go @@ -0,0 +1,35 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +// A server error or a busy Turn keeps the invocation queued; only a client +// error settles it as failed. +func TestAutomationDeliveryRetriesTemporaryErrors(t *testing.T) { + for _, tc := range []struct { + err error + done bool + }{ + {errors.New("database down"), false}, + {sessions.ErrTurnConflict, false}, + {sessions.ErrNotFound, true}, + } { + deps, fakes := testDependencies(t) + fakes.inputAdmission.submitInputs = func(context.Context, string, string, string, []sessions.Input) ([]sessions.InputReceipt, error) { + return nil, tc.err + } + h := &Handler{Dependencies: deps} + request, _ := json.Marshal(map[string]string{"session_id": "s", "instructions": "go"}) + outcome, done := h.deliverAutomation(context.Background(), automations.Delivery{InvocationID: "i", TargetType: "existing_session", Request: request}) + if done != tc.done || (done && outcome.Status != "failed") { + t.Fatalf("%v: done=%v outcome=%+v", tc.err, done, outcome) + } + } +} diff --git a/services/core/internal/api/automations.go b/services/core/internal/api/automations.go new file mode 100644 index 000000000..e6aaa01b6 --- /dev/null +++ b/services/core/internal/api/automations.go @@ -0,0 +1,439 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + "unicode/utf8" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" + "github.com/go-chi/chi/v5" +) + +// Automations runs the Automation use cases and the delivery pass. +type Automations interface { + Create(context.Context, automations.Automation) (automations.Automation, error) + Update(ctx context.Context, tenantID, id string, patch automations.Patch) (automations.Automation, error) + Pause(ctx context.Context, tenantID, id string) (automations.Automation, error) + Resume(ctx context.Context, tenantID, id string) (automations.Automation, error) + Delete(ctx context.Context, tenantID, id string) (string, error) + Invoke(ctx context.Context, tenantID, id string) (automations.Invocation, error) + Record(context.Context, time.Time) (int, error) + Deliverable(ctx context.Context, limit int) ([]automations.Delivery, error) + Settle(context.Context, automations.Outcome) error +} + +const maxInstructions = 1 << 20 + +func (h *Handler) registerAutomationRoutes(r chi.Router) { + const base = "/agents/automations" + r.Post(base, h.createAutomation) + r.Get(base, h.listAutomations) + r.Get(base+"/{automation_id}", h.getAutomation) + r.Post(base+"/{automation_id}", h.updateAutomation) + r.Delete(base+"/{automation_id}", h.deleteAutomation) + r.Post(base+"/{automation_id}/pause", h.pauseAutomation) + r.Post(base+"/{automation_id}/resume", h.resumeAutomation) + r.Post(base+"/{automation_id}/invocations", h.invokeAutomation) + r.Get(base+"/{automation_id}/invocations", h.listAutomationInvocations) + r.Get(base+"/{automation_id}/invocations/{invocation_id}", h.getAutomationInvocation) +} + +// automationTarget is a validated target: its type, the safe projection the +// resource shows and the request the delivery replays. +type automationTarget struct { + kind string + projection, contents json.RawMessage + // report writes the response for the error that came with the target. + report func(http.ResponseWriter, *http.Request, error) +} + +func (h *Handler) createAutomation(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONObjectLimit(w, r, 16*1024*1024, "Request exceeds 16 MiB.") + if !ok { + return + } + var kind struct { + Type json.RawMessage `json:"type"` + } + _ = json.Unmarshal(raw, &kind) + pinned := createAutomationParamsPeriodic + switch string(kind.Type) { + case `"once"`: + pinned = createAutomationParamsOnce + case `"periodic"`, "": + default: + if writeFieldError(w, checkValue("type", kind.Type, shape{kind: enumValue, values: []string{automations.Periodic, automations.Once}})) { + return + } + } + if writeFieldError(w, validateBody(raw, pinned)) { + return + } + var body struct { + Type string `json:"type"` + Name string `json:"name"` + Metadata map[string]string `json:"metadata"` + Expression string `json:"expression"` + Timezone string `json:"timezone"` + RunAt int64 `json:"run_at"` + Target json.RawMessage `json:"target"` + } + _ = json.Unmarshal(raw, &body) + if err := automationFields(&body.Name, body.Metadata); err != nil { + writeFieldError(w, err) + return + } + target, err := h.automationTarget(r, body.Target) + if err != nil { + target.report(w, r, err) + return + } + a := automations.Automation{TenantID: tenantID(r), Type: body.Type, Name: body.Name, Metadata: metadataOrEmpty(body.Metadata), Expression: body.Expression, Timezone: body.Timezone, TargetType: target.kind, Target: target.projection, Contents: target.contents} + if body.Type == automations.Once { + runAt, err := automations.RunAt(body.RunAt) + if err != nil { + writeAutomationsError(w, r, err) + return + } + a.RunAt = &runAt + } + created, err := h.Automations.Create(r.Context(), a) + if err != nil { + writeAutomationsError(w, r, err) + return + } + writeJSON(w, http.StatusCreated, created.Resource()) +} + +func (h *Handler) updateAutomation(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONObjectLimit(w, r, 16*1024*1024, "Request exceeds 16 MiB.") + if !ok || writeFieldError(w, validateBody(raw, updateAutomationParams)) { + return + } + var body struct { + Name *string `json:"name"` + Metadata map[string]string `json:"metadata"` + Expression *string `json:"expression"` + Timezone *string `json:"timezone"` + RunAt *int64 `json:"run_at"` + Target json.RawMessage `json:"target"` + } + _ = json.Unmarshal(raw, &body) + patch := automations.Patch{Name: body.Name, Metadata: body.Metadata, Expression: body.Expression, Timezone: body.Timezone} + if err := automationFields(body.Name, body.Metadata); err != nil { + writeFieldError(w, err) + return + } + if body.RunAt != nil { + runAt, err := automations.RunAt(*body.RunAt) + if err != nil { + writeAutomationsError(w, r, err) + return + } + patch.RunAt = &runAt + } + if body.Target != nil { + target, err := h.automationTarget(r, body.Target) + if err != nil { + target.report(w, r, err) + return + } + patch.TargetType, patch.Target, patch.Contents = target.kind, target.projection, target.contents + } + updated, err := h.Automations.Update(r.Context(), tenantID(r), chi.URLParam(r, "automation_id"), patch) + if err != nil { + writeAutomationsError(w, r, err) + return + } + writeJSON(w, http.StatusOK, updated.Resource()) +} + +func (h *Handler) getAutomation(w http.ResponseWriter, r *http.Request) { + a, err := h.AutomationsReader.Get(r.Context(), tenantID(r), chi.URLParam(r, "automation_id")) + if err != nil { + writeAutomationsError(w, r, err) + return + } + writeJSON(w, http.StatusOK, a.Resource()) +} + +func (h *Handler) listAutomations(w http.ResponseWriter, r *http.Request) { + options, ok := readClampedPage(w, r, "session_id") + if !ok { + return + } + // A supplied empty session_id matches no Automation, as an empty agent_id matches no Session. + var sessionID *string + if values, present := r.URL.Query()["session_id"]; present { + sessionID = &values[0] + } + page, err := h.AutomationsReader.List(r.Context(), tenantID(r), automations.ListQuery{After: options.after, Limit: options.limit, Ascending: options.ascending, SessionID: sessionID}) + if err != nil { + writeAutomationsError(w, r, err) + return + } + response := v1.AutomationList{Object: "list", Data: make([]v1.Automation, 0, len(page.Automations)), HasMore: page.HasMore} + for _, a := range page.Automations { + response.Data = append(response.Data, a.Resource()) + } + if len(response.Data) > 0 { + response.FirstID, response.LastID = &response.Data[0].ID, &response.Data[len(response.Data)-1].ID + } + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) deleteAutomation(w http.ResponseWriter, r *http.Request) { + id, err := h.Automations.Delete(r.Context(), tenantID(r), chi.URLParam(r, "automation_id")) + if err != nil { + writeAutomationsError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.AutomationDeleted{ID: id, Object: "agent.automation.deleted", Deleted: true}) +} + +func (h *Handler) pauseAutomation(w http.ResponseWriter, r *http.Request) { + h.changeAutomation(w, r, h.Automations.Pause) +} + +func (h *Handler) resumeAutomation(w http.ResponseWriter, r *http.Request) { + h.changeAutomation(w, r, h.Automations.Resume) +} + +func (h *Handler) changeAutomation(w http.ResponseWriter, r *http.Request, change func(context.Context, string, string) (automations.Automation, error)) { + a, err := change(r.Context(), tenantID(r), chi.URLParam(r, "automation_id")) + if err != nil { + writeAutomationsError(w, r, err) + return + } + writeJSON(w, http.StatusOK, a.Resource()) +} + +func (h *Handler) invokeAutomation(w http.ResponseWriter, r *http.Request) { + invocation, err := h.Automations.Invoke(r.Context(), tenantID(r), chi.URLParam(r, "automation_id")) + if err != nil { + writeAutomationsError(w, r, err) + return + } + writeJSON(w, http.StatusOK, invocation.Resource()) +} + +func (h *Handler) getAutomationInvocation(w http.ResponseWriter, r *http.Request) { + invocation, err := h.AutomationsReader.GetInvocation(r.Context(), tenantID(r), chi.URLParam(r, "automation_id"), chi.URLParam(r, "invocation_id")) + if err != nil { + writeAutomationsError(w, r, err) + return + } + writeJSON(w, http.StatusOK, invocation.Resource()) +} + +func (h *Handler) listAutomationInvocations(w http.ResponseWriter, r *http.Request) { + options, ok := readClampedPage(w, r) + if !ok { + return + } + page, err := h.AutomationsReader.ListInvocations(r.Context(), tenantID(r), chi.URLParam(r, "automation_id"), automations.ListQuery{After: options.after, Limit: options.limit, Ascending: options.ascending}) + if err != nil { + writeAutomationsError(w, r, err) + return + } + response := v1.AutomationInvocationList{Object: "list", Data: make([]v1.AutomationInvocation, 0, len(page.Invocations)), HasMore: page.HasMore} + for _, invocation := range page.Invocations { + response.Data = append(response.Data, invocation.Resource()) + } + if len(response.Data) > 0 { + response.FirstID, response.LastID = &response.Data[0].ID, &response.Data[len(response.Data)-1].ID + } + writeJSON(w, http.StatusOK, response) +} + +// automationFields checks the name and metadata of a request. A nil name is +// not checked. +func automationFields(name *string, values map[string]string) error { + if name != nil { + switch n := utf8.RuneCountInString(*name); { + case n < 1: + return &fieldError{param: "name", message: "Invalid 'name': empty string. Expected a string with minimum length 1, but got an empty string instead."} + case n > 256: + return &fieldError{param: "name", message: fmt.Sprintf("Invalid 'name': string too long. Expected a string with maximum length 256, but got a string with length %d instead.", n)} + } + } + return metadataFieldError(metadata.Validate(values)) +} + +// prefixTarget names a Session create field error under the target. +func prefixTarget(err error) error { + var field *fieldError + if !errors.As(err, &field) { + return err + } + param := "target" + if field.param != "" { + param += "." + field.param + } + return &fieldError{param: param, message: field.message, code: field.code} +} + +// writeTargetError reports a target that failed validation: a field error, or +// an error of the Session lookup rules the target reuses. +func writeTargetError(w http.ResponseWriter, r *http.Request, err error) { + if !writeFieldError(w, err) { + writeSessionsError(w, r, err) + } +} + +// automationTarget validates a target and builds what is saved. A new_session +// target is validated as the Session create body it replays; an +// existing_session target must name a Session and hold deliverable text. +func (h *Handler) automationTarget(r *http.Request, raw json.RawMessage) (automationTarget, error) { + var fields map[string]json.RawMessage + _ = json.Unmarshal(raw, &fields) + var kind string + _ = json.Unmarshal(fields["type"], &kind) + if kind == "existing_session" { + return h.existingSessionTarget(r, raw) + } + if kind != "new_session" && fields["type"] != nil { + if err := checkValue("target.type", fields["type"], shape{kind: enumValue, values: []string{"new_session", "existing_session"}}); err != nil { + return automationTarget{report: writeTargetError}, err + } + } + return h.newSessionTarget(r, raw, fields) +} + +func (h *Handler) existingSessionTarget(r *http.Request, raw json.RawMessage) (automationTarget, error) { + fail := automationTarget{report: writeTargetError} + if err := checkValue("target", raw, automationTargetParamExistingSession); err != nil { + return fail, err + } + var target struct { + SessionID string `json:"session_id"` + Instructions string `json:"instructions"` + } + _ = json.Unmarshal(raw, &target) + if utf8.RuneCountInString(target.Instructions) > maxInstructions { + return fail, &fieldError{param: "target.instructions", message: "instructions must not exceed 1048576 characters."} + } + inputs, err := executionInputs([]json.RawMessage{instructionsEvent(target.Instructions)}) + if err != nil { + return fail, &fieldError{param: "target.instructions", message: "instructions must be non-empty text this service can store."} + } + session, err := h.SessionsReader.GetSession(r.Context(), tenantID(r), target.SessionID) + if err != nil { + return fail, err + } + if h.Policy.ValidateEngineInputs(session.Engine, session.Configuration, inputs) != nil { + return fail, &fieldError{param: "target.instructions", message: "The Session's Harness does not accept these instructions."} + } + // The saved target names the Session by its canonical id, whatever form the request used. + contents, _ := json.Marshal(map[string]string{"session_id": session.ID, "instructions": target.Instructions}) + projection, _ := json.Marshal(v1.AutomationTarget{Type: "existing_session", SessionID: &session.ID, Instructions: &target.Instructions}) + return automationTarget{kind: "existing_session", projection: projection, contents: contents}, nil +} + +// instructionsEvent is the input an existing_session delivery sends: instructions as +// one user message. +func instructionsEvent(instructions string) json.RawMessage { + event, _ := json.Marshal(map[string]any{"type": "agent.session.input.message", "input": []v1.InputMessage{{Type: "message", Role: "user", Content: []v1.InputContent{{Type: "input_text", Text: &instructions}}}}}) + return event +} + +func (h *Handler) newSessionTarget(r *http.Request, raw json.RawMessage, fields map[string]json.RawMessage) (automationTarget, error) { + fail := automationTarget{report: writeTargetError} + if err := checkValue("target", raw, automationTargetParamNewSession); err != nil { + return fail, err + } + _, inline := fields["agent"] + _, saved := fields["agent_id"] + if inline == saved { + return fail, &fieldError{param: "target", message: "Provide exactly one of agent and agent_id."} + } + if inline { + var agent map[string]json.RawMessage + _ = json.Unmarshal(fields["agent"], &agent) + if agent["x_agents_core"] != nil { + return fail, &fieldError{param: "target.agent.x_agents_core", message: "x_agents_core is not supported in an automation target."} + } + } + delete(fields, "type") + contents, _ := json.Marshal(fields) + input, _, err := parseSessionRequest(contents) + if err != nil { + return fail, prefixTarget(err) + } + target := v1.AutomationTarget{Type: "new_session", AgentID: input.AgentID, Environment: automationEnvironment(input), VaultIDs: input.VaultIDs, Metadata: input.Metadata} + _ = json.Unmarshal(fields["input"], &target.Input) + for i := range target.Input { + target.Input[i].Type = "message" + } + plan, report, err := h.planSession(r.Context(), tenantID(r), "", &input) + if err != nil { + var required *modelProviderRequiredError + if errors.As(err, &required) { + report = func(w http.ResponseWriter, _ *http.Request, _ error) { + writeError(w, http.StatusBadRequest, "model_provider_required", required.message, "target") + } + } + return automationTarget{report: report}, prefixTarget(err) + } + if inline { + var cfg configuration + _ = json.Unmarshal(plan.configuration, &cfg) + agent := cfg.Agent + tools, err := resourceTools(cfg) + if err != nil { + return fail, err + } + target.Agent = &v1.AutomationAgentConfig{Model: agent.Model, Reasoning: v1.ReasoningResource{Effort: agent.Reasoning.Effort, Summary: agent.Reasoning.Summary}, Text: agent.Text, ServiceTier: agent.ServiceTier, Instructions: agent.Instructions, Tools: tools, MultiAgent: agent.MultiAgent, Compaction: agent.Compaction, Experimental: agent.Experimental} + } + projection, err := json.Marshal(target) + return automationTarget{kind: "new_session", projection: projection, contents: contents}, err +} + +// automationEnvironment projects the environment the request supplied. A member +// the request left out stays nil, which the resource shows as null: inherit. A +// self_hosted environment always lists its capability directories. +func automationEnvironment(input sessionRequest) *v1.AutomationEnvironment { + env := input.Environment + result := &v1.AutomationEnvironment{Type: env.Type} + var supplied map[string]json.RawMessage + _ = json.Unmarshal(input.originalEnvironment, &supplied) + has := func(name string) bool { value, ok := supplied[name]; return ok && string(value) != "null" } + if has("environment_template_id") { + result.EnvironmentTemplateID = &input.templateID + } + if has("packages") && env.Packages != nil { + packages := packageMetadata(env.Packages) + result.Packages = &packages + } + if has("network") && env.Network != nil { + result.Network = &v1.EnvironmentNetwork{Access: env.Network.Access, AllowedDomains: append([]string{}, env.Network.AllowedDomains...)} + } + if has("desktop") { + result.Desktop = json.RawMessage(`{"enabled":false}`) + } + if has("capability_directories") { + result.CapabilityDirectories = env.CapabilityDirectories + } + if result.CapabilityDirectories == nil && env.Type == "self_hosted" { + result.CapabilityDirectories = []string{} + } + if has("skills") { + result.Skills = env.Skills + } + if has("plugins") { + result.Plugins = env.Plugins + } + if has("files") { + result.Files = env.Files + } + if env.WorkspaceDirectory != "" { + result.WorkspaceDirectory = &env.WorkspaceDirectory + } + return result +} diff --git a/services/core/internal/api/dependencies.go b/services/core/internal/api/dependencies.go index 8feec2ab9..c3b70d4bf 100644 --- a/services/core/internal/api/dependencies.go +++ b/services/core/internal/api/dependencies.go @@ -1,10 +1,12 @@ package api import ( + "context" "errors" "fmt" "net/http" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -43,6 +45,8 @@ type Dependencies struct { EnvironmentTemplates EnvironmentTemplates Agents Agents AgentsReader AgentsReader + Automations Automations + AutomationsReader automations.Reader Sessions Sessions SessionsReader SessionsReader SessionCreation SessionCreation @@ -100,17 +104,19 @@ type Handler struct { requestLogs *requestLogs } -// NewHandler builds the HTTP handler from complete dependencies. -func NewHandler(deps Dependencies) (http.Handler, error) { +// NewHandler builds the HTTP handler and the pass that records due +// Automation occurrences and delivers queued invocations through the handler's +// Session create and input paths. +func NewHandler(deps Dependencies) (http.Handler, func(context.Context) (*int64, int64, error), error) { if err := deps.validate(); err != nil { - return nil, err + return nil, nil, err } deps.Installation.Object = "core.installation" h := &Handler{Dependencies: deps, harnesses: make(map[string]bool, len(deps.Harnesses))} for _, kind := range deps.Harnesses { h.harnesses[kind] = true } - return CanonicalPaths(h.routes()), nil + return CanonicalPaths(h.routes()), h.deliverAutomations, nil } func (d Dependencies) validate() error { @@ -130,6 +136,7 @@ func (d Dependencies) validate() error { field{"EnvironmentTemplates", d.EnvironmentTemplates}, field{"EnvironmentTemplatesReader", d.EnvironmentTemplatesReader}, field{"Skills", d.Skills}, field{"SkillsReader", d.SkillsReader}, field{"Agents", d.Agents}, field{"AgentsReader", d.AgentsReader}, + field{"Automations", d.Automations}, field{"AutomationsReader", d.AutomationsReader}, field{"Sessions", d.Sessions}, field{"SessionsReader", d.SessionsReader}, field{"SessionCreation", d.SessionCreation}, field{"SessionEvents", d.SessionEvents}, diff --git a/services/core/internal/api/dependencies_test.go b/services/core/internal/api/dependencies_test.go index 48262d85b..b1f7236d8 100644 --- a/services/core/internal/api/dependencies_test.go +++ b/services/core/internal/api/dependencies_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" ) @@ -106,6 +107,7 @@ func testDependencies(t testing.TB) (Dependencies, *testFakes) { EnvironmentTemplates: f.environmentTemplates, EnvironmentTemplatesReader: f.environmentTemplatesReader, Skills: f.skills, SkillsReader: f.skillsReader, Agents: f.agents, AgentsReader: f.agentsReader, + Automations: struct{ Automations }{}, AutomationsReader: struct{ automations.Reader }{}, Sessions: f.sessions, SessionsReader: f.sessionsReader, SessionCreation: f.sessionCreation, @@ -153,7 +155,7 @@ func coreKeys(t testing.TB, tokens ...string) *DeploymentAuthenticator { // NewHandler rejects them. func newTestHandler(t testing.TB, deps Dependencies) http.Handler { t.Helper() - h, err := NewHandler(deps) + h, _, err := NewHandler(deps) if err != nil { t.Fatal(err) } @@ -167,11 +169,11 @@ func noDeploymentModelProvider(context.Context, string) (*modelconfiguration.Sna func TestNewHandlerAcceptsCompleteDependencies(t *testing.T) { deps, _ := testDependencies(t) - if _, err := NewHandler(deps); err != nil { + if _, _, err := NewHandler(deps); err != nil { t.Fatal(err) } deps.Execution.NativeInstaller = &NativeInstaller{Version: "build", Base: "https://core.example/api/v1/agent-daemon/install/"} - if _, err := NewHandler(deps); err != nil { + if _, _, err := NewHandler(deps); err != nil { t.Fatal(err) } } @@ -200,7 +202,7 @@ func TestNewHandlerRejectsIncompleteDependencies(t *testing.T) { t.Run(test.missing, func(t *testing.T) { deps, f := testDependencies(t) test.change(&deps, f) - if h, err := NewHandler(deps); err == nil || h != nil || !strings.Contains(err.Error(), test.missing) { + if h, _, err := NewHandler(deps); err == nil || h != nil || !strings.Contains(err.Error(), test.missing) { t.Fatalf("NewHandler = %v, %v", h, err) } }) diff --git a/services/core/internal/api/errors_automations.go b/services/core/internal/api/errors_automations.go new file mode 100644 index 000000000..791b82456 --- /dev/null +++ b/services/core/internal/api/errors_automations.go @@ -0,0 +1,25 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" +) + +// writeAutomationsError maps the errors of Automation operations. +func writeAutomationsError(w http.ResponseWriter, r *http.Request, err error) { + var invalid *automations.InvalidError + var conflict *automations.ConflictError + switch { + case errors.Is(err, automations.ErrNotFound): + writeError(w, http.StatusNotFound, "not_found_error", "Resource not found.") + case errors.As(err, &invalid): + writeError(w, http.StatusBadRequest, "invalid_request_error", invalid.Message, invalid.Param) + case errors.As(err, &conflict): + writeError(w, http.StatusConflict, "conflict_error", conflict.Message) + case writeStoredDataError(w, r, err), writeTextValueError(w, r, err), writeAuditSourceError(w, r, err): + default: + writeInternalError(w, r) + } +} diff --git a/services/core/internal/api/handler.go b/services/core/internal/api/handler.go index 188221aff..c2e3593a1 100644 --- a/services/core/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "net/http" "reflect" @@ -118,14 +117,9 @@ func (h *Handler) routes() *chi.Mux { r.Get("/agents/sessions/{session_id}/artifacts/{artifact_id}/content", h.sessionArtifactContent) r.Head("/agents/sessions/{session_id}/artifacts/{artifact_id}/content", methodNotAllowed) r.Delete("/agents/sessions/{session_id}/artifacts/{artifact_id}", h.deleteSessionArtifact) + h.registerAutomationRoutes(r) // Official operations Core does not run answer like an unknown route. - const automations = "/agents/automations" for _, route := range []struct{ method, path string }{ - {http.MethodPost, automations}, {http.MethodGet, automations}, - {http.MethodGet, automations + "/{automation_id}"}, {http.MethodPost, automations + "/{automation_id}"}, {http.MethodDelete, automations + "/{automation_id}"}, - {http.MethodPost, automations + "/{automation_id}/pause"}, {http.MethodPost, automations + "/{automation_id}/resume"}, - {http.MethodPost, automations + "/{automation_id}/invocations"}, {http.MethodGet, automations + "/{automation_id}/invocations"}, - {http.MethodGet, automations + "/{automation_id}/invocations/{invocation_id}"}, {http.MethodPost, "/agents/environments"}, {http.MethodGet, "/agents/environments"}, {http.MethodGet, "/agents/sessions/{session_id}/traces"}, } { @@ -141,14 +135,12 @@ func unsupportedOperation(w http.ResponseWriter, _ *http.Request) { writeError(w, http.StatusNotFound, "unsupported_operation", "This API operation is not supported.") } -// createSession atomically reserves or admits initial text with the Session. -func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { - raw, ok := readJSONObjectLimit(w, r, 16*1024*1024, "Request exceeds 16 MiB.") - if !ok { - return - } - if writeFieldError(w, validateBody(raw, createAgentSessionParams)) { - return +// parseSessionRequest validates a Session create body and decodes it with its +// initial inputs. Every failure that is not a field error is a fieldError +// without a param, so callers report one shape. +func parseSessionRequest(raw []byte) (sessionRequest, []sessions.Input, error) { + if err := validateBody(raw, createAgentSessionParams); err != nil { + return sessionRequest{}, nil, err } var request decodedSessionRequest decoder := json.NewDecoder(bytes.NewReader(raw)) @@ -156,33 +148,46 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { // An unknown member, including a case variant such as Metadata, is rejected // before decoding; see inexactMember. if inexactMember(raw, reflect.TypeOf(request)) || decoder.Decode(&request) != nil { - writeError(w, http.StatusBadRequest, "invalid_request", "Request must be a JSON object containing supported fields.") - return + return sessionRequest{}, nil, &fieldError{code: "invalid_request", message: "Request must be a JSON object containing supported fields."} } input, err := request.validated() if err != nil { - if !writeFieldError(w, err) { - writeError(w, http.StatusBadRequest, "invalid_request", "Request fields have invalid types or null values.") + var field *fieldError + if !errors.As(err, &field) { + err = &fieldError{code: "invalid_request", message: "Request fields have invalid types or null values."} } - return - } - key := r.Header.Get("Idempotency-Key") - if key == "" { - key = uuid.NewString() + return input, nil, err } initialInputs, err := initialSessionInputs(input.Input) if err != nil { - writeSessionsError(w, r, err) - return + return input, nil, err } if input.Environment.Type == "none" && len(initialInputs) == 0 { - writeError(w, http.StatusBadRequest, "invalid_request_error", "conversation-only sessions currently require initial input") - return + return input, nil, &fieldError{code: "invalid_request_error", message: "conversation-only sessions currently require initial input"} } if input.Stream && input.Environment.Type != "self_hosted" && len(initialInputs) == 0 { - writeError(w, http.StatusBadRequest, "invalid_request_error", "streaming session creation requires initial input") + return input, nil, &fieldError{code: "invalid_request_error", message: "streaming session creation requires initial input"} + } + return input, initialInputs, nil +} + +// createSession atomically reserves or admits initial text with the Session. +func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONObjectLimit(w, r, 16*1024*1024, "Request exceeds 16 MiB.") + if !ok { + return + } + input, initialInputs, err := parseSessionRequest(raw) + if err != nil { + if !writeFieldError(w, err) { + writeSessionsError(w, r, err) + } return } + key := r.Header.Get("Idempotency-Key") + if key == "" { + key = uuid.NewString() + } creationRequest, err := sessionCreationRequest(input, initialInputs) if err != nil { writeSessionsError(w, r, err) @@ -191,82 +196,21 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { if h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { return } - if input.templateID != "" { - template, err := h.EnvironmentTemplatesReader.Resolve(r.Context(), tenantID(r), input.templateID) - if err != nil { - if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { - writeEnvironmentTemplatesError(w, r, err) - } - return - } - if err := applyTemplateEnvironment(&input, template); err != nil { - if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) && !writeFieldError(w, err) { - writeSessionsError(w, r, err) - } - return - } - } - saved, inheritedProvider, err := h.sessionAgentDefaults(r.Context(), tenantID(r), input) + plan, report, err := h.planSession(r.Context(), tenantID(r), key, &input) if err != nil { if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { - writeAgentsError(w, r, err) - } - return - } - err = h.prepareSessionModelConfiguration(r.Context(), &input, saved, inheritedProvider) - var configuration json.RawMessage - if err == nil { - configuration, err = resolve(input, tenantID(r), key, saved) - } - if err == nil { - configuration, err = freezeSessionHarnessConfig(configuration, input.resolvedHarnessConfig) - } - if err == nil { - configuration, err = h.bindSessionCredentials(r.Context(), tenantID(r), configuration) - if err != nil { - if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { - writeVaultsError(w, r, err) - } - return - } - } - selectedEngine := h.Engine - var provider *v1.ModelProviderInput - var providerSource v1.ExecutionSource - var deploymentRevision uuid.UUID - if err == nil { - selectedEngine, provider, providerSource, deploymentRevision, err = h.resolveSessionExecution(r.Context(), input, inheritedProvider, configuration) - } - if err == nil { - if invalid := h.Policy.ValidateSessionConfiguration(selectedEngine, configuration); invalid != nil { - err = fmt.Errorf("Harness %s does not support the requested Agent/environment configuration: %w", selectedEngine, invalid) - } - } - if err == nil { - err = validateSessionModelConfiguration(selectedEngine, provider, configuration) - } - if err != nil { - if h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { - return - } - var required *modelProviderRequiredError - switch { - case errors.As(err, &required): - writeError(w, http.StatusBadRequest, "model_provider_required", required.message, "x_agents_core.model_provider") - case writeStoredDataError(w, r, err): - case !writeFieldError(w, err): - writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) + report(w, r, err) } return } - executionConfiguration := sessionExecutionProjection(input, saved, inheritedProvider, provider, selectedEngine, configuration) + executionConfiguration := sessionExecutionProjection(input, plan.saved, plan.inheritedProvider, plan.provider, plan.engine, plan.configuration) createInput := sessions.CreateSession{ ExecutionConfiguration: &executionConfiguration, - ModelProvider: provider, - ModelProviderSource: providerSource, - DeploymentProviderRevision: deploymentRevision, + ModelProvider: plan.provider, + ModelProviderSource: plan.source, + DeploymentProviderRevision: plan.revision, Creator: sessionCreator(r), InitialFiles: input.initialFiles, Initialization: input.initialization, - Engine: selectedEngine, IdempotencyKey: key, Metadata: input.Metadata, Configuration: configuration, InitialInputs: initialInputs, CreationRequest: creationRequest, + Engine: plan.engine, IdempotencyKey: key, Metadata: input.Metadata, Configuration: plan.configuration, InitialInputs: initialInputs, CreationRequest: creationRequest, } create := h.SessionCreation.CreateSession if len(initialInputs) > 0 || input.Environment.Type == "openai_hosted" { diff --git a/services/core/internal/api/official_shapes.gen.go b/services/core/internal/api/official_shapes.gen.go index 729e78e9c..ac84ff905 100644 --- a/services/core/internal/api/official_shapes.gen.go +++ b/services/core/internal/api/official_shapes.gen.go @@ -556,4 +556,52 @@ var ( }, }}, required: true}}, }} + createAutomationParamsPeriodic = shape{kind: objectValue, members: []member{ + {"type", shape{kind: enumValue, values: []string{"periodic"}, required: true}}, + {"name", shape{kind: stringValue, required: true}}, + {"target", shape{kind: openObject, required: true}}, + {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}}}, + {"expression", shape{kind: stringValue, required: true}}, + {"timezone", shape{kind: stringValue, required: true}}, + }} + createAutomationParamsOnce = shape{kind: objectValue, members: []member{ + {"type", shape{kind: enumValue, values: []string{"once"}, required: true}}, + {"name", shape{kind: stringValue, required: true}}, + {"target", shape{kind: openObject, required: true}}, + {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}}}, + {"run_at", shape{kind: integerValue, minimum: -1 << 63, required: true}}, + }} + updateAutomationParams = shape{kind: objectValue, members: []member{ + {"name", shape{kind: stringValue}}, + {"target", shape{kind: openObject}}, + {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}}}, + {"expression", shape{kind: stringValue}}, + {"timezone", shape{kind: stringValue}}, + {"run_at", shape{kind: integerValue, minimum: -1 << 63}}, + }} + automationTargetParamNewSession = shape{kind: objectValue, members: []member{ + {"type", shape{kind: enumValue, values: []string{"new_session"}, required: true}}, + {"agent", shape{kind: openObject}}, + {"agent_id", shape{kind: stringValue}}, + {"environment", shape{kind: openObject, required: true}}, + {"input", shape{kind: arrayValue, items: &shape{kind: objectValue, members: []member{ + {"type", shape{kind: enumValue, values: []string{"message"}}}, + {"role", shape{kind: enumValue, values: []string{"user"}, required: true}}, + {"content", shape{kind: arrayValue, items: &shape{kind: unionValue, values: []string{"input_text", "input_image"}, variants: map[string][]member{ + "input_text": { + {"text", shape{kind: stringValue, required: true}}, + }, + "input_image": { + {"image_url", shape{kind: stringValue, required: true}}, + }, + }}, required: true}}, + }}, required: true}}, + {"vault_ids", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}, nullable: true}}, + }} + automationTargetParamExistingSession = shape{kind: objectValue, members: []member{ + {"type", shape{kind: enumValue, values: []string{"existing_session"}, required: true}}, + {"session_id", shape{kind: stringValue, required: true}}, + {"instructions", shape{kind: stringValue, required: true}}, + }} ) diff --git a/services/core/internal/api/routing_test.go b/services/core/internal/api/routing_test.go index 00fbcf5d9..f3a75cc87 100644 --- a/services/core/internal/api/routing_test.go +++ b/services/core/internal/api/routing_test.go @@ -349,7 +349,7 @@ func TestEveryRouteAuthenticatesItsCanonicalPath(t *testing.T) { t.Errorf("/v1 routes differ from the pinned set:\nserved %v\npinned %v", served, pinned.Routes) } // Official operations Core does not run answer like an unknown route. - for _, route := range []string{"POST /v1/agents/automations", "GET /v1/agents/automations/a/invocations/i", "GET /v1/agents/environments", "GET /v1/agents/sessions/s/traces"} { + for _, route := range []string{"POST /v1/agents/environments", "GET /v1/agents/environments", "GET /v1/agents/sessions/s/traces"} { method, target, _ := strings.Cut(route, " ") if got := serve(handler, method, target, "", withHeaders(project, beta)); got.Code != http.StatusNotFound || !strings.Contains(got.Body.String(), `"code":"unsupported_operation"`) { t.Errorf("%s = %d %s", route, got.Code, got.Body) diff --git a/services/core/internal/api/session_plan.go b/services/core/internal/api/session_plan.go new file mode 100644 index 000000000..34663f604 --- /dev/null +++ b/services/core/internal/api/session_plan.go @@ -0,0 +1,79 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/google/uuid" +) + +// sessionPlan is a Session configuration that passed every check Session create +// runs before it creates anything. +type sessionPlan struct { + saved *v1.SavedAgent + inheritedProvider, provider *v1.ModelProviderInput + source v1.ExecutionSource + revision uuid.UUID + engine string + configuration json.RawMessage +} + +// planSession resolves the environment template and the saved Agent, freezes +// the configuration, binds its credentials and checks it against the Harness +// and the model provider. Deployment readiness, admission and leases are not +// part of it. On an error, report writes the response for the stage that failed. +func (h *Handler) planSession(ctx context.Context, tenant, key string, input *sessionRequest) (p sessionPlan, report func(http.ResponseWriter, *http.Request, error), err error) { + if input.templateID != "" { + template, err := h.EnvironmentTemplatesReader.Resolve(ctx, tenant, input.templateID) + if err != nil { + return p, writeEnvironmentTemplatesError, err + } + if err := applyTemplateEnvironment(input, template); err != nil { + return p, func(w http.ResponseWriter, r *http.Request, err error) { + if !writeFieldError(w, err) { + writeSessionsError(w, r, err) + } + }, err + } + } + if p.saved, p.inheritedProvider, err = h.sessionAgentDefaults(ctx, tenant, *input); err != nil { + return p, writeAgentsError, err + } + err = h.prepareSessionModelConfiguration(ctx, input, p.saved, p.inheritedProvider) + if err == nil { + p.configuration, err = resolve(*input, tenant, key, p.saved) + } + if err == nil { + p.configuration, err = freezeSessionHarnessConfig(p.configuration, input.resolvedHarnessConfig) + } + if err == nil { + if p.configuration, err = h.bindSessionCredentials(ctx, tenant, p.configuration); err != nil { + return p, writeVaultsError, err + } + p.engine, p.provider, p.source, p.revision, err = h.resolveSessionExecution(ctx, *input, p.inheritedProvider, p.configuration) + } + if err == nil { + if invalid := h.Policy.ValidateSessionConfiguration(p.engine, p.configuration); invalid != nil { + err = fmt.Errorf("Harness %s does not support the requested Agent/environment configuration: %w", p.engine, invalid) + } + } + if err == nil { + err = validateSessionModelConfiguration(p.engine, p.provider, p.configuration) + } + return p, writeSessionConfigurationError, err +} + +func writeSessionConfigurationError(w http.ResponseWriter, r *http.Request, err error) { + var required *modelProviderRequiredError + switch { + case errors.As(err, &required): + writeError(w, http.StatusBadRequest, "model_provider_required", required.message, "x_agents_core.model_provider") + case writeStoredDataError(w, r, err): + case !writeFieldError(w, err): + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) + } +} diff --git a/services/core/internal/api/session_request.go b/services/core/internal/api/session_request.go index 2a1d2a8c4..6ebe519b8 100644 --- a/services/core/internal/api/session_request.go +++ b/services/core/internal/api/session_request.go @@ -3,6 +3,8 @@ package api import ( "bytes" "encoding/json" + "fmt" + "unicode/utf8" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" @@ -39,6 +41,9 @@ type sessionRequest struct { func (request decodedSessionRequest) validated() (sessionRequest, error) { input := sessionRequest{CreateSessionRequest: request.CreateSessionRequest, Input: request.Input} + if request.AgentID != nil && utf8.RuneCountInString(*request.AgentID) > 64 { + return input, &fieldError{param: "agent_id", message: fmt.Sprintf("Invalid 'agent_id': string too long. Expected a string with maximum length 64, but got a string with length %d instead.", utf8.RuneCountInString(*request.AgentID))} + } if len(request.Execution) > 0 && !bytes.Equal(bytes.TrimSpace(request.Execution), []byte("null")) { if decodeInputObject(request.Execution, &input.XAgentsCore, "model_provider", "harness_config", "environment") != nil { return input, sessions.ErrInvalidInput diff --git a/services/core/internal/api/session_response.go b/services/core/internal/api/session_response.go index f0cf6122a..c718aa0df 100644 --- a/services/core/internal/api/session_response.go +++ b/services/core/internal/api/session_response.go @@ -16,19 +16,9 @@ func sessionResponse(session sessions.Session, executorURL string) (v1.Session, if cfg.Agent.XAgentsCore != nil && session.Engine != "" { cfg.Agent.XAgentsCore = &v1.AgentsCore{Harness: session.Engine, HarnessConfig: cfg.Agent.XAgentsCore.HarnessConfig} } - // The pinned Session AgentTool resource union excludes the tool_search - // input declaration. Retain it in saved Agents and frozen execution input. - tools := make([]json.RawMessage, 0, len(cfg.Agent.Tools)) - for _, raw := range cfg.Agent.Tools { - var tool struct { - Type string `json:"type"` - } - if json.Unmarshal(raw, &tool) != nil { - return v1.Session{}, errors.New("unsupported stored tool configuration") - } - if tool.Type != "tool_search" { - tools = append(tools, projectedMCPCredential(withoutMCPHeaders(raw), cfg)) - } + tools, err := resourceTools(cfg) + if err != nil { + return v1.Session{}, err } cfg.Agent.Tools = tools environment, err := sessionEnvironment(session, cfg.Environment.Type, executorURL) @@ -155,3 +145,22 @@ func sessionEnvironment(session sessions.Session, kind, executorURL string) (v1. RemoteURL: executorURL, WorkspaceDirectory: cfg.WorkspaceDirectory, }, nil } + +// resourceTools projects the stored Agent tools onto the pinned resource union, +// which excludes the tool_search input declaration. Saved Agents and frozen +// execution input retain it. +func resourceTools(cfg configuration) ([]json.RawMessage, error) { + tools := make([]json.RawMessage, 0, len(cfg.Agent.Tools)) + for _, raw := range cfg.Agent.Tools { + var tool struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &tool) != nil { + return nil, errors.New("unsupported stored tool configuration") + } + if tool.Type != "tool_search" { + tools = append(tools, projectedMCPCredential(withoutMCPHeaders(raw), cfg)) + } + } + return tools, nil +} diff --git a/services/core/internal/automations/automation.go b/services/core/internal/automations/automation.go new file mode 100644 index 000000000..cdaaf0bcb --- /dev/null +++ b/services/core/internal/automations/automation.go @@ -0,0 +1,253 @@ +// Package automations owns Automations: saved schedules that deliver a target +// Session create or instructions, and the invocation records of each delivery. +// This package holds the rules; the PostgreSQL adapter stores them and the API +// package runs the deliveries. +package automations + +import ( + "encoding/json" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" +) + +// Automation statuses and types. +const ( + Active = "active" + Paused = "paused" + Completed = "completed" + + Periodic = "periodic" + Once = "once" +) + +// Automation is a saved schedule. Target is the safe projection of its target; +// Contents is the target request, set only on the way in, and the storage seals +// it. +type Automation struct { + ID, TenantID string + Type string + Name string + Metadata map[string]string + Status string + RunAt *time.Time // once + Expression string // periodic + Timezone string // periodic + // NextRunAt is the next occurrence to record while the Automation is active. + NextRunAt *time.Time + TargetType string + Target json.RawMessage + Contents json.RawMessage + CreatedAt time.Time + // Pending and Queued report a queued scheduled invocation and a queued + // invocation of any source. + Pending, Queued bool +} + +// Invocation is the delivery record of one occurrence or manual invocation. +type Invocation struct { + ID, AutomationID string + CreatedAt time.Time + ScheduledAt *time.Time + // Snapshot is the Automation resource at the occurrence; nil for a manual + // invocation. + Snapshot json.RawMessage + Status, SkipReason, SessionID, Code, Message string +} + +// Delivery is a queued invocation ready to deliver, with the Project that owns +// the Automation and the opened target request. +type Delivery struct { + InvocationID, TargetType string + Scope identity.Principal + Request json.RawMessage +} + +// Outcome settles a queued invocation. Status is completed or failed. +type Outcome struct { + InvocationID, Status, SessionID, Code, Message string +} + +// Patch is an update. Nil members keep the stored value. A target replaces the +// whole target and keeps its type. +type Patch struct { + Name *string + Metadata map[string]string + TargetType string + Target, Contents json.RawMessage + Expression, Timezone *string + RunAt *time.Time +} + +// maxRunAt is the last second of year 9999, the last a stored timestamp holds. +const maxRunAt = 253402300799 + +// RunAt converts a Unix timestamp in seconds, which must not pass year 9999. +func RunAt(unix int64) (time.Time, error) { + if unix > maxRunAt { + return time.Time{}, &InvalidError{"run_at", "run_at must not be later than 253402300799."} + } + return time.Unix(unix, 0), nil +} + +// Occurrence is what a due Automation records. +type Occurrence struct { + ScheduledAt time.Time + // Skipped reports an occurrence recorded as skipped because an earlier + // invocation is still queued. + Skipped bool + // Next is the Automation's next occurrence; nil for none. + Next *time.Time + // Completed reports a one-time Automation whose schedule has finished. + Completed bool +} + +// Occurrence decides the invocation of an active Automation whose NextRunAt is +// due. After downtime a periodic Automation records only its latest due +// occurrence. +func (a Automation) Occurrence(now time.Time) Occurrence { + o := Occurrence{ScheduledAt: *a.NextRunAt, Skipped: a.Queued} + if a.Type == Once { + o.Completed = o.Skipped + return o + } + schedule, err := ParseSchedule(a.Expression, a.Timezone) + for err == nil { + next, ok := schedule.Next(o.ScheduledAt) + if !ok { + break + } + if next.After(now) { + o.Next = &next + break + } + o.ScheduledAt = next + } + return o +} + +// nextRun is the first occurrence of a periodic Automation after now. +func (a Automation) nextRun(now time.Time) (*time.Time, error) { + schedule, err := ParseSchedule(a.Expression, a.Timezone) + if err != nil { + return nil, err + } + next, _ := schedule.Next(now) + return &next, nil +} + +// Pause stops scheduled delivery. A paused Automation stays paused. +func (a Automation) Pause() (Automation, error) { + if a.Status == Completed { + return a, &ConflictError{"A completed automation cannot be paused."} + } + a.Status = Paused + return a, nil +} + +// Resume restarts scheduled delivery. A one-time Automation without a pending +// scheduled invocation needs a future run_at. An active Automation is unchanged. +func (a Automation) Resume(now time.Time) (Automation, error) { + var err error + switch { + case a.Status == Completed: + return a, &ConflictError{"A completed automation cannot be resumed."} + case a.Status == Active: + return a, nil + case a.Type == Periodic: + a.NextRunAt, err = a.nextRun(now) + case a.Pending: + a.NextRunAt = nil + case a.RunAt.After(now): + a.NextRunAt = a.RunAt + default: + return a, &ConflictError{"A one-time automation without a pending invocation needs a future run_at to resume."} + } + a.Status = Active + return a, err +} + +// Apply applies an update. Timing fields must match the stored type, run_at +// must be in the future and cannot change once the scheduled invocation exists, +// and an active Automation recomputes its next occurrence. +func (a Automation) Apply(p Patch, now time.Time) (Automation, error) { + if p.Name != nil { + a.Name = *p.Name + } + if p.Metadata != nil { + a.Metadata = p.Metadata + } + if p.Target != nil { + if p.TargetType != a.TargetType { + return a, &InvalidError{"target.type", "target.type must remain " + a.TargetType + "."} + } + a.Target, a.Contents = p.Target, p.Contents + } + var err error + switch { + case a.Type == Once && (p.Expression != nil || p.Timezone != nil): + param := "expression" + if p.Expression == nil { + param = "timezone" + } + return a, &InvalidError{param, param + " applies only to periodic automations."} + case a.Type == Periodic && p.RunAt != nil: + return a, &InvalidError{"run_at", "run_at applies only to one-time automations."} + case p.RunAt != nil: + if !p.RunAt.After(now) { + return a, &InvalidError{"run_at", "run_at must be a future Unix timestamp."} + } + if a.Status == Completed || a.Pending { + return a, &ConflictError{"run_at cannot change once the scheduled invocation exists."} + } + a.RunAt = p.RunAt + if a.Status == Active { + a.NextRunAt = p.RunAt + } + case p.Expression != nil || p.Timezone != nil: + if p.Expression != nil { + a.Expression = *p.Expression + } + if p.Timezone != nil { + a.Timezone = *p.Timezone + } + var next *time.Time + if next, err = a.nextRun(now); err == nil && a.Status == Active { + a.NextRunAt = next + } + } + return a, err +} + +// Resource projects the Automation onto its public resource. +func (a Automation) Resource() v1.Automation { + resource := v1.Automation{Type: a.Type, ID: a.ID, Object: "agent.automation", CreatedAt: a.CreatedAt.Unix(), Name: a.Name, Target: a.Target, Metadata: a.Metadata, CreatedBy: v1.AutomationCreatedBy{Type: "developer"}, Status: a.Status} + if a.Type == Periodic { + resource.Expression, resource.Timezone = &a.Expression, &a.Timezone + } else { + runAt := a.RunAt.Unix() + resource.RunAt = &runAt + } + return resource +} + +// Resource projects the invocation onto its public resource. +func (i Invocation) Resource() v1.AutomationInvocation { + resource := v1.AutomationInvocation{ID: i.ID, Object: "agent.automation.invocation", AutomationID: i.AutomationID, CreatedAt: i.CreatedAt.Unix(), Source: v1.AutomationInvocationSource{Type: "manual"}, Status: i.Status} + if i.ScheduledAt != nil { + scheduled := i.ScheduledAt.Unix() + resource.ScheduledAt = &scheduled + resource.Source = v1.AutomationInvocationSource{Type: "automation", Automation: i.Snapshot} + } + if i.SessionID != "" { + resource.SessionID = &i.SessionID + } + if i.SkipReason != "" { + resource.SkipReason = &i.SkipReason + } + if i.Status == "failed" { + resource.Error = &v1.AutomationInvocationError{Code: i.Code, Message: i.Message} + } + return resource +} diff --git a/services/core/internal/automations/automation_test.go b/services/core/internal/automations/automation_test.go new file mode 100644 index 000000000..4c03626e4 --- /dev/null +++ b/services/core/internal/automations/automation_test.go @@ -0,0 +1,88 @@ +package automations + +import ( + "errors" + "testing" + "time" +) + +func at(s string) *time.Time { + t, _ := time.Parse(time.RFC3339, s) + return &t +} + +func TestOccurrence(t *testing.T) { + periodic := Automation{Type: Periodic, Status: Active, Expression: "0 * * * *", Timezone: "UTC", NextRunAt: at("2026-03-01T01:00:00Z")} + now := *at("2026-03-01T05:10:00Z") + if got := periodic.Occurrence(now); !got.ScheduledAt.Equal(*at("2026-03-01T05:00:00Z")) || got.Skipped || got.Next == nil || !got.Next.Equal(*at("2026-03-01T06:00:00Z")) { + t.Errorf("after downtime only the latest occurrence is recorded: %+v", got) + } + periodic.Queued = true + if got := periodic.Occurrence(now); !got.Skipped || got.Completed { + t.Errorf("a queued invocation skips the occurrence: %+v", got) + } + once := Automation{Type: Once, Status: Active, NextRunAt: at("2026-03-01T01:00:00Z")} + if got := once.Occurrence(now); got.Next != nil || got.Completed { + t.Errorf("a one-time occurrence has no next: %+v", got) + } + once.Queued = true + if got := once.Occurrence(now); !got.Skipped || !got.Completed { + t.Errorf("a skipped one-time occurrence completes it: %+v", got) + } +} + +func TestPauseResume(t *testing.T) { + now := *at("2026-03-01T00:00:00Z") + var conflict *ConflictError + if _, err := (Automation{Status: Completed}).Pause(); !errors.As(err, &conflict) { + t.Errorf("pausing a completed automation: %v", err) + } + if paused, err := (Automation{Status: Paused}).Pause(); err != nil || paused.Status != Paused { + t.Errorf("pausing a paused automation: %v", err) + } + if resumed, err := (Automation{Status: Active}).Resume(now); err != nil || resumed.Status != Active { + t.Errorf("resuming an active automation: %v", err) + } + once := Automation{Type: Once, Status: Paused, RunAt: at("2026-02-01T00:00:00Z")} + if _, err := once.Resume(now); !errors.As(err, &conflict) { + t.Errorf("resuming a past run_at without a pending invocation: %v", err) + } + once.Pending = true + if resumed, err := once.Resume(now); err != nil || resumed.Status != Active || resumed.NextRunAt != nil { + t.Errorf("resuming with a pending invocation: %+v %v", resumed, err) + } + once.Pending, once.RunAt = false, at("2026-04-01T00:00:00Z") + if resumed, err := once.Resume(now); err != nil || resumed.NextRunAt == nil { + t.Errorf("resuming with a future run_at: %+v %v", resumed, err) + } +} + +func TestApply(t *testing.T) { + now := *at("2026-03-01T00:00:00Z") + var invalid *InvalidError + var conflict *ConflictError + periodic := Automation{Type: Periodic, Status: Active, Expression: "0 * * * *", Timezone: "UTC", TargetType: "existing_session"} + if _, err := periodic.Apply(Patch{RunAt: at("2026-04-01T00:00:00Z")}, now); !errors.As(err, &invalid) || invalid.Param != "run_at" { + t.Errorf("run_at on a periodic automation: %v", err) + } + if _, err := periodic.Apply(Patch{TargetType: "new_session", Target: []byte(`{}`)}, now); !errors.As(err, &invalid) || invalid.Param != "target.type" { + t.Errorf("target of another type: %v", err) + } + updated, err := periodic.Apply(Patch{Expression: ptr("30 * * * *")}, now) + if err != nil || updated.NextRunAt == nil || !updated.NextRunAt.Equal(*at("2026-03-01T00:30:00Z")) { + t.Errorf("a new expression recomputes the next occurrence: %+v %v", updated, err) + } + once := Automation{Type: Once, Status: Active, RunAt: at("2026-05-01T00:00:00Z")} + if _, err := once.Apply(Patch{Expression: ptr("0 * * * *")}, now); !errors.As(err, &invalid) || invalid.Param != "expression" { + t.Errorf("expression on a one-time automation: %v", err) + } + if _, err := once.Apply(Patch{RunAt: at("2026-02-01T00:00:00Z")}, now); !errors.As(err, &invalid) { + t.Errorf("past run_at: %v", err) + } + once.Pending = true + if _, err := once.Apply(Patch{RunAt: at("2026-06-01T00:00:00Z")}, now); !errors.As(err, &conflict) { + t.Errorf("run_at after the scheduled invocation exists: %v", err) + } +} + +func ptr[T any](v T) *T { return &v } diff --git a/services/core/internal/automations/cron.go b/services/core/internal/automations/cron.go new file mode 100644 index 000000000..2658d7094 --- /dev/null +++ b/services/core/internal/automations/cron.go @@ -0,0 +1,185 @@ +package automations + +import ( + "fmt" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +// Schedule is a parsed five-field crontab(5) expression in a time zone. +type Schedule struct { + minutes, hours, days, months, weekdays uint64 + // dayOr reports day fields that both do not start with "*", so a day + // matches either. fixed reports the same of the minute and hour fields. + dayOr, fixed bool + location *time.Location +} + +var ( + monthNames = []string{"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"} + weekdayNames = []string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"} +) + +// ParseSchedule parses expression, which is minute, hour, day of month, month +// and day of week. Each field is "*" or a comma list of n, a-b, */s or a-b/s; +// months and weekdays also take three-letter names, and weekday 7 is Sunday. +// It rejects descriptors, seconds and an expression with no occurrence in the +// next five years. timezone is an IANA name. +func ParseSchedule(expression, timezone string) (*Schedule, error) { + fields := strings.Fields(expression) + if utf8.RuneCountInString(expression) > 256 || len(fields) != 5 { + return nil, &InvalidError{"expression", "expression must have five fields of at most 256 characters: minute, hour, day of month, month and day of week."} + } + if timezone == "" || timezone == "Local" || utf8.RuneCountInString(timezone) > 128 { + return nil, &InvalidError{"timezone", "timezone must be an IANA time zone name."} + } + location, err := time.LoadLocation(timezone) + if err != nil { + return nil, &InvalidError{"timezone", "timezone must be an IANA time zone name."} + } + s := &Schedule{location: location, dayOr: !strings.HasPrefix(fields[2], "*") && !strings.HasPrefix(fields[4], "*"), fixed: !strings.HasPrefix(fields[0], "*") && !strings.HasPrefix(fields[1], "*")} + for i, target := range []struct { + mask *uint64 + min, max int + names []string + first int + }{{&s.minutes, 0, 59, nil, 0}, {&s.hours, 0, 23, nil, 0}, {&s.days, 1, 31, nil, 0}, {&s.months, 1, 12, monthNames, 1}, {&s.weekdays, 0, 7, weekdayNames, 0}} { + if *target.mask, err = parseField(fields[i], target.min, target.max, target.names, target.first); err != nil { + return nil, &InvalidError{"expression", fmt.Sprintf("expression field %d %q is invalid.", i+1, fields[i])} + } + } + if s.weekdays&(1<<7) != 0 { + s.weekdays |= 1 + } + if _, ok := s.Next(time.Now()); !ok { + return nil, &InvalidError{"expression", "expression has no occurrence in the next five years."} + } + return s, nil +} + +func parseField(field string, min, max int, names []string, first int) (uint64, error) { + var mask uint64 + for _, part := range strings.Split(field, ",") { + bounds, step, stepped := strings.Cut(part, "/") + low, high := min, max + if bounds != "*" { + from, to, ranged := strings.Cut(bounds, "-") + var err error + if low, err = parseValue(from, names, first); err != nil { + return 0, err + } + if high = low; ranged { + if high, err = parseValue(to, names, first); err != nil { + return 0, err + } + } else if stepped { + return 0, fmt.Errorf("a step needs a range") + } + } + increment := 1 + if stepped { + var err error + if increment, err = strconv.Atoi(step); err != nil || increment < 1 { + return 0, fmt.Errorf("invalid step") + } + } + if low < min || high > max || low > high { + return 0, fmt.Errorf("out of range") + } + for v := low; v <= high; v += increment { + mask |= 1 << v + } + } + return mask, nil +} + +func parseValue(text string, names []string, first int) (int, error) { + for i, name := range names { + if strings.EqualFold(text, name) { + return first + i, nil + } + } + if text == "" || strings.TrimLeft(text, "0123456789") != "" { + return 0, fmt.Errorf("invalid value") + } + return strconv.Atoi(text) +} + +// Next returns the first occurrence after the given time, or false when there +// is none in the next five years. It follows crontab(5) around daylight saving +// changes. A fixed-time Schedule, whose minute and hour fields do not start +// with "*", runs a wall-clock time in a gap at the first instant after the gap +// and a repeated one only at its first instant. Any other Schedule runs at +// every instant whose wall-clock time matches: never in a gap, on both passes +// of a repeated hour. +func (s *Schedule) Next(after time.Time) (time.Time, bool) { + t := after.Truncate(time.Minute).Add(time.Minute) + limit := t.AddDate(5, 0, 0) + for t.Before(limit) { + w := t.In(s.location) + switch { + case s.months&(1<>'type' = 'existing_session' AND a.target->>'session_id' = sqlc.narg(session_id)::text)) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (NOT sqlc.arg(ascending)::boolean AND (a.created_at, a.id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (a.created_at, a.id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN a.created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN a.id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN a.created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN a.id END DESC +LIMIT sqlc.arg(page_limit); + +-- name: InsertManualInvocation :one +INSERT INTO automation_invocations (id, automation_id, status, session_id, delivery) +SELECT sqlc.arg(id), a.id, 'queued', (CASE WHEN a.target->>'type' = 'existing_session' THEN a.target->>'session_id' END)::uuid, a.target_contents FROM automations a WHERE a.tenant_id = sqlc.arg(tenant_id) AND a.id = sqlc.arg(automation_id) +RETURNING created_at, session_id; + +-- name: GetInvocation :one +SELECT i.id, i.automation_id, i.created_at, i.scheduled_at, i.automation_snapshot, i.status, i.skip_reason, i.session_id, i.error_code, i.error_message +FROM automation_invocations i JOIN automations a ON a.id = i.automation_id +WHERE a.tenant_id = $1 AND i.automation_id = $2 AND i.id = $3; + +-- name: ListInvocations :many +SELECT i.id, i.automation_id, i.created_at, i.scheduled_at, i.automation_snapshot, i.status, i.skip_reason, i.session_id, i.error_code, i.error_message +FROM automation_invocations i +WHERE i.automation_id = sqlc.arg(automation_id) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (NOT sqlc.arg(ascending)::boolean AND (i.created_at, i.id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (i.created_at, i.id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN i.created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN i.id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN i.created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN i.id END DESC +LIMIT sqlc.arg(page_limit); + +-- name: DueAutomationIDs :many +SELECT a.tenant_id, a.id FROM automations a +WHERE a.status = 'active' AND a.next_run_at <= sqlc.arg(now)::timestamptz + AND EXISTS (SELECT 1 FROM projects p WHERE p.tenant_id = a.tenant_id AND p.archived_at IS NULL) +ORDER BY a.next_run_at LIMIT sqlc.arg(page_limit); + +-- name: InsertScheduledInvocation :exec +INSERT INTO automation_invocations (id, automation_id, scheduled_at, automation_snapshot, status, skip_reason, session_id, delivery) +SELECT sqlc.arg(id), a.id, sqlc.arg(scheduled_at), sqlc.arg(automation_snapshot), sqlc.arg(status)::text, sqlc.narg(skip_reason)::text, + (CASE WHEN a.target->>'type' = 'existing_session' THEN a.target->>'session_id' END)::uuid, + CASE WHEN sqlc.arg(status)::text = 'queued' THEN a.target_contents END +FROM automations a WHERE a.id = sqlc.arg(automation_id) +ON CONFLICT (automation_id, scheduled_at) DO NOTHING; + +-- name: AdvanceAutomation :exec +UPDATE automations SET next_run_at = sqlc.narg(next_run_at), status = CASE WHEN sqlc.arg(completed)::boolean THEN 'completed' ELSE status END WHERE id = sqlc.arg(id); + +-- name: DeliverableInvocations :many +WITH picked AS ( + SELECT * FROM ( + SELECT DISTINCT ON (i.automation_id) i.id, i.automation_id, a.tenant_id, (a.target->>'type')::text AS target_type, i.delivery, i.created_at, i.attempted_at, + p.subject_kind, p.subject_id, s.organization_id, s.project_id + FROM automation_invocations i + JOIN automations a ON a.id = i.automation_id + JOIN projects p ON p.tenant_id = a.tenant_id AND p.archived_at IS NULL + JOIN execution_project_scopes s ON s.tenant_id = a.tenant_id + WHERE i.status = 'queued' AND (i.scheduled_at IS NULL OR a.status = 'active') + ORDER BY i.automation_id, i.created_at, i.id + ) oldest + ORDER BY attempted_at NULLS FIRST, created_at, id + LIMIT sqlc.arg(page_limit) +), marked AS ( + UPDATE automation_invocations u SET attempted_at = statement_timestamp() FROM picked WHERE u.id = picked.id +) +SELECT id, automation_id, tenant_id, target_type, delivery, subject_kind, subject_id, organization_id, project_id FROM picked; + +-- name: SettleInvocation :one +UPDATE automation_invocations SET status = $2, session_id = COALESCE(sqlc.narg(session_id), session_id), error_code = sqlc.narg(error_code), error_message = sqlc.narg(error_message), delivery = NULL +WHERE id = $1 AND status = 'queued' RETURNING automation_id, scheduled_at; + +-- name: CompleteOnceAutomation :exec +UPDATE automations SET status = 'completed', next_run_at = NULL WHERE id = $1 AND type = 'once'; diff --git a/services/core/internal/db/sqlc/automations.sql.go b/services/core/internal/db/sqlc/automations.sql.go new file mode 100644 index 000000000..30fb11fdf --- /dev/null +++ b/services/core/internal/db/sqlc/automations.sql.go @@ -0,0 +1,620 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: automations.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const advanceAutomation = `-- name: AdvanceAutomation :exec +UPDATE automations SET next_run_at = $1, status = CASE WHEN $2::boolean THEN 'completed' ELSE status END WHERE id = $3 +` + +type AdvanceAutomationParams struct { + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + Completed bool `json:"completed"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) AdvanceAutomation(ctx context.Context, arg AdvanceAutomationParams) error { + _, err := q.db.Exec(ctx, advanceAutomation, arg.NextRunAt, arg.Completed, arg.ID) + return err +} + +const automationQueue = `-- name: AutomationQueue :one +SELECT EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = $1::uuid AND i.status = 'queued' AND i.scheduled_at IS NOT NULL) AS pending, + EXISTS (SELECT 1 FROM automation_invocations i WHERE i.automation_id = $1::uuid AND i.status = 'queued') AS queued +` + +type AutomationQueueRow struct { + Pending bool `json:"pending"` + Queued bool `json:"queued"` +} + +// A statement after LockAutomation, so it reads what committed while the lock waited. +func (q *Queries) AutomationQueue(ctx context.Context, id pgtype.UUID) (AutomationQueueRow, error) { + row := q.db.QueryRow(ctx, automationQueue, id) + var i AutomationQueueRow + err := row.Scan(&i.Pending, &i.Queued) + return i, err +} + +const completeOnceAutomation = `-- name: CompleteOnceAutomation :exec +UPDATE automations SET status = 'completed', next_run_at = NULL WHERE id = $1 AND type = 'once' +` + +func (q *Queries) CompleteOnceAutomation(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, completeOnceAutomation, id) + return err +} + +const deleteAutomation = `-- name: DeleteAutomation :one +DELETE FROM automations WHERE tenant_id = $1 AND id = $2 RETURNING id +` + +type DeleteAutomationParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) DeleteAutomation(ctx context.Context, arg DeleteAutomationParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, deleteAutomation, arg.TenantID, arg.ID) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} + +const deliverableInvocations = `-- name: DeliverableInvocations :many +WITH picked AS ( + SELECT id, automation_id, tenant_id, target_type, delivery, created_at, attempted_at, subject_kind, subject_id, organization_id, project_id FROM ( + SELECT DISTINCT ON (i.automation_id) i.id, i.automation_id, a.tenant_id, (a.target->>'type')::text AS target_type, i.delivery, i.created_at, i.attempted_at, + p.subject_kind, p.subject_id, s.organization_id, s.project_id + FROM automation_invocations i + JOIN automations a ON a.id = i.automation_id + JOIN projects p ON p.tenant_id = a.tenant_id AND p.archived_at IS NULL + JOIN execution_project_scopes s ON s.tenant_id = a.tenant_id + WHERE i.status = 'queued' AND (i.scheduled_at IS NULL OR a.status = 'active') + ORDER BY i.automation_id, i.created_at, i.id + ) oldest + ORDER BY attempted_at NULLS FIRST, created_at, id + LIMIT $1 +), marked AS ( + UPDATE automation_invocations u SET attempted_at = statement_timestamp() FROM picked WHERE u.id = picked.id +) +SELECT id, automation_id, tenant_id, target_type, delivery, subject_kind, subject_id, organization_id, project_id FROM picked +` + +type DeliverableInvocationsRow struct { + ID pgtype.UUID `json:"id"` + AutomationID pgtype.UUID `json:"automation_id"` + TenantID pgtype.UUID `json:"tenant_id"` + TargetType string `json:"target_type"` + Delivery []byte `json:"delivery"` + SubjectKind string `json:"subject_kind"` + SubjectID string `json:"subject_id"` + OrganizationID string `json:"organization_id"` + ProjectID string `json:"project_id"` +} + +func (q *Queries) DeliverableInvocations(ctx context.Context, pageLimit int32) ([]DeliverableInvocationsRow, error) { + rows, err := q.db.Query(ctx, deliverableInvocations, pageLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []DeliverableInvocationsRow{} + for rows.Next() { + var i DeliverableInvocationsRow + if err := rows.Scan( + &i.ID, + &i.AutomationID, + &i.TenantID, + &i.TargetType, + &i.Delivery, + &i.SubjectKind, + &i.SubjectID, + &i.OrganizationID, + &i.ProjectID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const dueAutomationIDs = `-- name: DueAutomationIDs :many +SELECT a.tenant_id, a.id FROM automations a +WHERE a.status = 'active' AND a.next_run_at <= $1::timestamptz + AND EXISTS (SELECT 1 FROM projects p WHERE p.tenant_id = a.tenant_id AND p.archived_at IS NULL) +ORDER BY a.next_run_at LIMIT $2 +` + +type DueAutomationIDsParams struct { + Now pgtype.Timestamptz `json:"now"` + PageLimit int32 `json:"page_limit"` +} + +type DueAutomationIDsRow struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) DueAutomationIDs(ctx context.Context, arg DueAutomationIDsParams) ([]DueAutomationIDsRow, error) { + rows, err := q.db.Query(ctx, dueAutomationIDs, arg.Now, arg.PageLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []DueAutomationIDsRow{} + for rows.Next() { + var i DueAutomationIDsRow + if err := rows.Scan(&i.TenantID, &i.ID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getAutomation = `-- name: GetAutomation :one +SELECT a.id, a.tenant_id, a.type, a.name, a.metadata, a.status, a.run_at, a.expression, a.timezone, a.next_run_at, a.target, a.created_at +FROM automations a WHERE a.tenant_id = $1 AND a.id = $2 +` + +type GetAutomationParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type GetAutomationRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Type string `json:"type"` + Name string `json:"name"` + Metadata []byte `json:"metadata"` + Status string `json:"status"` + RunAt pgtype.Timestamptz `json:"run_at"` + Expression pgtype.Text `json:"expression"` + Timezone pgtype.Text `json:"timezone"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + Target []byte `json:"target"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) GetAutomation(ctx context.Context, arg GetAutomationParams) (GetAutomationRow, error) { + row := q.db.QueryRow(ctx, getAutomation, arg.TenantID, arg.ID) + var i GetAutomationRow + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Type, + &i.Name, + &i.Metadata, + &i.Status, + &i.RunAt, + &i.Expression, + &i.Timezone, + &i.NextRunAt, + &i.Target, + &i.CreatedAt, + ) + return i, err +} + +const getInvocation = `-- name: GetInvocation :one +SELECT i.id, i.automation_id, i.created_at, i.scheduled_at, i.automation_snapshot, i.status, i.skip_reason, i.session_id, i.error_code, i.error_message +FROM automation_invocations i JOIN automations a ON a.id = i.automation_id +WHERE a.tenant_id = $1 AND i.automation_id = $2 AND i.id = $3 +` + +type GetInvocationParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + AutomationID pgtype.UUID `json:"automation_id"` + ID pgtype.UUID `json:"id"` +} + +type GetInvocationRow struct { + ID pgtype.UUID `json:"id"` + AutomationID pgtype.UUID `json:"automation_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + AutomationSnapshot []byte `json:"automation_snapshot"` + Status string `json:"status"` + SkipReason pgtype.Text `json:"skip_reason"` + SessionID pgtype.UUID `json:"session_id"` + ErrorCode pgtype.Text `json:"error_code"` + ErrorMessage pgtype.Text `json:"error_message"` +} + +func (q *Queries) GetInvocation(ctx context.Context, arg GetInvocationParams) (GetInvocationRow, error) { + row := q.db.QueryRow(ctx, getInvocation, arg.TenantID, arg.AutomationID, arg.ID) + var i GetInvocationRow + err := row.Scan( + &i.ID, + &i.AutomationID, + &i.CreatedAt, + &i.ScheduledAt, + &i.AutomationSnapshot, + &i.Status, + &i.SkipReason, + &i.SessionID, + &i.ErrorCode, + &i.ErrorMessage, + ) + return i, err +} + +const insertAutomation = `-- name: InsertAutomation :one +INSERT INTO automations (id, tenant_id, type, name, metadata, status, run_at, expression, timezone, next_run_at, target, target_contents) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING created_at +` + +type InsertAutomationParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Type string `json:"type"` + Name string `json:"name"` + Metadata []byte `json:"metadata"` + Status string `json:"status"` + RunAt pgtype.Timestamptz `json:"run_at"` + Expression pgtype.Text `json:"expression"` + Timezone pgtype.Text `json:"timezone"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + Target []byte `json:"target"` + TargetContents []byte `json:"target_contents"` +} + +func (q *Queries) InsertAutomation(ctx context.Context, arg InsertAutomationParams) (pgtype.Timestamptz, error) { + row := q.db.QueryRow(ctx, insertAutomation, + arg.ID, + arg.TenantID, + arg.Type, + arg.Name, + arg.Metadata, + arg.Status, + arg.RunAt, + arg.Expression, + arg.Timezone, + arg.NextRunAt, + arg.Target, + arg.TargetContents, + ) + var created_at pgtype.Timestamptz + err := row.Scan(&created_at) + return created_at, err +} + +const insertManualInvocation = `-- name: InsertManualInvocation :one +INSERT INTO automation_invocations (id, automation_id, status, session_id, delivery) +SELECT $1, a.id, 'queued', (CASE WHEN a.target->>'type' = 'existing_session' THEN a.target->>'session_id' END)::uuid, a.target_contents FROM automations a WHERE a.tenant_id = $2 AND a.id = $3 +RETURNING created_at, session_id +` + +type InsertManualInvocationParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + AutomationID pgtype.UUID `json:"automation_id"` +} + +type InsertManualInvocationRow struct { + CreatedAt pgtype.Timestamptz `json:"created_at"` + SessionID pgtype.UUID `json:"session_id"` +} + +func (q *Queries) InsertManualInvocation(ctx context.Context, arg InsertManualInvocationParams) (InsertManualInvocationRow, error) { + row := q.db.QueryRow(ctx, insertManualInvocation, arg.ID, arg.TenantID, arg.AutomationID) + var i InsertManualInvocationRow + err := row.Scan(&i.CreatedAt, &i.SessionID) + return i, err +} + +const insertScheduledInvocation = `-- name: InsertScheduledInvocation :exec +INSERT INTO automation_invocations (id, automation_id, scheduled_at, automation_snapshot, status, skip_reason, session_id, delivery) +SELECT $1, a.id, $2, $3, $4::text, $5::text, + (CASE WHEN a.target->>'type' = 'existing_session' THEN a.target->>'session_id' END)::uuid, + CASE WHEN $4::text = 'queued' THEN a.target_contents END +FROM automations a WHERE a.id = $6 +ON CONFLICT (automation_id, scheduled_at) DO NOTHING +` + +type InsertScheduledInvocationParams struct { + ID pgtype.UUID `json:"id"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + AutomationSnapshot []byte `json:"automation_snapshot"` + Status string `json:"status"` + SkipReason pgtype.Text `json:"skip_reason"` + AutomationID pgtype.UUID `json:"automation_id"` +} + +func (q *Queries) InsertScheduledInvocation(ctx context.Context, arg InsertScheduledInvocationParams) error { + _, err := q.db.Exec(ctx, insertScheduledInvocation, + arg.ID, + arg.ScheduledAt, + arg.AutomationSnapshot, + arg.Status, + arg.SkipReason, + arg.AutomationID, + ) + return err +} + +const listAutomations = `-- name: ListAutomations :many +SELECT a.id, a.tenant_id, a.type, a.name, a.metadata, a.status, a.run_at, a.expression, a.timezone, a.next_run_at, a.target, a.created_at +FROM automations a +WHERE a.tenant_id = $1 + AND ($2::text IS NULL OR (a.target->>'type' = 'existing_session' AND a.target->>'session_id' = $2::text)) + AND ($3::timestamptz IS NULL + OR (NOT $4::boolean AND (a.created_at, a.id) < ($3::timestamptz, $5::uuid)) + OR ($4::boolean AND (a.created_at, a.id) > ($3::timestamptz, $5::uuid))) +ORDER BY + CASE WHEN $4::boolean THEN a.created_at END ASC, + CASE WHEN $4::boolean THEN a.id END ASC, + CASE WHEN NOT $4::boolean THEN a.created_at END DESC, + CASE WHEN NOT $4::boolean THEN a.id END DESC +LIMIT $6 +` + +type ListAutomationsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.Text `json:"session_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +type ListAutomationsRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Type string `json:"type"` + Name string `json:"name"` + Metadata []byte `json:"metadata"` + Status string `json:"status"` + RunAt pgtype.Timestamptz `json:"run_at"` + Expression pgtype.Text `json:"expression"` + Timezone pgtype.Text `json:"timezone"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + Target []byte `json:"target"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) ListAutomations(ctx context.Context, arg ListAutomationsParams) ([]ListAutomationsRow, error) { + rows, err := q.db.Query(ctx, listAutomations, + arg.TenantID, + arg.SessionID, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListAutomationsRow{} + for rows.Next() { + var i ListAutomationsRow + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.Type, + &i.Name, + &i.Metadata, + &i.Status, + &i.RunAt, + &i.Expression, + &i.Timezone, + &i.NextRunAt, + &i.Target, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listInvocations = `-- name: ListInvocations :many +SELECT i.id, i.automation_id, i.created_at, i.scheduled_at, i.automation_snapshot, i.status, i.skip_reason, i.session_id, i.error_code, i.error_message +FROM automation_invocations i +WHERE i.automation_id = $1 + AND ($2::timestamptz IS NULL + OR (NOT $3::boolean AND (i.created_at, i.id) < ($2::timestamptz, $4::uuid)) + OR ($3::boolean AND (i.created_at, i.id) > ($2::timestamptz, $4::uuid))) +ORDER BY + CASE WHEN $3::boolean THEN i.created_at END ASC, + CASE WHEN $3::boolean THEN i.id END ASC, + CASE WHEN NOT $3::boolean THEN i.created_at END DESC, + CASE WHEN NOT $3::boolean THEN i.id END DESC +LIMIT $5 +` + +type ListInvocationsParams struct { + AutomationID pgtype.UUID `json:"automation_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +type ListInvocationsRow struct { + ID pgtype.UUID `json:"id"` + AutomationID pgtype.UUID `json:"automation_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + AutomationSnapshot []byte `json:"automation_snapshot"` + Status string `json:"status"` + SkipReason pgtype.Text `json:"skip_reason"` + SessionID pgtype.UUID `json:"session_id"` + ErrorCode pgtype.Text `json:"error_code"` + ErrorMessage pgtype.Text `json:"error_message"` +} + +func (q *Queries) ListInvocations(ctx context.Context, arg ListInvocationsParams) ([]ListInvocationsRow, error) { + rows, err := q.db.Query(ctx, listInvocations, + arg.AutomationID, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListInvocationsRow{} + for rows.Next() { + var i ListInvocationsRow + if err := rows.Scan( + &i.ID, + &i.AutomationID, + &i.CreatedAt, + &i.ScheduledAt, + &i.AutomationSnapshot, + &i.Status, + &i.SkipReason, + &i.SessionID, + &i.ErrorCode, + &i.ErrorMessage, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const lockAutomation = `-- name: LockAutomation :one +SELECT a.id, a.tenant_id, a.type, a.name, a.metadata, a.status, a.run_at, a.expression, a.timezone, a.next_run_at, a.target, a.created_at +FROM automations a WHERE a.tenant_id = $1 AND a.id = $2 FOR UPDATE +` + +type LockAutomationParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type LockAutomationRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Type string `json:"type"` + Name string `json:"name"` + Metadata []byte `json:"metadata"` + Status string `json:"status"` + RunAt pgtype.Timestamptz `json:"run_at"` + Expression pgtype.Text `json:"expression"` + Timezone pgtype.Text `json:"timezone"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + Target []byte `json:"target"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) LockAutomation(ctx context.Context, arg LockAutomationParams) (LockAutomationRow, error) { + row := q.db.QueryRow(ctx, lockAutomation, arg.TenantID, arg.ID) + var i LockAutomationRow + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Type, + &i.Name, + &i.Metadata, + &i.Status, + &i.RunAt, + &i.Expression, + &i.Timezone, + &i.NextRunAt, + &i.Target, + &i.CreatedAt, + ) + return i, err +} + +const settleInvocation = `-- name: SettleInvocation :one +UPDATE automation_invocations SET status = $2, session_id = COALESCE($3, session_id), error_code = $4, error_message = $5, delivery = NULL +WHERE id = $1 AND status = 'queued' RETURNING automation_id, scheduled_at +` + +type SettleInvocationParams struct { + ID pgtype.UUID `json:"id"` + Status string `json:"status"` + SessionID pgtype.UUID `json:"session_id"` + ErrorCode pgtype.Text `json:"error_code"` + ErrorMessage pgtype.Text `json:"error_message"` +} + +type SettleInvocationRow struct { + AutomationID pgtype.UUID `json:"automation_id"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` +} + +func (q *Queries) SettleInvocation(ctx context.Context, arg SettleInvocationParams) (SettleInvocationRow, error) { + row := q.db.QueryRow(ctx, settleInvocation, + arg.ID, + arg.Status, + arg.SessionID, + arg.ErrorCode, + arg.ErrorMessage, + ) + var i SettleInvocationRow + err := row.Scan(&i.AutomationID, &i.ScheduledAt) + return i, err +} + +const updateAutomation = `-- name: UpdateAutomation :exec +UPDATE automations SET name = $3, metadata = $4, status = $5, run_at = $6, expression = $7, timezone = $8, next_run_at = $9, + target = CASE WHEN $10::boolean THEN $11::jsonb ELSE target END, + target_contents = CASE WHEN $10::boolean THEN $12::bytea ELSE target_contents END +WHERE tenant_id = $1 AND id = $2 +` + +type UpdateAutomationParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Metadata []byte `json:"metadata"` + Status string `json:"status"` + RunAt pgtype.Timestamptz `json:"run_at"` + Expression pgtype.Text `json:"expression"` + Timezone pgtype.Text `json:"timezone"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + SetTarget bool `json:"set_target"` + Target []byte `json:"target"` + TargetContents []byte `json:"target_contents"` +} + +func (q *Queries) UpdateAutomation(ctx context.Context, arg UpdateAutomationParams) error { + _, err := q.db.Exec(ctx, updateAutomation, + arg.TenantID, + arg.ID, + arg.Name, + arg.Metadata, + arg.Status, + arg.RunAt, + arg.Expression, + arg.Timezone, + arg.NextRunAt, + arg.SetTarget, + arg.Target, + arg.TargetContents, + ) + return err +} diff --git a/services/core/internal/db/sqlc/models.go b/services/core/internal/db/sqlc/models.go index c046b4d95..3dee63fc7 100644 --- a/services/core/internal/db/sqlc/models.go +++ b/services/core/internal/db/sqlc/models.go @@ -36,6 +36,37 @@ type AgentModelExecution struct { EncryptedConfig []byte `json:"encrypted_config"` } +type Automation struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Type string `json:"type"` + Name string `json:"name"` + Metadata []byte `json:"metadata"` + Status string `json:"status"` + RunAt pgtype.Timestamptz `json:"run_at"` + Expression pgtype.Text `json:"expression"` + Timezone pgtype.Text `json:"timezone"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + Target []byte `json:"target"` + TargetContents []byte `json:"target_contents"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AutomationInvocation struct { + ID pgtype.UUID `json:"id"` + AutomationID pgtype.UUID `json:"automation_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + AutomationSnapshot []byte `json:"automation_snapshot"` + Status string `json:"status"` + SkipReason pgtype.Text `json:"skip_reason"` + SessionID pgtype.UUID `json:"session_id"` + ErrorCode pgtype.Text `json:"error_code"` + ErrorMessage pgtype.Text `json:"error_message"` + Delivery []byte `json:"delivery"` + AttemptedAt pgtype.Timestamptz `json:"attempted_at"` +} + type DeploymentModelProvider struct { Harness string `json:"harness"` Protocol string `json:"protocol"` diff --git a/services/core/internal/execution/environment_admission.go b/services/core/internal/execution/environment_admission.go index 5e19b55e9..266591c6a 100644 --- a/services/core/internal/execution/environment_admission.go +++ b/services/core/internal/execution/environment_admission.go @@ -49,7 +49,7 @@ func (w *Worker) validateEnvironmentAdmission(ctx context.Context, engine string } func (w *Worker) validateCreation(ctx context.Context, input sessions.CreateSession) error { - if err := w.dispatcher.validateEngineInputs(input.Engine, input.Configuration, input.InitialInputs); err != nil { + if err := w.dispatcher.ValidateEngineInputs(input.Engine, input.Configuration, input.InitialInputs); err != nil { return err } if preparedEnvironmentConfiguration(input.Configuration) { diff --git a/services/core/internal/execution/support.go b/services/core/internal/execution/support.go index 07ce721b2..5fe34b3fd 100644 --- a/services/core/internal/execution/support.go +++ b/services/core/internal/execution/support.go @@ -68,7 +68,7 @@ func (p Policy) canAdmitInputs(engine string, configuration json.RawMessage) boo return p.ValidateSessionConfiguration(engine, configuration) == nil } -func (p Policy) validateEngineInputs(engine string, configuration json.RawMessage, inputs []sessions.Input) error { +func (p Policy) ValidateEngineInputs(engine string, configuration json.RawMessage, inputs []sessions.Input) error { declaration, ok := p.Harnesses().Lookup(engine) if !ok { return sessions.ErrInvalidInput diff --git a/services/core/internal/execution/worker.go b/services/core/internal/execution/worker.go index 0d48162cc..9d946dba7 100644 --- a/services/core/internal/execution/worker.go +++ b/services/core/internal/execution/worker.go @@ -117,7 +117,7 @@ func (w *Worker) SubmitInputs(ctx context.Context, tenant, session, key string, if err != nil { return nil, err } - if err := w.dispatcher.validateEngineInputs(value.Engine, value.Configuration, inputs); err != nil { + if err := w.dispatcher.ValidateEngineInputs(value.Engine, value.Configuration, inputs); err != nil { return nil, err } if err := w.checkInputCredentials(ctx, value, key, inputs); err != nil { diff --git a/services/core/internal/persistence/postgres/automationpg/store.go b/services/core/internal/persistence/postgres/automationpg/store.go new file mode 100644 index 000000000..1454044fe --- /dev/null +++ b/services/core/internal/persistence/postgres/automationpg/store.go @@ -0,0 +1,406 @@ +// Package automationpg stores Automations and their invocations in PostgreSQL. +// The target request, which may hold confidential Session configuration, is +// sealed with the credential key and bound to its tenant and Automation. A +// queued invocation carries its own sealed copy until it settles. +package automationpg + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// maxDue bounds the occurrences one Record call records. +const maxDue = 100 + +// Store implements automations.Storage and automations.Reader. +type Store struct { + pool *pgunit.Pool + cipher *credentialcrypto.Cipher +} + +var ( + _ automations.Storage = (*Store)(nil) + _ automations.Reader = (*Store)(nil) +) + +func New(pool *pgunit.Pool, cipher *credentialcrypto.Cipher) *Store { + return &Store{pool: pool, cipher: cipher} +} + +func (s *Store) Create(ctx context.Context, a automations.Automation) (automations.Automation, error) { + tenant, err := pgunit.ParseID(a.TenantID) + if err != nil { + return a, automations.ErrNotFound + } + id := uuid.New() + a.ID = id.String() + contents, err := s.cipher.SealAutomationTarget(a.Contents, a.TenantID, a.ID) + if err != nil { + return a, err + } + metadata, err := json.Marshal(a.Metadata) + if err != nil { + return a, err + } + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + created, err := q.InsertAutomation(ctx, sqlc.InsertAutomationParams{ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, Type: a.Type, Name: a.Name, Metadata: metadata, Status: a.Status, + RunAt: timestamp(a.RunAt), Expression: text(a.Expression), Timezone: text(a.Timezone), NextRunAt: timestamp(a.NextRunAt), Target: a.Target, TargetContents: contents}) + if err != nil { + return err + } + a.CreatedAt = created.Time + return auditpg.RecordWriteAudit(ctx, q, a.TenantID, writeaudit.ActionCreate, writeaudit.ResourceAutomation, a.ID, "", writeaudit.Resource{Type: writeaudit.ResourceAutomation, ID: a.ID}) + }) + a.Contents = nil + return a, storageError(err) +} + +func (s *Store) Update(ctx context.Context, tenantID, id string, change func(automations.Automation) (automations.Automation, error)) (automations.Automation, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return automations.Automation{}, automations.ErrNotFound + } + var result automations.Automation + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + current, err := lock(ctx, q, tenant, pgunit.PathID(id)) + if err != nil { + return err + } + if result, err = change(current); err != nil { + return err + } + metadata, err := json.Marshal(result.Metadata) + if err != nil { + return err + } + var contents []byte + if result.Contents != nil { + if contents, err = s.cipher.SealAutomationTarget(result.Contents, tenantID, result.ID); err != nil { + return err + } + } + if err := q.UpdateAutomation(ctx, sqlc.UpdateAutomationParams{TenantID: tenant, ID: pgunit.PathID(id), Name: result.Name, Metadata: metadata, Status: result.Status, RunAt: timestamp(result.RunAt), + Expression: text(result.Expression), Timezone: text(result.Timezone), NextRunAt: timestamp(result.NextRunAt), SetTarget: result.Contents != nil, Target: result.Target, TargetContents: contents}); err != nil { + return err + } + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionUpdate, writeaudit.ResourceAutomation, result.ID, "") + }) + result.Contents = nil + return result, storageError(err) +} + +func (s *Store) Delete(ctx context.Context, tenantID, id string) (string, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return "", automations.ErrNotFound + } + var deleted string + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + row, err := q.DeleteAutomation(ctx, sqlc.DeleteAutomationParams{TenantID: tenant, ID: pgunit.PathID(id)}) + if err != nil { + return err + } + deleted = uuidString(row) + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionDelete, writeaudit.ResourceAutomation, deleted, "") + }) + return deleted, storageError(err) +} + +func (s *Store) Invoke(ctx context.Context, tenantID, id string) (automations.Invocation, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return automations.Invocation{}, automations.ErrNotFound + } + automationID, invocationID := pgunit.PathID(id), uuid.New() + result := automations.Invocation{ID: invocationID.String(), AutomationID: uuidString(automationID), Status: "queued"} + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + created, err := q.InsertManualInvocation(ctx, sqlc.InsertManualInvocationParams{ID: pgtype.UUID{Bytes: invocationID, Valid: true}, TenantID: tenant, AutomationID: automationID}) + if err != nil { + return err + } + result.CreatedAt, result.SessionID = created.CreatedAt.Time, uuidString(created.SessionID) + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionCreate, writeaudit.ResourceAutomationInvocation, result.ID, result.AutomationID, writeaudit.Resource{Type: writeaudit.ResourceAutomationInvocation, ID: result.ID, ParentID: result.AutomationID}) + }) + return result, storageError(err) +} + +// Record records the occurrence of each due active Automation of a live +// Project and advances it, each in its own transaction so that one failing +// Automation stops no other. The unique scheduled time keeps an occurrence +// single across concurrent passes. +func (s *Store) Record(ctx context.Context, now time.Time) (int, error) { + due, err := s.pool.Queries().DueAutomationIDs(ctx, sqlc.DueAutomationIDsParams{Now: timestamp(&now), PageLimit: maxDue}) + if err != nil { + return 0, err + } + recorded := 0 + var failures []error + for _, d := range due { + switch ok, err := s.record(ctx, d.TenantID, d.ID, now); { + case err != nil: + failures = append(failures, err) + case ok: + recorded++ + } + } + return recorded, errors.Join(failures...) +} + +// record locks one Automation, rechecks that it is still due and records it. +func (s *Store) record(ctx context.Context, tenant, id pgtype.UUID, now time.Time) (recorded bool, err error) { + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + a, err := lock(ctx, q, tenant, id) + if errors.Is(err, pgx.ErrNoRows) { + return nil // deleted since the pass listed it + } + if err != nil { + return err + } + if a.Status != automations.Active || a.NextRunAt == nil || a.NextRunAt.After(now) { + return nil + } + snapshot, err := json.Marshal(a.Resource()) + if err != nil { + return err + } + occurrence := a.Occurrence(now) + invocation := sqlc.InsertScheduledInvocationParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, AutomationID: id, ScheduledAt: timestamp(&occurrence.ScheduledAt), AutomationSnapshot: snapshot, Status: "queued"} + if occurrence.Skipped { + invocation.Status, invocation.SkipReason = "skipped", text("pending_delivery") + } + if err := q.InsertScheduledInvocation(ctx, invocation); err != nil { + return err + } + recorded = true + return q.AdvanceAutomation(ctx, sqlc.AdvanceAutomationParams{ID: id, NextRunAt: timestamp(occurrence.Next), Completed: occurrence.Completed}) + }) + return recorded, err +} + +func (s *Store) Deliverable(ctx context.Context, limit int) ([]automations.Delivery, error) { + rows, err := s.pool.Queries().DeliverableInvocations(ctx, int32(limit)) + if err != nil { + return nil, err + } + deliveries := make([]automations.Delivery, 0, len(rows)) + for _, row := range rows { + tenant, id := uuidString(row.TenantID), uuidString(row.AutomationID) + request, err := s.cipher.OpenAutomationTarget(row.Delivery, tenant, id) + if err != nil { + log.Ctx(ctx).Error("Automation target could not be opened", "automation_id", id, "error", err) + if err := s.Settle(ctx, automations.Outcome{InvocationID: uuidString(row.ID), Status: "failed", Code: "internal_error", Message: "The saved target could not be opened."}); err != nil { + return nil, err + } + continue + } + deliveries = append(deliveries, automations.Delivery{InvocationID: uuidString(row.ID), TargetType: row.TargetType, Request: request, + Scope: identity.Principal{ProjectScope: identity.ProjectScope{TenantID: tenant, OrganizationID: row.OrganizationID, ProjectID: row.ProjectID}, SubjectKind: row.SubjectKind, SubjectID: row.SubjectID}}) + } + return deliveries, nil +} + +func (s *Store) Settle(ctx context.Context, outcome automations.Outcome) error { + id, _ := pgunit.ParseID(outcome.InvocationID) + params := sqlc.SettleInvocationParams{ID: id, Status: outcome.Status, ErrorCode: text(outcome.Code), ErrorMessage: text(outcome.Message)} + if session, err := pgunit.ParseID(outcome.SessionID); err == nil { + params.SessionID = session + } + return s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + settled, err := q.SettleInvocation(ctx, params) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil || !settled.ScheduledAt.Valid { + return err + } + return q.CompleteOnceAutomation(ctx, settled.AutomationID) + }) +} + +func (s *Store) Get(ctx context.Context, tenantID, id string) (automations.Automation, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return automations.Automation{}, automations.ErrNotFound + } + row, err := s.pool.Queries().GetAutomation(ctx, sqlc.GetAutomationParams{TenantID: tenant, ID: pgunit.PathID(id)}) + if err != nil { + return automations.Automation{}, storageError(err) + } + return automation(row) +} + +// List reads the cursor and the page in one snapshot. +func (s *Store) List(ctx context.Context, tenantID string, query automations.ListQuery) (automations.Page, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return automations.Page{}, automations.ErrNotFound + } + params := sqlc.ListAutomationsParams{TenantID: tenant, PageLimit: int32(query.Limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: query.Ascending} + if query.SessionID != nil { + params.SessionID = pgtype.Text{String: *query.SessionID, Valid: true} + } + var rows []sqlc.ListAutomationsRow + err = s.pool.Snapshot(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + if query.After != "" { + after, err := q.GetAutomation(ctx, sqlc.GetAutomationParams{TenantID: tenant, ID: pgunit.PathID(pgunit.LookupCursor(query.After))}) + if err != nil { + return err + } + params.AfterCreated, params.AfterID = after.CreatedAt, after.ID + } + var err error + rows, err = q.ListAutomations(ctx, params) + return err + }) + if err != nil { + return automations.Page{}, storageError(err) + } + page := automations.Page{Automations: make([]automations.Automation, 0, min(query.Limit, len(rows))), HasMore: len(rows) > query.Limit} + for _, row := range rows[:min(query.Limit, len(rows))] { + a, err := automation(sqlc.GetAutomationRow(row)) + if err != nil { + return automations.Page{}, err + } + page.Automations = append(page.Automations, a) + } + return page, nil +} + +func (s *Store) GetInvocation(ctx context.Context, tenantID, id, invocationID string) (automations.Invocation, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return automations.Invocation{}, automations.ErrNotFound + } + row, err := s.pool.Queries().GetInvocation(ctx, sqlc.GetInvocationParams{TenantID: tenant, AutomationID: pgunit.PathID(id), ID: pgunit.PathID(invocationID)}) + if err != nil { + return automations.Invocation{}, storageError(err) + } + return invocation(row), nil +} + +// ListInvocations reads the Automation, the cursor and the page in one +// snapshot, so a missing Automation answers before an unresolved cursor. +func (s *Store) ListInvocations(ctx context.Context, tenantID, id string, query automations.ListQuery) (automations.InvocationPage, error) { + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return automations.InvocationPage{}, automations.ErrNotFound + } + automationID := pgunit.PathID(id) + params := sqlc.ListInvocationsParams{AutomationID: automationID, PageLimit: int32(query.Limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: query.Ascending} + var rows []sqlc.ListInvocationsRow + err = s.pool.Snapshot(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + if _, err := q.GetAutomation(ctx, sqlc.GetAutomationParams{TenantID: tenant, ID: automationID}); err != nil { + return err + } + if query.After != "" { + after, err := q.GetInvocation(ctx, sqlc.GetInvocationParams{TenantID: tenant, AutomationID: automationID, ID: pgunit.PathID(pgunit.LookupCursor(query.After))}) + if err != nil { + return err + } + params.AfterCreated, params.AfterID = after.CreatedAt, after.ID + } + var err error + rows, err = q.ListInvocations(ctx, params) + return err + }) + if err != nil { + return automations.InvocationPage{}, storageError(err) + } + page := automations.InvocationPage{Invocations: make([]automations.Invocation, 0, min(query.Limit, len(rows))), HasMore: len(rows) > query.Limit} + for _, row := range rows[:min(query.Limit, len(rows))] { + page.Invocations = append(page.Invocations, invocation(sqlc.GetInvocationRow(row))) + } + return page, nil +} + +// lock locks one Automation and only then reads its queue, so the queue +// reflects every commit made while the lock waited. +func lock(ctx context.Context, q *sqlc.Queries, tenant, id pgtype.UUID) (automations.Automation, error) { + row, err := q.LockAutomation(ctx, sqlc.LockAutomationParams{TenantID: tenant, ID: id}) + if err != nil { + return automations.Automation{}, err + } + a, err := automation(sqlc.GetAutomationRow(row)) + if err != nil { + return a, err + } + queue, err := q.AutomationQueue(ctx, row.ID) + a.Pending, a.Queued = queue.Pending, queue.Queued + return a, err +} + +func automation(row sqlc.GetAutomationRow) (automations.Automation, error) { + a := automations.Automation{ID: uuidString(row.ID), TenantID: uuidString(row.TenantID), Type: row.Type, Name: row.Name, Status: row.Status, RunAt: timePtr(row.RunAt), + Expression: row.Expression.String, Timezone: row.Timezone.String, NextRunAt: timePtr(row.NextRunAt), Target: row.Target, CreatedAt: row.CreatedAt.Time} + var target struct { + Type string `json:"type"` + } + if json.Unmarshal(row.Metadata, &a.Metadata) != nil || json.Unmarshal(row.Target, &target) != nil { + return a, errors.New("invalid stored automation") + } + a.TargetType = target.Type + return a, nil +} + +func invocation(row sqlc.GetInvocationRow) automations.Invocation { + return automations.Invocation{ID: uuidString(row.ID), AutomationID: uuidString(row.AutomationID), CreatedAt: row.CreatedAt.Time, ScheduledAt: timePtr(row.ScheduledAt), Snapshot: row.AutomationSnapshot, + Status: row.Status, SkipReason: row.SkipReason.String, SessionID: uuidString(row.SessionID), Code: row.ErrorCode.String, Message: row.ErrorMessage.String} +} + +func uuidString(id pgtype.UUID) string { + if !id.Valid { + return "" + } + return uuid.UUID(id.Bytes).String() +} + +func timestamp(t *time.Time) pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{} + } + return pgtype.Timestamptz{Time: *t, Valid: true} +} + +func timePtr(t pgtype.Timestamptz) *time.Time { + if !t.Valid { + return nil + } + return &t.Time +} + +func text(value string) pgtype.Text { return pgtype.Text{String: value, Valid: value != ""} } + +// storageError translates the database outcomes callers act on: no row is +// ErrNotFound and text PostgreSQL cannot store is textvalue.ErrUnstorable. +func storageError(err error) error { + switch { + case errors.Is(err, pgx.ErrNoRows): + return automations.ErrNotFound + case pgunit.IsUnstorableText(err): + return textvalue.ErrUnstorable + } + return err +} diff --git a/services/core/internal/sandbox/providers/configuration_flow_test.go b/services/core/internal/sandbox/providers/configuration_flow_test.go index b5ca45f72..7efcf0f2a 100644 --- a/services/core/internal/sandbox/providers/configuration_flow_test.go +++ b/services/core/internal/sandbox/providers/configuration_flow_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "net/http/httptest" "strings" "testing" @@ -132,7 +133,7 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { } // The flow reaches only the deployment setup; every other dependency // panics if called. - h, err := api.NewHandler(api.Dependencies{ + h, _, err := api.NewHandler(api.Dependencies{ Engine: "codex", CoreKeys: auth, InstallationBindings: service, Projects: struct{ api.Projects }{}, ProjectsReader: struct{ api.ProjectsReader }{}, ModelProviders: struct{ api.ModelProviders }{}, ModelProvidersReader: struct{ api.ModelProvidersReader }{}, @@ -141,6 +142,7 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { EnvironmentTemplates: struct{ api.EnvironmentTemplates }{}, EnvironmentTemplatesReader: struct{ api.EnvironmentTemplatesReader }{}, Skills: struct{ api.Skills }{}, SkillsReader: struct{ api.SkillsReader }{}, Agents: struct{ api.Agents }{}, AgentsReader: struct{ api.AgentsReader }{}, + Automations: struct{ api.Automations }{}, AutomationsReader: struct{ automations.Reader }{}, Sessions: struct{ api.Sessions }{}, SessionsReader: struct{ api.SessionsReader }{}, SessionCreation: struct{ api.SessionCreation }{}, diff --git a/services/core/migrations/000013_automations.sql b/services/core/migrations/000013_automations.sql new file mode 100644 index 000000000..9066fa14b --- /dev/null +++ b/services/core/migrations/000013_automations.sql @@ -0,0 +1,44 @@ +-- +goose Up +CREATE TABLE automations ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + type text NOT NULL CHECK (type IN ('periodic', 'once')), + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 256), + metadata jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(metadata) = 'object'), + status text NOT NULL CHECK (status IN ('active', 'paused', 'completed')), + run_at timestamptz, + expression text, + timezone text, + next_run_at timestamptz, + target jsonb NOT NULL, + target_contents bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT statement_timestamp(), + CHECK ((type = 'once') = (run_at IS NOT NULL)), + CHECK ((type = 'periodic') = (expression IS NOT NULL AND timezone IS NOT NULL)), + CHECK (status <> 'completed' OR type = 'once') +); +CREATE INDEX automations_page ON automations (tenant_id, created_at, id); +CREATE INDEX automations_target_session ON automations (tenant_id, (target->>'session_id')) WHERE target->>'type' = 'existing_session'; +CREATE INDEX automations_due ON automations (next_run_at) WHERE status = 'active'; + +CREATE TABLE automation_invocations ( + id uuid PRIMARY KEY, + automation_id uuid NOT NULL REFERENCES automations (id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT statement_timestamp(), + scheduled_at timestamptz, + automation_snapshot jsonb, + status text NOT NULL CHECK (status IN ('queued', 'completed', 'failed', 'skipped')), + skip_reason text CHECK (skip_reason = 'pending_delivery'), + session_id uuid, + error_code text, + error_message text, + delivery bytea, + attempted_at timestamptz, + UNIQUE (automation_id, scheduled_at), + CHECK ((scheduled_at IS NULL) = (automation_snapshot IS NULL)), + CHECK ((status = 'skipped') = (skip_reason IS NOT NULL)), + CHECK ((status = 'failed') = (error_code IS NOT NULL AND error_message IS NOT NULL)), + CHECK ((status = 'queued') = (delivery IS NOT NULL)) +); +CREATE INDEX automation_invocations_page ON automation_invocations (automation_id, created_at, id); +CREATE INDEX automation_invocations_queued ON automation_invocations (automation_id, created_at) WHERE status = 'queued'; diff --git a/services/core/tests/integration/automations_public_test.go b/services/core/tests/integration/automations_public_test.go new file mode 100644 index 000000000..fbd0e804f --- /dev/null +++ b/services/core/tests/integration/automations_public_test.go @@ -0,0 +1,60 @@ +package integration + +import ( + "context" + "net/http/httptest" + "os" + "os/exec" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/google/uuid" +) + +func TestAutomationsOfficialClient(t *testing.T) { + python := os.Getenv("OAC_TEST_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, _ := newManagedTestStore(t) + token, foreign, tenant, project := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + auth := newTestAuthenticator(t, []testAPIKey{ + {OrganizationID: "test-org", ProjectID: project, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + // Delivery resolves the owning Project's principal from its rows. + if _, err := s.pool.Exec(t.Context(), "INSERT INTO execution_project_scopes(tenant_id,organization_id,project_id) VALUES($1,'test-org',$2)", tenant, project); err != nil { + t.Fatal(err) + } + if _, err := s.pool.Exec(t.Context(), "INSERT INTO projects(id,name,tenant_id,subject_kind,subject_id) VALUES($1,'Automation fixture',$1,'service_account','test-runner')", tenant); err != nil { + t.Fatal(err) + } + var deps api.Dependencies + h, err := publicHandler(t, s, auth, "codex", func(d *api.Dependencies) { deps = *d }) + if err != nil { + t.Fatal(err) + } + _, deliver, err := api.NewHandler(deps) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(h) + defer server.Close() + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + go func() { + for ctx.Err() == nil { + if _, failed, err := deliver(ctx); err != nil && ctx.Err() == nil { + t.Errorf("delivery failed %d: %v", failed, err) + } + time.Sleep(100 * time.Millisecond) + } + }() + out, err := exec.CommandContext(ctx, python, "../../tests/official_automations.py", server.URL, token, foreign).CombinedOutput() + if err != nil { + t.Fatalf("official Automations: %v %s", err, out) + } + t.Log(string(out)) +} diff --git a/services/core/tests/integration/automations_store_test.go b/services/core/tests/integration/automations_store_test.go new file mode 100644 index 000000000..0484893a7 --- /dev/null +++ b/services/core/tests/integration/automations_store_test.go @@ -0,0 +1,132 @@ +package integration + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" + "github.com/google/uuid" +) + +// Concurrent recording makes one invocation per occurrence, and settling is +// idempotent: the first outcome stays and completes the one-time Automation. +func TestAutomationRecordingAndSettlement(t *testing.T) { + s, _ := newManagedTestStore(t) + store, _ := fixtureAutomations(t, s) + newTenant := func() (string, string) { + tenant, project := uuid.NewString(), uuid.NewString() + if _, err := s.pool.Exec(t.Context(), "INSERT INTO execution_project_scopes(tenant_id,organization_id,project_id) VALUES($1,'test-org',$2)", tenant, project); err != nil { + t.Fatal(err) + } + if _, err := s.pool.Exec(t.Context(), "INSERT INTO projects(id,name,tenant_id,subject_kind,subject_id) VALUES($1,'Automation store',$1,'service_account','test-runner')", tenant); err != nil { + t.Fatal(err) + } + return tenant, project + } + tenant, project := newTenant() + due := time.Now().Add(-time.Minute) + created, err := store.Create(t.Context(), automations.Automation{TenantID: tenant, Type: automations.Once, Name: "due", Metadata: map[string]string{}, Status: automations.Active, RunAt: &due, NextRunAt: &due, + TargetType: "existing_session", Target: []byte(`{"type":"existing_session"}`), Contents: []byte(`{"session_id":"s","instructions":"i"}`)}) + if err != nil { + t.Fatal(err) + } + var recorded atomic.Int64 + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + n, err := store.Record(t.Context(), time.Now()) + if err != nil { + t.Error(err) + } + recorded.Add(int64(n)) + }() + } + wg.Wait() + page, err := store.ListInvocations(t.Context(), tenant, created.ID, automations.ListQuery{Limit: 10}) + if err != nil || recorded.Load() != 1 || len(page.Invocations) != 1 || page.Invocations[0].Status != "queued" { + t.Fatalf("recorded %d: %+v %v", recorded.Load(), page, err) + } + deliveries, err := store.Deliverable(t.Context(), 10) + if err != nil || len(deliveries) != 1 || string(deliveries[0].Request) != `{"session_id":"s","instructions":"i"}` || deliveries[0].Scope.ProjectID != project { + t.Fatalf("deliverable %+v %v", deliveries, err) + } + id := deliveries[0].InvocationID + if err := store.Settle(t.Context(), automations.Outcome{InvocationID: id, Status: "completed", SessionID: uuid.NewString()}); err != nil { + t.Fatal(err) + } + if err := store.Settle(t.Context(), automations.Outcome{InvocationID: id, Status: "failed", Code: "late", Message: "late"}); err != nil { + t.Fatal(err) + } + invocation, err := store.GetInvocation(t.Context(), tenant, created.ID, id) + if err != nil || invocation.Status != "completed" || invocation.Code != "" { + t.Fatalf("settled twice: %+v %v", invocation, err) + } + if a, err := store.Get(t.Context(), tenant, created.ID); err != nil || a.Status != automations.Completed { + t.Fatalf("one-time automation after its invocation settled: %+v %v", a, err) + } + if left, err := store.Deliverable(t.Context(), 10); err != nil || len(left) != 0 { + t.Fatalf("settled invocation stays deliverable: %+v %v", left, err) + } + + // An occurrence that finds a queued invocation is recorded as skipped, with the target Session. + session := uuid.NewString() + periodic, err := store.Create(t.Context(), automations.Automation{TenantID: tenant, Type: automations.Periodic, Name: "busy", Metadata: map[string]string{}, Status: automations.Active, Expression: "0 0 1 1 *", Timezone: "UTC", NextRunAt: &due, + TargetType: "existing_session", Target: []byte(`{"type":"existing_session","session_id":"` + session + `"}`), Contents: []byte(`{}`)}) + if err != nil { + t.Fatal(err) + } + if _, err := store.Invoke(t.Context(), tenant, periodic.ID); err != nil { + t.Fatal(err) + } + if n, err := store.Record(t.Context(), time.Now()); err != nil || n != 1 { + t.Fatalf("recorded %d: %v", n, err) + } + page, err = store.ListInvocations(t.Context(), tenant, periodic.ID, automations.ListQuery{Limit: 10, Ascending: true}) + if err != nil || len(page.Invocations) != 2 || page.Invocations[0].Status != "queued" || page.Invocations[1].Status != "skipped" || page.Invocations[1].SkipReason != "pending_delivery" || page.Invocations[1].SessionID != session { + t.Fatalf("skipped occurrence: %+v %v", page, err) + } + + // A sealed target that cannot be opened stops no other Automation. + other, _ := newTenant() + odd, err := store.Create(t.Context(), automations.Automation{TenantID: other, Type: automations.Periodic, Name: "odd", Metadata: map[string]string{}, Status: automations.Active, Expression: "* * * * *", Timezone: "UTC", NextRunAt: &due, + TargetType: "existing_session", Target: []byte(`{"type":"existing_session","session_id":"` + session + `"}`), Contents: []byte(`{}`)}) + if err != nil { + t.Fatal(err) + } + good, err := store.Create(t.Context(), automations.Automation{TenantID: tenant, Type: automations.Periodic, Name: "good", Metadata: map[string]string{}, Status: automations.Active, Expression: "* * * * *", Timezone: "UTC", NextRunAt: &due, + TargetType: "existing_session", Target: []byte(`{"type":"existing_session","session_id":"` + session + `"}`), Contents: []byte(`{}`)}) + if err != nil { + t.Fatal(err) + } + if _, err := store.Invoke(t.Context(), other, odd.ID); err != nil { + t.Fatal(err) + } + if n, err := store.Record(t.Context(), time.Now()); err != nil || n != 2 { + t.Fatalf("recorded %d: %v", n, err) + } + if _, err := s.pool.Exec(t.Context(), "UPDATE automation_invocations SET delivery = '\\x00' WHERE automation_id = $1 AND scheduled_at IS NULL", odd.ID); err != nil { + t.Fatal(err) + } + + // Pause holds the queued scheduled delivery; a manual invocation still delivers. + if _, err := store.Update(t.Context(), tenant, good.ID, automations.Automation.Pause); err != nil { + t.Fatal(err) + } + if held, err := store.Deliverable(t.Context(), 10); err != nil || len(held) != 1 { // only the earlier manual invocation of "busy" + t.Fatalf("paused or corrupt deliveries: %+v %v", held, err) + } + page, err = store.ListInvocations(t.Context(), other, odd.ID, automations.ListQuery{Limit: 10, Ascending: true}) + if err != nil || len(page.Invocations) != 2 || page.Invocations[0].Status != "failed" || page.Invocations[0].Code != "internal_error" || page.Invocations[1].Status != "skipped" { + t.Fatalf("corrupt row: %+v %v", page, err) + } + if _, err := store.Invoke(t.Context(), tenant, good.ID); err != nil { + t.Fatal(err) + } + if manual, err := store.Deliverable(t.Context(), 10); err != nil || len(manual) != 2 { + t.Fatalf("manual invocation of a paused automation: %+v %v", manual, err) + } +} diff --git a/services/core/tests/integration/public_handler_fixture_test.go b/services/core/tests/integration/public_handler_fixture_test.go index 8d26a015a..11b9feb3d 100644 --- a/services/core/tests/integration/public_handler_fixture_test.go +++ b/services/core/tests/integration/public_handler_fixture_test.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agents" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/automations" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" @@ -16,6 +17,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/agentpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/automationpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/filepg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" @@ -77,6 +79,7 @@ func publicHandler(t testing.TB, s *Store, keys fixtureKeyResolver, engine strin return nil, err } deployments := deploymentService(t, s) + automationStore, automationService := fixtureAutomations(t, s) deps := api.Dependencies{ Engine: engine, CoreKeys: admin, InstallationBindings: deployments, Projects: projectService, ProjectsReader: fixtureProjectsReader{Store: projectStore, keys: keys}, @@ -86,6 +89,7 @@ func publicHandler(t testing.TB, s *Store, keys fixtureKeyResolver, engine strin EnvironmentTemplates: environmentTemplates, EnvironmentTemplatesReader: templates, Skills: skillService, SkillsReader: skillStore, Agents: agentService, AgentsReader: agentStore, + Automations: automationService, AutomationsReader: automationStore, Sessions: service, SessionsReader: sessionStore, SessionCreation: service, @@ -103,7 +107,8 @@ func publicHandler(t testing.TB, s *Store, keys fixtureKeyResolver, engine strin for _, c := range configure { c(&deps) } - return api.NewHandler(deps) + h, _, err := api.NewHandler(deps) + return h, err } // fixtureAgents builds the Agent adapter and service on s. @@ -117,6 +122,17 @@ func fixtureAgents(t testing.TB, s *Store) (*agentpg.Store, *agents.Service) { return agentStore, agentService } +// fixtureAutomations builds the Automation adapter and service on s. +func fixtureAutomations(t testing.TB, s *Store) (*automationpg.Store, *automations.Service) { + t.Helper() + store := automationpg.New(pgunit.NewPool(s.pool), s.credentialCipher) + service, err := automations.NewService(store) + if err != nil { + t.Fatal(err) + } + return store, service +} + // fixtureFiles builds the File adapter and service on s. func fixtureFiles(t testing.TB, s *Store) (*filepg.Store, *files.Service) { t.Helper() diff --git a/services/core/tests/official_automations.py b/services/core/tests/official_automations.py new file mode 100644 index 000000000..933647e24 --- /dev/null +++ b/services/core/tests/official_automations.py @@ -0,0 +1,142 @@ +"""Public Automations through the fixed SDK, real delivery and PostgreSQL.""" + +import sys +import time + +import httpx2 +from openai import BadRequestError, ConflictError, NotFoundError, OpenAI + +MESSAGE = [{"role": "user", "content": [{"type": "input_text", "text": "scheduled"}]}] + + +def fails(error, action, param=None): + try: + action() + except error as e: + if param is not None: + assert e.response.json()["error"]["param"] == param, e.response.text + return + raise AssertionError("expected " + error.__name__) + + +def settled(read): + deadline = time.time() + 20 + while time.time() < deadline: + value = read() + if value.status != "queued": + return value + time.sleep(0.2) + raise AssertionError("delivery did not settle") + + +def main(): + base, token, foreign = sys.argv[1:] + with httpx2.Client(trust_env=False, timeout=10) as http: + make = lambda key: OpenAI(api_key=key, base_url=base + "/v1", http_client=http, max_retries=0, _strict_response_validation=True) + client, other = make(token), make(foreign) + agents = client.beta.agents + automations, invocations = agents.automations, agents.automations.invocations + saved = agents.create(model="saved-model", instructions="Saved instructions.") + session = agents.sessions.create(input="hello", agent_id=saved.id, environment={"type": "none"}) + new_session = {"type": "new_session", "agent_id": saved.id, "environment": {"type": "none"}, "input": MESSAGE, "metadata": {"k": "v"}} + existing = {"type": "existing_session", "session_id": session.id, "instructions": "wake up"} + + periodic = automations.create(type="periodic", name="yearly", expression="0 0 1 1 *", timezone="UTC", target=new_session, metadata={"a": "b"}) + assert periodic.status == "active" and periodic.created_by.type == "developer" and periodic.metadata == {"a": "b"} + target = periodic.target + assert target.agent_id == saved.id and target.agent is None and target.metadata == {"k": "v"} and target.vault_ids == [] and target.environment.type == "none" + inline = automations.create(type="periodic", name="inline", expression="*/5 * * * *", timezone="Asia/Shanghai", + target={"type": "new_session", "agent": {"model": "saved-model", "instructions": "Inline."}, "environment": {"type": "none"}, "input": MESSAGE}) + assert inline.target.agent.model == "saved-model" and inline.target.agent_id is None + assert inline.target.agent.reasoning.effort is None and inline.target.agent.reasoning.summary is None + # Reasoning keeps both members and a self-hosted environment lists its directories. + rich = automations.create(type="periodic", name="rich", expression="0 0 1 1 *", timezone="UTC", + target={"type": "new_session", "agent": {"model": "saved-model", "reasoning": {"effort": "low"}}, "environment": {"type": "none"}, "input": MESSAGE}) + assert rich.target.agent.reasoning.effort == "low" and rich.target.agent.reasoning.summary is None + provider = {"x_agents_core": {"model_provider": {"protocol": "responses", "base_url": "https://model.fixture.example/v1", "api_key": "fixture-model-key"}}} + hosted = agents.create(model="saved-model", extra_body=provider) + own = automations.create(type="periodic", name="own", expression="0 0 1 1 *", timezone="UTC", target={"type": "new_session", "agent_id": hosted.id, "environment": {"type": "self_hosted", "workspace_directory": "/workspace"}, "input": MESSAGE}) + assert own.target.environment.type == "self_hosted" and own.target.environment.capability_directories == [] + for extra in (rich, own): + automations.delete(extra.id) + once = automations.create(type="once", name="later", run_at=int(time.time()) + 3600, target=existing) + assert once.target.session_id == session.id and once.target.instructions == "wake up" and once.status == "active" + for created in (periodic, inline, once): + assert automations.retrieve(created.id) == created + assert [a.id for a in automations.list().data] == [once.id, inline.id, periodic.id] + assert [a.id for a in automations.list(order="asc", limit=2).data] == [periodic.id, inline.id] + assert [a.id for a in automations.list(after=inline.id).data] == [periodic.id] + assert [a.id for a in automations.list(session_id=session.id).data] == [once.id] + assert automations.list(session_id="").data == [] + fails(NotFoundError, lambda: automations.list(after="missing")) + fails(NotFoundError, lambda: other.beta.agents.automations.retrieve(periodic.id)) + + # Update replaces whole fields and keeps the target type. + updated = automations.update(periodic.id, name="renamed", metadata={}, expression="30 0 1 1 *") + assert updated.name == "renamed" and updated.metadata == {} and updated.expression == "30 0 1 1 *" and automations.retrieve(periodic.id) == updated + assert automations.update(once.id, target={**existing, "instructions": "changed"}).target.instructions == "changed" + fails(BadRequestError, lambda: automations.update(once.id, target=new_session), "target.type") + fails(BadRequestError, lambda: automations.update(periodic.id, run_at=int(time.time()) + 60), "run_at") + fails(BadRequestError, lambda: automations.update(once.id, expression="* * * * *"), "expression") + fails(BadRequestError, lambda: automations.update(once.id, run_at=int(time.time()) - 60), "run_at") + + # Pause holds scheduled delivery; both transitions are idempotent. + assert automations.pause(periodic.id).status == "paused" and automations.pause(periodic.id).status == "paused" + assert automations.resume(periodic.id).status == "active" and automations.resume(periodic.id).status == "active" + + # Manual invocations deliver for both target types. + queued = invocations.create(once.id) + assert queued.status == "queued" and queued.source.type == "manual" and queued.scheduled_at is None + done = settled(lambda: invocations.retrieve(queued.id, automation_id=once.id)) + assert done.status == "completed" and done.session_id == session.id and done.error is None + manual = invocations.create(periodic.id) + created = settled(lambda: invocations.retrieve(manual.id, automation_id=periodic.id)) + assert created.status == "completed" and agents.sessions.retrieve(created.session_id).id == created.session_id + listed = invocations.list(once.id) + assert [i.id for i in listed.data] == [queued.id] and listed.data[0] == done + + # A one-time Automation runs at run_at and then completes. + soon = automations.create(type="once", name="soon", run_at=int(time.time()) + 2, target=existing) + deadline = time.time() + 20 + while automations.retrieve(soon.id).status != "completed": + assert time.time() < deadline, "one-time automation did not complete" + time.sleep(0.2) + record = invocations.list(soon.id).data[0] + assert record.status == "completed" and record.scheduled_at == soon.run_at and record.source.type == "automation" and record.source.automation.id == soon.id + assert record.session_id == session.id + fails(ConflictError, lambda: automations.pause(soon.id)) + fails(ConflictError, lambda: automations.resume(soon.id)) + fails(ConflictError, lambda: automations.update(soon.id, run_at=int(time.time()) + 60)) + + # Rejected requests save nothing. + count = len(automations.list(limit=100).data) + periodic_args = {"type": "periodic", "name": "bad", "expression": "0 0 1 1 *", "timezone": "UTC", "target": new_session} + fails(BadRequestError, lambda: automations.create(**periodic_args | {"expression": "bad"}), "expression") + fails(BadRequestError, lambda: automations.create(**periodic_args | {"timezone": "Mars/Base"}), "timezone") + fails(BadRequestError, lambda: automations.create(type="once", name="bad", run_at=int(time.time()) - 5, target=existing), "run_at") + fails(BadRequestError, lambda: automations.create(type="once", name="bad", run_at=253402300800, target=existing), "run_at") + fails(BadRequestError, lambda: automations.update(once.id, run_at=253402300800), "run_at") + fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {**new_session, "environment": {"type": "openai_hosted", "environment_id": "env"}}})) + fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {**new_session, "agent": {"model": "m"}}}), "target") + inline_target = {k: v for k, v in new_session.items() if k != "agent_id"} + fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": inline_target | {"agent": {"instructions": "no model"}}})) + fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": inline_target | {"agent": {"model": "m", "tools": [{"type": "automations"}]}}})) + fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {k: v for k, v in new_session.items() if k != "agent_id"} | {"agent": {"model": "m", "x_agents_core": {"harness": "codex"}}}}), "target.agent.x_agents_core") + fails(BadRequestError, lambda: automations.create(**periodic_args | {"target": {**existing, "instructions": ""}}), "target.instructions") + fails(NotFoundError, lambda: automations.create(**periodic_args | {"target": {**existing, "session_id": "missing"}})) + # A Session named in another uuid form is saved under its canonical id. + urn = automations.create(**periodic_args | {"name": "urn", "target": {**existing, "session_id": "urn:uuid:" + session.id}}) + assert urn.target.session_id == session.id and urn.id in [a.id for a in automations.list(session_id=session.id).data] + automations.delete(urn.id) + assert len(automations.list(limit=100).data) == count + + # Delete removes the Automation and its records, not the Session. + deleted = automations.delete(periodic.id) + assert deleted.id == periodic.id and deleted.deleted and deleted.object == "agent.automation.deleted" + fails(NotFoundError, lambda: automations.retrieve(periodic.id)) + fails(NotFoundError, lambda: invocations.list(periodic.id)) + assert agents.sessions.retrieve(session.id).id == session.id + + +if __name__ == "__main__": + main()