diff --git a/docs/architecture.md b/docs/architecture.md index 57a223e..3af0c68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,8 +35,9 @@ flowchart LR ### The server owns history -The event log is the source of truth for a session, but two different orderings -are read from it: +The event log is the source of truth for public session history. It is not the +lossless provider transcript and must not be used to reconstruct provider-native +context. Two different public orderings are read from the event ledger: - **Public event history** (`GET .../events`, list, and the live SSE stream) is the immutable receipt/commit sequence. It never reorders or hides events. @@ -46,9 +47,13 @@ are read from it: the current trigger. A turn never sees a later message queued while it was still running. -Before each model turn the application reconstructs that causal history and -projects it into a Messages API conversation. The model endpoint performs -inference; it does not own session state. +New Sessions continue model conversations from a lossless Provider Transcript; +the causal public-event projection remains only as a compatibility fallback for +Sessions created before transcript support. Immutable Context Snapshots remain +follow-up work. This separation is required for native server-tool blocks, +citations, compaction, and large results. See +[Storage, context, and connected tools](architecture/storage-context-and-tools.md). +The model endpoint performs inference; it does not own session state. ### Wire and domain models are separate @@ -139,6 +144,10 @@ The strongest current risks are semantic rather than structural: quotas, and eviction are not implemented. 3. Worker Versioning, observability, authentication, large-payload offload, and production manifests remain open. +4. Provider Transcript, native Web Search/Fetch, sandbox result + materialization, and unauthenticated MCP tools are implemented. Context + Snapshots, provider-round records, deployment-managed MCP authentication, and + reference-only Temporal payloads remain open. Current API support is tracked in the [compatibility matrix](compatibility.md); planned capability work is kept in the [roadmap](roadmap.md). diff --git a/docs/architecture/runtime-and-sandbox.md b/docs/architecture/runtime-and-sandbox.md index 0dc0ca0..1f5f826 100644 --- a/docs/architecture/runtime-and-sandbox.md +++ b/docs/architecture/runtime-and-sandbox.md @@ -37,6 +37,12 @@ Conversation ownership remains in this server. This is why earlier self-contained harness integrations were removed: a second component owning history would create competing sources of truth. +The context boundary now includes a lossless Provider Transcript rather than +reconstructing provider-native history from public events. Native Web +Search/Fetch and MCP keep separate raw, model-facing, and public projections; +immutable Context Snapshots remain follow-up work. See +[Storage, context, and connected tools](storage-context-and-tools.md). + ## Tool runtime Agent tool configuration is parsed into: @@ -45,12 +51,17 @@ Agent tool configuration is parsed into: - custom tool definitions; - MCP toolset references. -`bash`, `read`, `write`, `edit`, `glob`, and `grep` currently execute. -`web_fetch` and `web_search` can be declared but return a not-implemented tool -result. MCP references are parsed but not resolved. - -Built-ins with `always_allow` execute inside the current run. Custom tools and -`always_ask` built-ins park the session for a client response. +`bash`, `read`, `write`, `edit`, `glob`, and `grep` execute in the Session +sandbox. `web_fetch` and `web_search` are sent as native server-tool +declarations to the configured Messages API endpoint and currently require +`always_allow`. Remote MCP tools are discovered through Streamable HTTP, pinned +per Session, permission-checked, journaled, and given the same sandbox-backed +large-result policy. Deployment-managed MCP authentication and deprecated-SSE +fallback remain follow-up work. + +Locally executable built-ins and MCP tools with `always_allow` execute inside +the current run. Custom tools and interceptable `always_ask` tools park the +session for a client response. ## Sandbox provider diff --git a/docs/architecture/storage-context-and-tools.md b/docs/architecture/storage-context-and-tools.md new file mode 100644 index 0000000..8212d7a --- /dev/null +++ b/docs/architecture/storage-context-and-tools.md @@ -0,0 +1,506 @@ +--- +title: Storage, context, and connected tools +--- + +# Storage, context, and connected tools + +Status: initial implementation plus follow-up boundaries, 2026-07-31. + +This decision treats the public Claude Managed Agents API as the compatibility +contract and derives the internal storage and execution boundaries from that +contract. It covers session context, large tool results, Web Search, Web Fetch, +MCP, sandboxes, and Temporal. + +The central decision is: + +> Web Search, Web Fetch, and MCP are tools at the public API and policy layers. +> Their execution owner is an internal capability selected and pinned for each +> session. + +The first implementation may require the configured Messages API `base_url` to +support native Web Search and Web Fetch. The public Agent schema does not gain a +separate search API key or vendor-specific search configuration. The internal +executor boundary remains in place so a platform-managed search/fetch backend +can be added later without changing the public API or stored session semantics. + +## Contract-derived requirements + +The target follows these observable Managed Agents behaviors: + +- A Session is stateful. Its event history and model context survive individual + runs, while each Session has an isolated sandbox. +- Agents, Environments, Files, Memory Stores, and Sessions have + independent lifecycles. Deleting a Session must not implicitly delete those + reusable resources. +- Built-in agent tools include `web_search` and `web_fetch`. MCP tools use the + same permission-policy concepts as built-ins. +- Agent definitions identify MCP servers and enabled toolsets without embedding + secrets. Provider and connector credentials belong to deployment/worker + configuration, not Agent or Session resources. +- MCP tools default to `always_ask`; built-in tools default to `always_allow`. + A running Session keeps the tool configuration snapshot with which it began. +- Large tool output is materialized as a file in the Session sandbox. The model + receives a bounded preview and a path rather than an unbounded inline value. +- Self-hosted sandboxes change the execution location, not the control-plane + resource model. Tool inputs and results still cross the control plane. + +The raw Messages API adds one requirement that the public event model alone +cannot satisfy: native server-tool blocks can contain opaque continuation data, +including Web Search encrypted content and citation indexes. Those blocks must +be returned to the provider exactly on subsequent calls. + +## Authority model + +There is no single representation that is correct for clients, the model, +recovery, and raw bytes. The platform therefore keeps separate logical +authorities: + +| Question | Authority | Initial physical store | +| --- | --- | --- | +| What did the client observe? | Public Event Ledger | PostgreSQL | +| What exact context continues the model conversation? | Provider Transcript | PostgreSQL JSONB | +| Did a tool possibly change the world? | Operation Journal | PostgreSQL | +| Where are large tool bytes, mutable files, and processes? | Session Sandbox | Sandbox provider | +| What knowledge is shared across Sessions? | Memory Store | Separate versioned resource | +| Where are credentials? | Deployment/worker configuration | Environment or operator-managed secret injection | +| What should execute next? | Temporal Workflow state | IDs and small control projections only | + +These are logical boundaries, not a requirement for separate services. +PostgreSQL initially holds small transactional records and the lossless +provider transcript. Large tool output and binary MCP content live in the +Session sandbox. There is intentionally no general Artifact/S3 subsystem in the +first implementation. Independent Files remain a separate API resource and can +later reuse object-storage infrastructure without changing tool-result paths. + +### Public Event Ledger + +The event ledger is the external API truth: + +- append-only receipt and commit order; +- the source for event list, replay, and SSE reconciliation; +- stable public event IDs and public tool-use correlations; +- no provider-private blocks, credentials, or unbounded raw output. + +It is **not** the authority for the next model request. Public events are a +projection designed for compatibility and observation. Reconstructing provider +context from them loses provider-native blocks, citations, encrypted +continuation data, non-text content, and context-compaction decisions. + +### Provider Transcript + +The Provider Transcript is the lossless model-continuation truth: + +- stores the exact ordered content blocks accepted from or sent to a provider; +- preserves provider tool-use IDs rather than replacing them with public event + IDs; +- preserves unknown provider fields so a client upgrade is not required merely + to round-trip a new block; +- records provider, model, server-tool version, and capability-profile version; +- is append-only within an execution attempt. + +Blocks are initially stored as JSONB. Hydration must reproduce the provider +wire value exactly; a preview or public event payload cannot substitute for it. +If provider transcripts later exceed practical PostgreSQL limits, their +physical blob storage can change behind the transcript boundary without +introducing a public Artifact resource. + +A separate mapping relates provider IDs to public IDs: + +```text +provider tool-use id <-> internal tool step id <-> public event id +``` + +The mapping allows public compatibility without mutating the provider +transcript. + +### Context Snapshot + +A Context Snapshot is an immutable recipe for one provider request. It pins: + +- the ordered transcript entry IDs included in the request; +- system instructions and effective Agent/Environment revision; +- resolved tool schemas, permission policy, and execution capability profile; +- model parameters and provider adapter version; +- context-policy version and any summary/compaction entry; +- token estimate and parent snapshot. + +Compaction creates a new snapshot and summary entry. It never rewrites the +original provider transcript. This gives debugging and audit tools an exact +answer to both “what happened?” and “what did this model call actually see?” + +### Operation Journal + +The existing `turn_attempts` and `tool_steps` journal remains the authority for +side-effect recovery: + +```text +prepared -> started -> completed + \-> ambiguous +``` + +After `started`, absence of a durable result is not proof that nothing happened. +An MCP mutation, shell command, custom tool, or platform-managed fetch must not +be retried blindly. Executor-specific idempotency keys can make selected +operations safely retryable, but they do not remove the journal boundary. + +Provider calls should gain a similar prepared/started/completed record for +cost, diagnostics, and exact response recovery. A provider idempotency feature +may be used when available; it must not be assumed from an arbitrary +`base_url`. + +### Session sandbox and Files + +The sandbox is the Session's mutable execution workspace. It owns processes, +intermediate files, tool-created files, oversized tool results, and MCP binary +content. For the current lifecycle contract it plays the same role as the local +filesystem in CCB: tools write files there and the agent reads them through +`read`, `bash`, and related built-ins. + +The sandbox is not the model-context database. Provider-native blocks remain in +the Provider Transcript even when a tool also creates files. + +Files uploaded through the public Files API are independent resources. They may +eventually use S3-compatible object storage and be mounted or copied into a +sandbox, but ordinary tool output does not automatically become a File or a +separate Artifact resource. + +For a large tool result: + +1. write the complete serialized output into the Session sandbox; +2. when serialized output exceeds the documented 100,000-character threshold + (about 25,000 tokens), create a bounded model projection containing a + truncated preview, size, media type, and sandbox path; +3. create the contract-compatible public event projection; +4. record the sandbox path on the same durable tool step where applicable. + +If the sandbox disappears unexpectedly, the Session workspace has been lost and +the runtime must surface that failure; it must not silently provision an empty +replacement. Independent File and Memory resources are unaffected. + +## One tool plane, multiple execution owners + +Tool policy and tool execution must be separate concepts. + +```mermaid +flowchart LR + Config["Agent tool configuration"] --> Resolve["Session capability snapshot"] + Resolve --> Native["provider_native"] + Resolve --> Managed["platform_managed"] + Resolve --> Worker["self_hosted_worker"] + Resolve --> Client["client_custom"] + Native --> Raw["Raw result / provider block"] + Managed --> Raw + Worker --> Raw + Client --> Raw + Raw --> Sandbox["Sandbox file when large/binary"] + Raw --> Transcript["Exact provider transcript"] + Raw --> Context["Model context projection"] + Raw --> Public["Public event projection"] + Native --> Journal["Operation journal"] + Managed --> Journal + Worker --> Journal + Client --> Journal +``` + +The internal execution owners are: + +| Owner | Meaning | +| --- | --- | +| `provider_native` | The configured model endpoint executes a server tool inside the model call | +| `platform_managed` | This control plane invokes a search/fetch service or remote MCP server | +| `self_hosted_worker` | A customer worker executes the tool in its sandbox/network | +| `client_custom` | The Session pauses and waits for a client-supplied custom-tool result | + +The selected owner, provider tool version, capability profile, and permission +policy are pinned in the Session runtime snapshot. They are operational facts, +not vendor-specific fields on the public Agent resource. + +### Capability resolution + +A URL is an address, not a capability declaration. Even though the first +release requires native Web Search/Fetch support from `base_url`, the model +adapter must expose an explicit capability profile: + +```text +native_web_search +native_web_fetch +native_citations +native_response_inclusion +preserves_unknown_content_blocks +provider_tool_versions +``` + +The profile is derived from a known adapter/configuration, not guessed from the +hostname. Agent or Session validation fails closed when an enabled tool cannot +be honored. The effective profile is snapshotted so a configuration rollout +cannot silently change a running Session. + +## Web Search + +`web_search` is a built-in tool in the public API. In the first release it maps +to the provider's native server tool. + +The adapter must: + +- map public `web_search` configuration to a pinned, versioned provider + server-tool declaration such as `web_search_20260318`, rather than exposing + it as an ordinary function tool with a permissive input schema; +- retain the complete provider response blocks, including citations and opaque + encrypted fields; +- pass those blocks back unchanged in later provider requests; +- preserve the distinction between an HTTP/API error and an in-band + server-tool error block; +- publish bounded public events linked to, but not substituted for, the exact + provider transcript; +- record usage and provider request IDs for diagnostics and billing. + +Provider features such as dynamic filtering and `response_inclusion` are +capabilities, not assumptions. `response_inclusion: excluded` may reduce +transcript size only where the provider explicitly guarantees that omitted +nested results are not required for continuation. Public operational events and +internal provenance remain durable. Eligibility is decided from the exact +Provider Transcript; an adapter must never exclude a block that a later request +has to resend. + +#### Permission constraint + +A native server tool executes inside the provider call. This platform cannot +pause between the model requesting the tool and the provider executing it. +Therefore: + +- `provider_native + always_allow` is supported; +- `provider_native + always_ask` is rejected during capability resolution; +- `always_ask` becomes available only with a `platform_managed` or + `self_hosted_worker` executor that can durably park before execution. + +This rejection is a temporary platform capability gap, not a restriction of the +Managed Agents contract: Anthropic's managed executor can honor `always_ask` +for built-in web tools. The API returns a clear unsupported-capability error +until this platform has an interceptable executor. Silently treating +`always_ask` as `always_allow` is not allowed. + +## Web Fetch + +`web_fetch` follows the same native-first execution model and exact-transcript +rules. The adapter retains document blocks, citations, PDF/document content, +provider errors, and cache-control-relevant metadata without flattening them to +text. + +A future `platform_managed` fetch executor must additionally enforce: + +- only `http` and `https`, with normalized URLs; +- DNS and resolved-IP checks before every connection and redirect; +- denial of loopback, link-local, metadata, private, and disallowed networks; +- redirect count, response size, decompression, media-type, and time limits; +- tenant/domain policy, egress proxy policy, and auditable provenance; +- a deliberate cache and content-retention policy; +- sanitization of model-facing previews without destroying the raw sandbox + file. + +The provider's rule that a fetched URL must already appear in conversation +context is treated as a security boundary for native execution. A managed +executor should enforce an equivalent or stricter policy. + +## MCP + +MCP is a connector subsystem behind the same tool plane, not a special kind of +model history. + +### Configuration and credentials + +Reusable Agent configuration stores: + +- MCP server name and normalized URL; +- matching `mcp_toolset` enablement and per-tool overrides; +- no bearer token, API key, or OAuth refresh token. + +This service is agent infrastructure. Agent and Session APIs therefore do not +accept connector credentials or references to user-owned keys. Model +credentials are read from deployment environment configuration when the worker +starts. The current MCP connector supports unauthenticated endpoints only. + +If authenticated MCP is added, credentials must remain operator-managed worker +configuration, keyed by a deployment-owned connector profile or normalized +server identity. Environment variables or the deployment's secret manager may +inject the value into the worker, which adds authentication at the outbound +transport boundary. Secret values must never enter Agent/Session resources, +PostgreSQL event/transcript rows, Temporal payloads, logs, sandbox files, or +public errors. Rotation is an operational deployment concern, not a Session +mutation. + +### Discovery snapshot + +The MCP connection manager performs `initialize` and `tools/list`, validates the +configured toolset, and writes a Session-scoped discovery snapshot containing: + +- protocol/server capability metadata; +- tool name, description, and input/output schema digest; +- enabled/disabled decision and permission policy; +- normalized server identity; +- discovery time and snapshot digest. + +New tools added by a remote server must not become automatically enabled in an +existing Session. The initial implementation should require an explicit +allowlist for production Agents even if the public contract permits a broader +default. + +Streamable HTTP is preferred, with SSE fallback where supported. Connection and +authentication failures are nonfatal Session errors: they are observable, +leave the Session recoverable, and may be retried on the next transition to +running. + +### MCP invocation + +An MCP tool call uses the normal operation journal: + +1. persist `prepared` with normalized input and discovery snapshot ID; +2. enforce `always_ask`, parking the Session before network execution; +3. mark `started`; +4. invoke the remote server with deadlines and bounded transport; +5. retain a bounded raw MCP result in the journal, or write its large/binary + form into the Session sandbox; +6. create the model and public projections; +7. atomically mark the step `completed`. + +If the connection breaks after `started`, the step is `ambiguous` unless the +tool has a proven idempotency contract. The runtime must not infer that read-like +tool names are safe. + +MCP `structuredContent`, textual/image content, `isError`, and protocol metadata +are retained in the raw result. Only explicitly allowed content enters model +context; transport `_meta`, credentials, and control metadata do not. Oversized +results use the same sandbox-file plus bounded-preview path as built-ins. + +The first MCP slice supports tools only. Resources and prompts should be added +only when the public compatibility contract and context policy define their +lifecycle. + +## Provider-round transaction model + +Intermediate model/tool rounds must survive an Activity retry without being +published prematurely. + +1. A turn starts from the committed Provider Transcript. +2. `CallModel` retains the complete provider response blocks in the turn's + private transcript delta. +3. A tool Activity journals execution and adds its model projection to that + delta. +4. Turn completion atomically commits the transcript delta with public events, + marks the trigger processed, and updates Session status. +5. A later turn loads the private transcript instead of reconstructing provider + context from public events. + +This model avoids both premature public events and loss of provider-native +conversation state. + +## Temporal boundary + +Temporal manages ordering, timers, retries, cancellation, approval waits, +Continue-As-New, and cleanup sagas. It is not the transcript or file store. + +The target Workflow and Activity payloads carry: + +- Session, run, attempt, provider-round, tool-step, and Context Snapshot IDs; +- small status/control projections; +- digests and bounded error summaries. + +They should not carry full model requests, provider responses, MCP results, +fetched documents, or file bytes. The current implementation still records the +bounded request and transcript delta in Temporal history while PostgreSQL +atomically commits it. Moving to context/round IDs is follow-up work. File bytes +already stay in the sandbox and never enter Temporal payloads. + +## Records + +Names are illustrative; they may share the existing PostgreSQL service: + +| Record | Key fields | +| --- | --- | +| `provider_transcript_turns` | Session, canonical trigger, all represented resolution event IDs, ordered lossless message delta, public/provider tool ID mappings | +| `tool_steps` | prepared/started/completed/ambiguous state, bounded raw result or sandbox path, model projection | +| `mcp_discovery_snapshots` | Session/server, normalized URL, immutable discovered tool definitions | + +Every table is tenant-scoped. Sensitive content is encrypted at rest, access is +audited, and public reads authorize through the owning resource rather than +accepting raw storage keys. + +## Lifecycle and deletion + +| Resource | Session deletion behavior | +| --- | --- | +| Public events and provider transcript | Delete according to Session retention contract | +| Session sandbox | Durably tear down through the existing deletion saga | +| Session-owned tool-result files | Delete with the Session sandbox | +| Independent uploaded File resources | Preserve | +| Memory Stores and immutable versions | Preserve | +| Agent and Environment definitions | Preserve | +| Deployment credentials | Outside Session lifecycle; rotate through deployment operations | +| Attempt/journal records | Retain for the bounded audit window, then purge with the Session | + +Deletion first fences new work, terminates orchestration, tears down the +sandbox, and then removes or tombstones authoritative records. Independent File +deletion follows the File resource's own lifecycle. + +## Implementation status + +The current code has a public ledger, outbox, Temporal workflow, sandbox +binding, and tool ambiguity journal. This change adds the minimum context and +tool boundaries needed for native web and unauthenticated MCP: + +1. Committed turns load a lossless Provider Transcript rather than reconstruct + provider context from public events. +2. The provider wire model retains opaque response blocks and replays them + unchanged. +3. Provider tool-use IDs remain private; explicit mappings connect them to + public event IDs. +4. Oversized local and MCP results share the 100,000-character sandbox policy. +5. Native Web Search/Fetch declarations go to the configured Messages API + `base_url`; provider-native web rejects `always_ask`. +6. Remote MCP tools use the official Go SDK, Session-pinned discovery, normal + permission policies, and the operation journal. Discovery connection + failures emit a recoverable `session.error`, omit that server's tools for + the turn, and are retried on a later turn. The default connector resolves + and pins public IPs per connection and rejects loopback, link-local, private, + metadata, and reserved networks; private MCP requires a future explicit + tunnel/egress capability. +7. Deployment-managed MCP authentication, provider-round records, explicit per-endpoint + capability profiles, context snapshots/compaction, and reference-only + Temporal payloads remain follow-up work. + +## Delivery order + +1. **Context foundation:** Provider Transcript, lossless provider blocks, and + provider/public ID mappings. Implemented for committed turns. +2. **Payload foundation:** Shared sandbox materialization. Implemented for + oversized local results and MCP raw/binary results. +3. **Native web:** provider capability profile plus native Web Search/Fetch in + `always_allow` mode, with exact replay, citations, and a hard per-request + context-size ceiling. Native declarations and replay are implemented; + explicit per-endpoint capability configuration and compaction remain. +4. **MCP tools:** Agent/server validation, discovery snapshots, approval + parking, and journaled invocation. Unauthenticated Streamable HTTP, + discovery snapshots, approval, and invocation are implemented; + deployment-managed authentication remains. +5. **Managed executors:** optional search/fetch providers and self-hosted + execution, enabling `always_ask` for web tools. +6. **Context engineering:** token budgets, compaction policies, summaries, + retention controls, and independent cross-Session Memory. + +The first two steps were treated as prerequisites rather than cleanup after +native web, so new Sessions do not depend on reconstructing provider context +from flattened public events. + +## Primary references + +- [Managed Agents overview](https://platform.claude.com/docs/en/managed-agents/overview) +- [Sessions](https://platform.claude.com/docs/en/managed-agents/sessions) +- [Events and streaming](https://platform.claude.com/docs/en/managed-agents/events-and-streaming) +- [Tools](https://platform.claude.com/docs/en/managed-agents/tools) +- [Permission policies](https://platform.claude.com/docs/en/managed-agents/permission-policies) +- [MCP connector](https://platform.claude.com/docs/en/managed-agents/mcp-connector) +- [Files](https://platform.claude.com/docs/en/managed-agents/files) +- [Memory](https://platform.claude.com/docs/en/managed-agents/memory) +- [Self-hosted sandboxes](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes) +- [Web Search server tool](https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool) +- [Web Fetch server tool](https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-fetch-tool) +- [Tool versions](https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-reference) diff --git a/go.mod b/go.mod index e01741c..6f0f1c3 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/anthropics/anthropic-sdk-go v1.61.0 github.com/daytona/clients/sdk-go v0.202.0 github.com/jackc/pgx/v5 v5.10.0 + github.com/modelcontextprotocol/go-sdk v1.6.0 github.com/nats-io/nats.go v1.52.0 github.com/pressly/goose/v3 v3.27.3 github.com/stretchr/testify v1.11.1 @@ -41,6 +42,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/mock v1.6.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect @@ -58,6 +60,8 @@ require ( github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/robfig/cron v1.2.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sethvargo/go-retry v0.4.0 // indirect github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect github.com/stretchr/objx v0.5.2 // indirect @@ -65,6 +69,7 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect @@ -79,6 +84,7 @@ require ( go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/crypto v0.54.0 // indirect golang.org/x/net v0.57.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect diff --git a/go.sum b/go.sum index a5e7f75..7fb2935 100644 --- a/go.sum +++ b/go.sum @@ -58,12 +58,16 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= @@ -94,6 +98,8 @@ github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyi github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY= +github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc= github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= @@ -119,6 +125,10 @@ github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI= @@ -142,6 +152,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= @@ -190,6 +202,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -216,6 +230,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/agentruntime/agentcore.go b/internal/agentruntime/agentcore.go index a74ccce..41a892a 100644 --- a/internal/agentruntime/agentcore.go +++ b/internal/agentruntime/agentcore.go @@ -73,6 +73,9 @@ func (a *AgentCore) Run(ctx context.Context, req RunRequest, sink EventSink) (Ru if !drivesModelTurn(req.Trigger.Type) { return RunOutcome{}, nil } + if err := ValidateToolCapabilities(req.ToolSet); err != nil { + return RunOutcome{}, err + } system := "" if req.AgentSnapshot.System != nil { system = *req.AgentSnapshot.System @@ -306,6 +309,21 @@ func (a *AgentCore) Run(ctx context.Context, req RunRequest, sink EventSink) (Ru return RunOutcome{}, nil } +// ValidateToolCapabilities rejects permission semantics the selected execution +// owner cannot honor. Native server tools execute inside the provider request, +// so this runtime cannot durably pause between request and execution. +func ValidateToolCapabilities(ts domain.ToolSet) error { + for _, name := range []string{"web_search", "web_fetch"} { + enabled, policy := ts.BuiltinEnabled(name) + if enabled && policy.Type != "always_allow" { + return domain.Validation( + name + " requires always_allow while it is provider-native", + ) + } + } + return nil +} + // seedConfirmation resolves a user.tool_confirmation into the paired // assistant tool_use / user tool_result blocks that unblock the parked // always_ask built-in, emitting the public agent.tool_result correlated to the @@ -493,6 +511,21 @@ func (a *AgentCore) executePreparedBuiltin( return tools.Result{}, err } result := exec(ctx, req.Sandbox, input) + result, materializeErr := tools.MaterializeLargeResult( + context.WithoutCancel(ctx), + req.Sandbox, + stepID, + result, + ) + if materializeErr != nil { + result = tools.Result{ + Content: []any{map[string]any{ + "type": "text", + "text": materializeErr.Error(), + }}, + IsError: true, + } + } if err := req.ToolJournal.Complete(ctx, stepID, domain.ToolStepResult{ Content: result.Content, IsError: result.IsError, @@ -561,6 +594,20 @@ func enabledBuiltinSchemas(ts domain.ToolSet) []model.ToolSchema { if enabled, _ := ts.BuiltinEnabled(name); !enabled { continue } + switch name { + case "web_search": + schemas = append(schemas, model.ToolSchema{ + Type: "web_search_20260318", + Name: name, + }) + continue + case "web_fetch": + schemas = append(schemas, model.ToolSchema{ + Type: "web_fetch_20260318", + Name: name, + }) + continue + } schema := tools.Schema(name) if schema == nil { continue diff --git a/internal/agentruntime/agentcore_test.go b/internal/agentruntime/agentcore_test.go index 297419a..fd571d4 100644 --- a/internal/agentruntime/agentcore_test.go +++ b/internal/agentruntime/agentcore_test.go @@ -266,6 +266,9 @@ func TestEnabledBuiltinSchemas_AllOfferedSchemasAreObjects(t *testing.T) { t.Fatalf("offered %d builtin schemas, want %d", len(schemas), len(domain.BuiltinToolNames)) } for _, s := range schemas { + if s.Type != "" { + continue + } if s.InputSchema == nil { t.Fatalf("tool %q offered with nil InputSchema (serializes to input_schema:null → 400)", s.Name) } @@ -275,6 +278,43 @@ func TestEnabledBuiltinSchemas_AllOfferedSchemasAreObjects(t *testing.T) { } } +func TestEnabledBuiltinSchemas_UsesNativeWebDeclarations(t *testing.T) { + ts := domain.ToolSet{Builtin: &domain.BuiltinToolset{ + DefaultEnabled: true, + DefaultPolicy: domain.PermissionPolicy{Type: "always_allow"}, + }} + native := make(map[string]model.ToolSchema) + for _, schema := range enabledBuiltinSchemas(ts) { + if schema.Type != "" { + native[schema.Name] = schema + } + } + if native["web_search"].Type != "web_search_20260318" || + native["web_fetch"].Type != "web_fetch_20260318" { + t.Fatalf("native web schemas = %#v", native) + } + if native["web_search"].InputSchema != nil || + native["web_fetch"].InputSchema != nil { + t.Fatalf("native tools must not carry client input schemas: %#v", native) + } +} + +func TestValidateToolCapabilities_RejectsApprovalForNativeWeb(t *testing.T) { + ts := domain.ToolSet{Builtin: &domain.BuiltinToolset{ + DefaultEnabled: true, + DefaultPolicy: domain.PermissionPolicy{Type: "always_allow"}, + Configs: []domain.BuiltinConfig{{ + Name: "web_search", + Policy: &domain.PermissionPolicy{ + Type: "always_ask", + }, + }}, + }} + if err := ValidateToolCapabilities(ts); err == nil { + t.Fatal("expected provider-native web_search always_ask to be rejected") + } +} + func TestAgentCore_ExecutesBuiltinToolLoop(t *testing.T) { _, sb, err := sandbox.NewLocalProvider().Create( context.Background(), diff --git a/internal/agentruntime/tools/mcp_result.go b/internal/agentruntime/tools/mcp_result.go new file mode 100644 index 0000000..93cd7d2 --- /dev/null +++ b/internal/agentruntime/tools/mcp_result.go @@ -0,0 +1,200 @@ +package tools + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "path" + "strings" + + "github.com/yanpgwang/managed-agent-go/internal/mcpclient" + "github.com/yanpgwang/managed-agent-go/internal/sandbox" +) + +// ProjectMCPResult separates a protocol-native MCP result from the content sent +// to the model. _meta and other control fields remain only in raw/rawPath; +// text and structured content are projected, while binary content is written to +// the Session sandbox and represented by a readable path. +func ProjectMCPResult( + ctx context.Context, + sb sandbox.Sandbox, + toolUseID string, + input mcpclient.Result, +) (result Result, raw json.RawMessage, rawPath string, err error) { + var wire struct { + Content []json.RawMessage `json:"content"` + StructuredContent any `json:"structuredContent"` + } + if err := json.Unmarshal(input.Raw, &wire); err != nil { + return Result{}, nil, "", fmt.Errorf("decode MCP result: %w", err) + } + var textParts []string + for index, content := range wire.Content { + var block map[string]any + if err := json.Unmarshal(content, &block); err != nil { + return Result{}, nil, "", fmt.Errorf( + "decode MCP content block %d: %w", + index, + err, + ) + } + typ, _ := block["type"].(string) + switch typ { + case "text": + if text, _ := block["text"].(string); text != "" { + textParts = append(textParts, text) + } + case "image", "audio": + location, err := persistMCPBase64( + ctx, + sb, + toolUseID, + index, + typ, + block, + ) + if err != nil { + return Result{}, nil, "", err + } + textParts = append( + textParts, + fmt.Sprintf("MCP %s content saved to %s", typ, location), + ) + case "resource": + resource, _ := block["resource"].(map[string]any) + uri, _ := resource["uri"].(string) + if text, _ := resource["text"].(string); text != "" { + if uri != "" { + textParts = append(textParts, "Resource "+uri+":\n"+text) + } else { + textParts = append(textParts, text) + } + continue + } + if _, ok := resource["blob"].(string); ok { + location, err := persistMCPBase64( + ctx, + sb, + toolUseID, + index, + "resource", + resource, + ) + if err != nil { + return Result{}, nil, "", err + } + textParts = append( + textParts, + fmt.Sprintf("MCP resource %s saved to %s", uri, location), + ) + } + case "resource_link": + uri, _ := block["uri"].(string) + name, _ := block["name"].(string) + textParts = append( + textParts, + fmt.Sprintf("MCP resource link %s: %s", name, uri), + ) + default: + // Unknown protocol content remains available in the raw diagnostic + // result but is not implicitly trusted as model context. In + // particular, this prevents future control/_meta fields from becoming + // prompt content before the projection policy understands them. + if typ != "" { + textParts = append( + textParts, + "MCP returned unsupported "+typ+" content.", + ) + } + } + } + if wire.StructuredContent != nil { + structured, err := json.MarshalIndent(wire.StructuredContent, "", " ") + if err != nil { + return Result{}, nil, "", fmt.Errorf( + "encode MCP structured content: %w", + err, + ) + } + textParts = append(textParts, "Structured content:\n"+string(structured)) + } + if len(textParts) == 0 { + textParts = append(textParts, "MCP tool returned no model-visible content.") + } + result = textResult(strings.Join(textParts, "\n\n"), input.IsError) + result, err = MaterializeLargeResult(ctx, sb, toolUseID, result) + if err != nil { + return Result{}, nil, "", err + } + + if len(input.Raw) > MaxInlineResultChars { + rawPath = path.Join( + ToolResultsDirectory, + safeResultFilename(toolUseID)+".mcp.json", + ) + if err := sb.WriteFile(ctx, rawPath, input.Raw); err != nil { + return Result{}, nil, "", fmt.Errorf( + "persist raw MCP result %q: %w", + rawPath, + err, + ) + } + } else { + raw = append(json.RawMessage(nil), input.Raw...) + } + return result, raw, rawPath, nil +} + +func persistMCPBase64( + ctx context.Context, + sb sandbox.Sandbox, + toolUseID string, + index int, + kind string, + block map[string]any, +) (string, error) { + encoded, _ := block["data"].(string) + if encoded == "" { + encoded, _ = block["blob"].(string) + } + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", fmt.Errorf("decode MCP %s content: %w", kind, err) + } + mimeType, _ := block["mimeType"].(string) + extension := extensionForMIME(mimeType) + location := path.Join( + ToolResultsDirectory, + fmt.Sprintf("%s-%d%s", safeResultFilename(toolUseID), index, extension), + ) + if err := sb.WriteFile(ctx, location, data); err != nil { + return "", fmt.Errorf("persist MCP %s content: %w", kind, err) + } + return location, nil +} + +func extensionForMIME(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "image/png": + return ".png" + case "image/jpeg": + return ".jpg" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "application/pdf": + return ".pdf" + case "audio/mpeg": + return ".mp3" + case "audio/wav", "audio/x-wav": + return ".wav" + case "application/json": + return ".json" + case "text/plain": + return ".txt" + default: + return ".bin" + } +} diff --git a/internal/agentruntime/tools/mcp_result_test.go b/internal/agentruntime/tools/mcp_result_test.go new file mode 100644 index 0000000..9995a03 --- /dev/null +++ b/internal/agentruntime/tools/mcp_result_test.go @@ -0,0 +1,85 @@ +package tools + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/yanpgwang/managed-agent-go/internal/mcpclient" +) + +func TestProjectMCPResult_SeparatesRawModelAndBinaryContent(t *testing.T) { + sb := newSB(t) + input := mcpclient.Result{ + Raw: json.RawMessage(`{ + "_meta":{"trace":"private-meta"}, + "content":[ + {"type":"text","text":"hello"}, + {"type":"image","mimeType":"image/png","data":"aW1hZ2UtYnl0ZXM="}, + {"type":"future_control","_meta":{"secret":"do-not-project"}} + ], + "structuredContent":{"count":2} + }`), + } + result, raw, rawPath, err := ProjectMCPResult( + context.Background(), + sb, + "sevt_mcp", + input, + ) + if err != nil { + t.Fatal(err) + } + if rawPath != "" || !strings.Contains(string(raw), "private-meta") { + t.Fatalf("raw=%s rawPath=%q", raw, rawPath) + } + text := result.Content[0].(map[string]any)["text"].(string) + if !strings.Contains(text, "hello") || + !strings.Contains(text, "tool-results/sevt_mcp-1.png") || + !strings.Contains(text, `"count": 2`) { + t.Fatalf("model projection = %q", text) + } + if strings.Contains(text, "private-meta") { + t.Fatalf("MCP _meta leaked into model projection: %q", text) + } + if strings.Contains(text, "do-not-project") || + !strings.Contains(text, "unsupported future_control content") { + t.Fatalf("unknown MCP control content was projected unsafely: %q", text) + } + binary, err := sb.ReadFile( + context.Background(), + "tool-results/sevt_mcp-1.png", + ) + if err != nil { + t.Fatal(err) + } + if string(binary) != "image-bytes" { + t.Fatalf("binary = %q", binary) + } +} + +func TestProjectMCPResult_LargeRawIsSandboxReference(t *testing.T) { + sb := newSB(t) + raw := json.RawMessage(`{"content":[{"type":"text","text":"ok"}],"_meta":{"large":"` + + strings.Repeat("x", MaxInlineResultChars) + `"}}`) + _, inline, rawPath, err := ProjectMCPResult( + context.Background(), + sb, + "sevt_raw", + mcpclient.Result{Raw: raw}, + ) + if err != nil { + t.Fatal(err) + } + if inline != nil || rawPath != "tool-results/sevt_raw.mcp.json" { + t.Fatalf("inline=%s rawPath=%q", inline, rawPath) + } + stored, err := sb.ReadFile(context.Background(), rawPath) + if err != nil { + t.Fatal(err) + } + if string(stored) != string(raw) { + t.Fatal("raw MCP result was not preserved") + } +} diff --git a/internal/agentruntime/tools/result_storage.go b/internal/agentruntime/tools/result_storage.go new file mode 100644 index 0000000..e231e7a --- /dev/null +++ b/internal/agentruntime/tools/result_storage.go @@ -0,0 +1,105 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "path" + "strings" + "unicode/utf8" + + "github.com/yanpgwang/managed-agent-go/internal/sandbox" +) + +const ( + // Managed Agents writes serialized tool output above 100,000 characters to + // the Session sandbox and gives the model a bounded preview plus file path. + MaxInlineResultChars = 100_000 + ResultPreviewChars = 2_000 + ToolResultsDirectory = "tool-results" +) + +// MaterializeLargeResult applies the common result-size policy shared by +// built-ins and, later, managed MCP executors. Small results are returned +// unchanged. Large results are written under tool-results/ using the stable +// public tool-use id and replaced with a model-readable preview. +func MaterializeLargeResult( + ctx context.Context, + sb sandbox.Sandbox, + toolUseID string, + result Result, +) (Result, error) { + serialized, extension, err := serializeResult(result.Content) + if err != nil { + return Result{}, fmt.Errorf("serialize tool result: %w", err) + } + characters := utf8.RuneCount(serialized) + if characters <= MaxInlineResultChars { + return result, nil + } + if sb == nil { + return Result{}, fmt.Errorf("materialize tool result: sandbox is required") + } + filename := safeResultFilename(toolUseID) + extension + resultPath := path.Join(ToolResultsDirectory, filename) + if err := sb.WriteFile(ctx, resultPath, serialized); err != nil { + return Result{}, fmt.Errorf("materialize tool result %q: %w", resultPath, err) + } + preview := truncateRunes(string(serialized), ResultPreviewChars) + message := fmt.Sprintf( + "\n"+ + "Tool output exceeded %d characters. The full output was saved to %s (%d characters).\n"+ + "Use the read tool with view_range to inspect it in chunks.\n\n"+ + "Preview:\n%s\n"+ + "", + MaxInlineResultChars, + resultPath, + characters, + preview, + ) + return textResult(message, result.IsError), nil +} + +func serializeResult(content []any) ([]byte, string, error) { + if len(content) == 1 { + if block, ok := content[0].(map[string]any); ok { + if typ, _ := block["type"].(string); typ == "text" { + if text, ok := block["text"].(string); ok { + return []byte(text), ".txt", nil + } + } + } + } + raw, err := json.MarshalIndent(content, "", " ") + return raw, ".json", err +} + +func safeResultFilename(toolUseID string) string { + var b strings.Builder + for _, r := range toolUseID { + switch { + case r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', + r == '-', r == '_': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + if b.Len() == 0 { + return "tool-result" + } + return b.String() +} + +func truncateRunes(value string, limit int) string { + if limit <= 0 { + return "" + } + if utf8.RuneCountInString(value) <= limit { + return value + } + runes := []rune(value) + return string(runes[:limit]) +} diff --git a/internal/agentruntime/tools/result_storage_test.go b/internal/agentruntime/tools/result_storage_test.go new file mode 100644 index 0000000..53af63e --- /dev/null +++ b/internal/agentruntime/tools/result_storage_test.go @@ -0,0 +1,59 @@ +package tools + +import ( + "context" + "strings" + "testing" +) + +func TestMaterializeLargeResult_WritesFullTextAndReturnsPreview(t *testing.T) { + sb := newSB(t) + full := strings.Repeat("界", MaxInlineResultChars+1) + got, err := MaterializeLargeResult( + context.Background(), + sb, + "sevt_large", + textResult(full, false), + ) + if err != nil { + t.Fatal(err) + } + if len(got.Content) != 1 || got.IsError { + t.Fatalf("materialized result = %#v", got) + } + message := got.Content[0].(map[string]any)["text"].(string) + if !strings.Contains(message, "tool-results/sevt_large.txt") || + !strings.Contains(message, "100001 characters") { + t.Fatalf("preview message = %q", message) + } + stored, err := sb.ReadFile(context.Background(), "tool-results/sevt_large.txt") + if err != nil { + t.Fatal(err) + } + if string(stored) != full { + t.Fatalf("stored output length = %d, want %d", len(stored), len(full)) + } +} + +func TestMaterializeLargeResult_LeavesThresholdInline(t *testing.T) { + sb := newSB(t) + full := strings.Repeat("x", MaxInlineResultChars) + got, err := MaterializeLargeResult( + context.Background(), + sb, + "sevt_inline", + textResult(full, true), + ) + if err != nil { + t.Fatal(err) + } + if !got.IsError || got.Content[0].(map[string]any)["text"] != full { + t.Fatalf("inline result changed: %#v", got) + } + if _, err := sb.ReadFile( + context.Background(), + "tool-results/sevt_inline.txt", + ); err == nil { + t.Fatal("threshold-sized result should not be written") + } +} diff --git a/internal/app/agent_service.go b/internal/app/agent_service.go index 94f1aaf..0cbc0fb 100644 --- a/internal/app/agent_service.go +++ b/internal/app/agent_service.go @@ -71,5 +71,8 @@ func validateAgent(a domain.Agent) error { if a.Model.ID == "" { return domain.Validation("model is required") } + if err := domain.ValidateToolConfiguration(a.Tools, a.MCPServers); err != nil { + return domain.Validation("invalid tool configuration: " + err.Error()) + } return validateMetadata(a.Metadata) } diff --git a/internal/controlplane/session_service.go b/internal/controlplane/session_service.go index 335bb8b..5b36f1a 100644 --- a/internal/controlplane/session_service.go +++ b/internal/controlplane/session_service.go @@ -95,6 +95,14 @@ func (s *SessionService) Create( if input.Overrides != nil { snapshot = agent.WithOverrides(*input.Overrides) } + if err := domain.ValidateToolConfiguration( + snapshot.Tools, + snapshot.MCPServers, + ); err != nil { + return domain.Session{}, domain.Validation( + "invalid agent tool configuration: " + err.Error(), + ) + } metadata := input.Metadata if metadata == nil { metadata = map[string]any{} diff --git a/internal/domain/execution.go b/internal/domain/execution.go index a249297..5253717 100644 --- a/internal/domain/execution.go +++ b/internal/domain/execution.go @@ -1,6 +1,9 @@ package domain -import "time" +import ( + "encoding/json" + "time" +) const ( PrefixRunAttempt = "ratm_" @@ -53,6 +56,11 @@ const ( type ToolStepResult struct { Content []any `json:"content"` IsError bool `json:"is_error"` + // Raw retains a bounded executor-native result for diagnostics/UI without + // placing it in model context. Large raw results are stored in the Session + // sandbox and referenced by RawPath instead. + Raw json.RawMessage `json:"raw,omitempty"` + RawPath string `json:"raw_path,omitempty"` } // ToolStep is an internal durable record of one built-in tool request and its diff --git a/internal/domain/message.go b/internal/domain/message.go index c9db571..8a1f735 100644 --- a/internal/domain/message.go +++ b/internal/domain/message.go @@ -1,6 +1,9 @@ package domain -import "strings" +import ( + "encoding/json" + "strings" +) type Role string @@ -17,6 +20,11 @@ type ContentBlock struct { Input map[string]any // tool_use: arguments ToolResultFor string // tool_result: the tool_use id it answers IsError bool // tool_result: error flag + // Raw is the complete provider content block. Provider adapters populate it + // for responses so unknown server-tool, citation, encrypted-continuation, + // and future fields can be round-tripped without flattening. Locally created + // blocks leave Raw empty and are encoded from the typed fields above. + Raw json.RawMessage `json:"raw,omitempty"` } type Message struct { @@ -24,6 +32,23 @@ type Message struct { Content []ContentBlock } +// ProviderToolUseMapping keeps provider-private tool ids separate from the +// stable public event ids exposed by Managed Agents. +type ProviderToolUseMapping struct { + PublicEventID string `json:"public_event_id"` + ProviderToolUseID string `json:"provider_tool_use_id"` + ToolName string `json:"tool_name"` +} + +// ProviderTranscript is the committed, lossless model-continuation history for +// one Session. TriggerEventIDs identifies which public turns are represented so +// a caller can safely fall back for sessions created before transcript support. +type ProviderTranscript struct { + Messages []Message `json:"messages"` + TriggerEventIDs []string `json:"trigger_event_ids"` + ToolUseMappings []ProviderToolUseMapping `json:"tool_use_mappings"` +} + // ProjectMessages folds an ordered session event log into a Messages-API // conversation. S1 handles only user.message and agent.message text blocks; // status, error, and server-only events are skipped, as are turns that carry no diff --git a/internal/domain/tools.go b/internal/domain/tools.go index dee7ceb..4763aa9 100644 --- a/internal/domain/tools.go +++ b/internal/domain/tools.go @@ -1,6 +1,12 @@ package domain -import "fmt" +import ( + "fmt" + "net/netip" + "net/url" + "strings" + "unicode/utf8" +) const BuiltinToolsetType = "agent_toolset_20260401" @@ -26,7 +32,17 @@ type CustomTool struct { InputSchema map[string]any } -type MCPToolset struct{ ServerName string } +type MCPToolset struct { + ServerName string + DefaultEnabled bool + DefaultPolicy PermissionPolicy + Configs []BuiltinConfig +} + +type MCPServer struct { + Name string + URL string +} type ToolSet struct { Builtin *BuiltinToolset @@ -34,6 +50,44 @@ type ToolSet struct { MCP []MCPToolset } +var blockedMCPAddressPrefixes = []netip.Prefix{ + netip.MustParsePrefix("100.64.0.0/10"), // shared carrier-grade NAT + netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments + netip.MustParsePrefix("192.0.2.0/24"), // documentation + netip.MustParsePrefix("198.18.0.0/15"), // benchmarking + netip.MustParsePrefix("198.51.100.0/24"), // documentation + netip.MustParsePrefix("203.0.113.0/24"), // documentation + netip.MustParsePrefix("240.0.0.0/4"), // reserved + netip.MustParsePrefix("2001:db8::/32"), // documentation +} + +// MCPAddressAllowed reports whether a resolved MCP endpoint is safe for the +// default public-internet connector. Private MCP services require a separate +// tunnel/egress capability; the worker must never reach them directly from a +// tenant-controlled URL. +func MCPAddressAllowed(address netip.Addr) bool { + address = address.Unmap() + if !address.IsValid() || + !address.IsGlobalUnicast() || + address.IsPrivate() || + address.IsLoopback() || + address.IsLinkLocalUnicast() || + address.IsLinkLocalMulticast() || + address.IsUnspecified() { + return false + } + for _, prefix := range blockedMCPAddressPrefixes { + if prefix.Contains(address) { + return false + } + } + return true +} + +func validPermissionPolicy(policy PermissionPolicy) bool { + return policy.Type == "always_allow" || policy.Type == "always_ask" +} + func parsePolicy(raw any) *PermissionPolicy { m, ok := raw.(map[string]any) if !ok { @@ -48,6 +102,8 @@ func parsePolicy(raw any) *PermissionPolicy { func ParseTools(raw []any) (ToolSet, error) { var ts ToolSet + customNames := make(map[string]struct{}) + mcpServers := make(map[string]struct{}) for _, item := range raw { m, ok := item.(map[string]any) if !ok { @@ -55,15 +111,23 @@ func ParseTools(raw []any) (ToolSet, error) { } switch m["type"] { case BuiltinToolsetType: + if ts.Builtin != nil { + return ToolSet{}, fmt.Errorf("only one built-in toolset may be configured") + } bt := &BuiltinToolset{DefaultEnabled: true, DefaultPolicy: PermissionPolicy{Type: "always_allow"}} if dc, ok := m["default_config"].(map[string]any); ok { if en, ok := dc["enabled"].(bool); ok { bt.DefaultEnabled = en } - if p := parsePolicy(dc["permission_policy"]); p != nil { + if rawPolicy, present := dc["permission_policy"]; present { + p := parsePolicy(rawPolicy) + if p == nil || !validPermissionPolicy(*p) { + return ToolSet{}, fmt.Errorf("built-in default permission_policy must be always_allow or always_ask") + } bt.DefaultPolicy = *p } } + configuredNames := make(map[string]struct{}) if cfgs, ok := m["configs"].([]any); ok { for _, c := range cfgs { cm, ok := c.(map[string]any) @@ -74,7 +138,21 @@ func ParseTools(raw []any) (ToolSet, error) { if name == "" { return ToolSet{}, fmt.Errorf("toolset config requires name") } - bc := BuiltinConfig{Name: name, Policy: parsePolicy(cm["permission_policy"])} + if !containsBuiltinTool(name) { + return ToolSet{}, fmt.Errorf("unknown built-in tool %q", name) + } + if _, duplicate := configuredNames[name]; duplicate { + return ToolSet{}, fmt.Errorf("duplicate built-in tool config %q", name) + } + configuredNames[name] = struct{}{} + bc := BuiltinConfig{Name: name} + if rawPolicy, present := cm["permission_policy"]; present { + p := parsePolicy(rawPolicy) + if p == nil || !validPermissionPolicy(*p) { + return ToolSet{}, fmt.Errorf("built-in tool %q permission_policy must be always_allow or always_ask", name) + } + bc.Policy = p + } if en, ok := cm["enabled"].(bool); ok { bc.Enabled = &en } @@ -87,6 +165,16 @@ func ParseTools(raw []any) (ToolSet, error) { if name == "" { return ToolSet{}, fmt.Errorf("custom tool requires name") } + if _, duplicate := customNames[name]; duplicate { + return ToolSet{}, fmt.Errorf("duplicate custom tool %q", name) + } + if containsBuiltinTool(name) { + return ToolSet{}, fmt.Errorf( + "custom tool %q conflicts with a built-in tool", + name, + ) + } + customNames[name] = struct{}{} ct := CustomTool{Name: name} ct.Description, _ = m["description"].(string) ct.InputSchema, _ = m["input_schema"].(map[string]any) @@ -96,7 +184,59 @@ func ParseTools(raw []any) (ToolSet, error) { if sn == "" { return ToolSet{}, fmt.Errorf("mcp_toolset requires mcp_server_name") } - ts.MCP = append(ts.MCP, MCPToolset{ServerName: sn}) + if _, duplicate := mcpServers[sn]; duplicate { + return ToolSet{}, fmt.Errorf("duplicate mcp_toolset for server %q", sn) + } + mcpServers[sn] = struct{}{} + mt := MCPToolset{ + ServerName: sn, + DefaultEnabled: true, + DefaultPolicy: PermissionPolicy{ + Type: "always_ask", + }, + } + if dc, ok := m["default_config"].(map[string]any); ok { + if en, ok := dc["enabled"].(bool); ok { + mt.DefaultEnabled = en + } + if rawPolicy, present := dc["permission_policy"]; present { + p := parsePolicy(rawPolicy) + if p == nil || !validPermissionPolicy(*p) { + return ToolSet{}, fmt.Errorf("mcp server %q default permission_policy must be always_allow or always_ask", sn) + } + mt.DefaultPolicy = *p + } + } + configuredNames := make(map[string]struct{}) + if cfgs, ok := m["configs"].([]any); ok { + for _, c := range cfgs { + cm, ok := c.(map[string]any) + if !ok { + return ToolSet{}, fmt.Errorf("mcp toolset config must be an object") + } + name, _ := cm["name"].(string) + if name == "" { + return ToolSet{}, fmt.Errorf("mcp toolset config requires name") + } + if _, duplicate := configuredNames[name]; duplicate { + return ToolSet{}, fmt.Errorf("duplicate mcp tool config %q for server %q", name, sn) + } + configuredNames[name] = struct{}{} + cfg := BuiltinConfig{Name: name} + if rawPolicy, present := cm["permission_policy"]; present { + p := parsePolicy(rawPolicy) + if p == nil || !validPermissionPolicy(*p) { + return ToolSet{}, fmt.Errorf("mcp tool %s/%s permission_policy must be always_allow or always_ask", sn, name) + } + cfg.Policy = p + } + if en, ok := cm["enabled"].(bool); ok { + cfg.Enabled = &en + } + mt.Configs = append(mt.Configs, cfg) + } + } + ts.MCP = append(ts.MCP, mt) default: return ToolSet{}, fmt.Errorf("unknown tool type %v", m["type"]) } @@ -104,6 +244,116 @@ func ParseTools(raw []any) (ToolSet, error) { return ts, nil } +func containsBuiltinTool(name string) bool { + for _, candidate := range BuiltinToolNames { + if candidate == name { + return true + } + } + return false +} + +func (ts MCPToolset) ToolEnabled(name string) (bool, PermissionPolicy) { + enabled := ts.DefaultEnabled + policy := ts.DefaultPolicy + for _, config := range ts.Configs { + if config.Name != name { + continue + } + if config.Enabled != nil { + enabled = *config.Enabled + } + if config.Policy != nil { + policy = *config.Policy + } + } + return enabled, policy +} + +func ParseMCPServers(raw []any) (map[string]MCPServer, error) { + if len(raw) > 20 { + return nil, fmt.Errorf("an agent may configure at most 20 MCP servers") + } + servers := make(map[string]MCPServer, len(raw)) + for _, item := range raw { + value, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("mcp server entry must be an object") + } + serverType, _ := value["type"].(string) + name, _ := value["name"].(string) + rawURL, _ := value["url"].(string) + if serverType != "url" || + strings.TrimSpace(name) == "" || + strings.TrimSpace(rawURL) == "" { + return nil, fmt.Errorf("mcp server requires type=url, name, and url") + } + if utf8.RuneCountInString(name) > 255 { + return nil, fmt.Errorf("mcp server name exceeds 255 characters") + } + if utf8.RuneCountInString(rawURL) > 2048 { + return nil, fmt.Errorf("mcp server %q url exceeds 2048 characters", name) + } + if _, duplicate := servers[name]; duplicate { + return nil, fmt.Errorf("duplicate mcp server name %q", name) + } + parsed, err := url.Parse(rawURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || + parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" { + return nil, fmt.Errorf("mcp server %q has an invalid http(s) url", name) + } + if address, err := netip.ParseAddr(parsed.Hostname()); err == nil && + !MCPAddressAllowed(address) { + return nil, fmt.Errorf( + "mcp server %q url targets a non-public network", + name, + ) + } + servers[name] = MCPServer{Name: name, URL: parsed.String()} + } + return servers, nil +} + +// ValidateToolConfiguration performs control-plane validation that does not +// require contacting a provider or MCP server. It keeps malformed or +// internally inconsistent capabilities out of immutable Agent versions and +// Session snapshots. +func ValidateToolConfiguration(rawTools, rawServers []any) error { + toolSet, err := ParseTools(rawTools) + if err != nil { + return err + } + servers, err := ParseMCPServers(rawServers) + if err != nil { + return err + } + referenced := make(map[string]struct{}, len(toolSet.MCP)) + for _, configured := range toolSet.MCP { + if _, ok := servers[configured.ServerName]; !ok { + return fmt.Errorf( + "mcp_toolset references unknown server %q", + configured.ServerName, + ) + } + referenced[configured.ServerName] = struct{}{} + } + for name := range servers { + if _, ok := referenced[name]; !ok { + return fmt.Errorf("MCP server %q has no matching mcp_toolset", name) + } + } + for _, name := range []string{"web_search", "web_fetch"} { + enabled, policy := toolSet.BuiltinEnabled(name) + if enabled && policy.Type != "always_allow" { + return fmt.Errorf( + "%s requires always_allow while it is provider-native", + name, + ) + } + } + return nil +} + func (ts ToolSet) BuiltinEnabled(name string) (bool, PermissionPolicy) { if ts.Builtin == nil { return false, PermissionPolicy{} diff --git a/internal/domain/tools_test.go b/internal/domain/tools_test.go index 7c8062a..d38875c 100644 --- a/internal/domain/tools_test.go +++ b/internal/domain/tools_test.go @@ -1,6 +1,9 @@ package domain -import "testing" +import ( + "net/netip" + "testing" +) func TestParseTools_BuiltinCustomMCP(t *testing.T) { raw := []any{ @@ -14,7 +17,16 @@ func TestParseTools_BuiltinCustomMCP(t *testing.T) { }, map[string]any{"type": "custom", "name": "get_weather", "description": "d", "input_schema": map[string]any{"type": "object"}}, - map[string]any{"type": "mcp_toolset", "mcp_server_name": "github"}, + map[string]any{ + "type": "mcp_toolset", + "mcp_server_name": "github", + "configs": []any{map[string]any{ + "name": "delete_issue", + "permission_policy": map[string]any{ + "type": "always_allow", + }, + }}, + }, } ts, err := ParseTools(raw) if err != nil { @@ -38,6 +50,12 @@ func TestParseTools_BuiltinCustomMCP(t *testing.T) { if en, pol := ts.BuiltinEnabled("read"); !en || pol.Type != "always_allow" { t.Fatalf("read en=%v pol=%v", en, pol.Type) } + if en, pol := ts.MCP[0].ToolEnabled("list_issues"); !en || pol.Type != "always_ask" { + t.Fatalf("MCP default en=%v pol=%v", en, pol.Type) + } + if en, pol := ts.MCP[0].ToolEnabled("delete_issue"); !en || pol.Type != "always_allow" { + t.Fatalf("MCP override en=%v pol=%v", en, pol.Type) + } } func TestParseTools_RejectsUnknownType(t *testing.T) { @@ -53,3 +71,118 @@ func TestParseTools_Empty(t *testing.T) { t.Fatalf("empty parse = %+v err=%v", ts, err) } } + +func TestParseMCPServers_ValidatesIdentityAndURL(t *testing.T) { + servers, err := ParseMCPServers([]any{map[string]any{ + "type": "url", "name": "github", "url": "https://example.com/mcp", + }}) + if err != nil || servers["github"].URL != "https://example.com/mcp" { + t.Fatalf("servers=%#v err=%v", servers, err) + } + for _, raw := range [][]any{ + {map[string]any{"type": "url", "name": "x", "url": "file:///tmp/mcp"}}, + {map[string]any{"type": "url", "name": "x", "url": "https://u:p@example.com/mcp"}}, + {map[string]any{"type": "url", "name": "x", "url": "http://127.0.0.1/mcp"}}, + {map[string]any{"type": "url", "name": "x", "url": "http://169.254.169.254/mcp"}}, + {map[string]any{"type": "url", "name": "x", "url": "http://[::1]/mcp"}}, + { + map[string]any{"type": "url", "name": "x", "url": "https://one.example"}, + map[string]any{"type": "url", "name": "x", "url": "https://two.example"}, + }, + } { + if _, err := ParseMCPServers(raw); err == nil { + t.Fatalf("expected invalid MCP servers to fail: %#v", raw) + } + } +} + +func TestMCPAddressAllowed(t *testing.T) { + for _, raw := range []string{ + "127.0.0.1", + "10.0.0.1", + "100.64.0.1", + "169.254.169.254", + "192.168.1.1", + "::1", + "fc00::1", + "fe80::1", + "2001:db8::1", + } { + address := netip.MustParseAddr(raw) + if MCPAddressAllowed(address) { + t.Errorf("non-public address %s was allowed", raw) + } + } + for _, raw := range []string{ + "8.8.8.8", + "1.1.1.1", + "2606:4700:4700::1111", + } { + address := netip.MustParseAddr(raw) + if !MCPAddressAllowed(address) { + t.Errorf("public address %s was rejected", raw) + } + } +} + +func TestValidateToolConfiguration_RequiresMatchingMCPPairs(t *testing.T) { + server := []any{map[string]any{ + "type": "url", "name": "github", "url": "https://mcp.example.com", + }} + toolset := []any{map[string]any{ + "type": "mcp_toolset", "mcp_server_name": "github", + }} + if err := ValidateToolConfiguration(toolset, server); err != nil { + t.Fatalf("valid MCP pair: %v", err) + } + if err := ValidateToolConfiguration(nil, server); err == nil { + t.Fatal("expected unreferenced MCP server to fail") + } + if err := ValidateToolConfiguration(toolset, nil); err == nil { + t.Fatal("expected dangling MCP toolset to fail") + } +} + +func TestValidateToolConfiguration_RejectsApprovalForNativeWeb(t *testing.T) { + err := ValidateToolConfiguration([]any{map[string]any{ + "type": BuiltinToolsetType, + "configs": []any{map[string]any{ + "name": "web_search", + "permission_policy": map[string]any{ + "type": "always_ask", + }, + }}, + }}, nil) + if err == nil { + t.Fatal("expected provider-native web approval policy to fail") + } +} + +func TestParseTools_RejectsDuplicateAndUnknownConfiguration(t *testing.T) { + cases := [][]any{ + { + map[string]any{"type": BuiltinToolsetType}, + map[string]any{"type": BuiltinToolsetType}, + }, + {map[string]any{ + "type": BuiltinToolsetType, + "configs": []any{ + map[string]any{"name": "not_a_builtin"}, + }, + }}, + {map[string]any{ + "type": "mcp_toolset", "mcp_server_name": "github", + "default_config": map[string]any{ + "permission_policy": map[string]any{"type": "sometimes"}, + }, + }}, + {map[string]any{ + "type": "custom", "name": "bash", + }}, + } + for _, raw := range cases { + if _, err := ParseTools(raw); err == nil { + t.Fatalf("expected invalid tool configuration to fail: %#v", raw) + } + } +} diff --git a/internal/httpapi/agents_test.go b/internal/httpapi/agents_test.go index dd43356..20900cd 100644 --- a/internal/httpapi/agents_test.go +++ b/internal/httpapi/agents_test.go @@ -131,7 +131,7 @@ func TestAgents_UpdateArrayNullClears(t *testing.T) { srv := newTestServer(t) rec := do(srv, "POST", "/v1/agents", `{"name":"Agent","model":"claude-opus-4-8",`+ - `"tools":[{"type":"custom","name":"x"}],`+ + `"tools":[{"type":"custom","name":"x"},{"type":"mcp_toolset","mcp_server_name":"m"}],`+ `"mcp_servers":[{"type":"url","name":"m","url":"https://example.com"}],`+ `"skills":[{"type":"anthropic","skill_id":"xlsx","version":"1"}]}`) if rec.Code != http.StatusOK { diff --git a/internal/mcpclient/client.go b/internal/mcpclient/client.go new file mode 100644 index 0000000..826655b --- /dev/null +++ b/internal/mcpclient/client.go @@ -0,0 +1,246 @@ +// Package mcpclient owns remote MCP discovery and invocation. It deliberately +// returns protocol-native JSON for tool results; the agent runtime separately +// derives model-facing and public projections. +package mcpclient + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/netip" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/yanpgwang/managed-agent-go/internal/domain" +) + +type Tool struct { + Name string + Description string + InputSchema map[string]any + OutputSchema any +} + +type Result struct { + Raw json.RawMessage + IsError bool +} + +type Client interface { + Discover(ctx context.Context, server domain.MCPServer) ([]Tool, error) + Call( + ctx context.Context, + server domain.MCPServer, + toolName string, + input map[string]any, + ) (Result, error) +} + +type Remote struct { + http *http.Client +} + +func NewRemote(httpClient *http.Client) *Remote { + if httpClient == nil { + transport := http.DefaultTransport.(*http.Transport).Clone() + // Tenant-configured MCP endpoints must not inherit a process-wide proxy + // that can bypass target-IP validation. A future managed egress proxy is + // an explicit connector capability, not an ambient environment setting. + transport.Proxy = nil + transport.DialContext = dialPublicMCP + httpClient = &http.Client{ + Timeout: 60 * time.Second, + Transport: transport, + } + } + return &Remote{http: httpClient} +} + +func dialPublicMCP( + ctx context.Context, + network string, + address string, +) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("mcp egress: parse address: %w", err) + } + var addresses []net.IP + if literal := net.ParseIP(host); literal != nil { + addresses = []net.IP{literal} + } else { + resolved, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + return nil, fmt.Errorf("mcp egress: resolve %s: %w", host, err) + } + addresses = resolved + } + + dialer := &net.Dialer{Timeout: 15 * time.Second} + var lastErr error + allowed := false + for _, candidate := range addresses { + parsed, ok := netipAddr(candidate) + if !ok || !domain.MCPAddressAllowed(parsed) { + continue + } + allowed = true + connection, err := dialer.DialContext( + ctx, + network, + net.JoinHostPort(parsed.String(), port), + ) + if err == nil { + return connection, nil + } + lastErr = err + } + if !allowed { + return nil, fmt.Errorf( + "mcp egress: %s resolves only to non-public addresses", + host, + ) + } + return nil, fmt.Errorf("mcp egress: dial %s: %w", host, lastErr) +} + +func netipAddr(value net.IP) (address netip.Addr, ok bool) { + address, ok = netip.AddrFromSlice(value) + if ok { + address = address.Unmap() + } + return address, ok +} + +func (r *Remote) Discover( + ctx context.Context, + server domain.MCPServer, +) ([]Tool, error) { + session, err := r.connect(ctx, server) + if err != nil { + return nil, err + } + defer func() { + // Shutdown errors do not invalidate a completed discovery response. + _ = session.Close() + }() + + var out []Tool + cursor := "" + for { + result, err := session.ListTools(ctx, &mcp.ListToolsParams{ + Cursor: cursor, + }) + if err != nil { + return nil, fmt.Errorf( + "mcp %s: list tools: %w", + server.Name, + err, + ) + } + for _, tool := range result.Tools { + if tool == nil || tool.Name == "" { + continue + } + schema, ok := tool.InputSchema.(map[string]any) + if !ok { + raw, err := json.Marshal(tool.InputSchema) + if err != nil { + return nil, fmt.Errorf( + "mcp %s tool %s: encode input schema: %w", + server.Name, + tool.Name, + err, + ) + } + if err := json.Unmarshal(raw, &schema); err != nil { + return nil, fmt.Errorf( + "mcp %s tool %s: input schema is not an object", + server.Name, + tool.Name, + ) + } + } + out = append(out, Tool{ + Name: tool.Name, + Description: tool.Description, + InputSchema: schema, + OutputSchema: tool.OutputSchema, + }) + } + if result.NextCursor == "" { + break + } + cursor = result.NextCursor + } + return out, nil +} + +func (r *Remote) Call( + ctx context.Context, + server domain.MCPServer, + toolName string, + input map[string]any, +) (Result, error) { + session, err := r.connect(ctx, server) + if err != nil { + return Result{}, err + } + defer func() { + // A shutdown error after CallTool returned must not trigger a retry of a + // possibly side-effecting operation. + _ = session.Close() + }() + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: toolName, + Arguments: input, + }) + if err != nil { + return Result{}, fmt.Errorf( + "mcp %s tool %s: call: %w", + server.Name, + toolName, + err, + ) + } + raw, err := json.Marshal(result) + if err != nil { + return Result{}, fmt.Errorf( + "mcp %s tool %s: encode result: %w", + server.Name, + toolName, + err, + ) + } + return Result{ + Raw: json.RawMessage(raw), + IsError: result.IsError, + }, nil +} + +func (r *Remote) connect( + ctx context.Context, + server domain.MCPServer, +) (*mcp.ClientSession, error) { + client := mcp.NewClient(&mcp.Implementation{ + Name: "managed-agent-go", + Version: "dev", + }, nil) + session, err := client.Connect( + ctx, + &mcp.StreamableClientTransport{ + Endpoint: server.URL, + HTTPClient: r.http, + MaxRetries: -1, + DisableStandaloneSSE: true, + }, + nil, + ) + if err != nil { + return nil, fmt.Errorf("mcp %s: connect: %w", server.Name, err) + } + return session, nil +} diff --git a/internal/mcpclient/client_test.go b/internal/mcpclient/client_test.go new file mode 100644 index 0000000..6f1b737 --- /dev/null +++ b/internal/mcpclient/client_test.go @@ -0,0 +1,37 @@ +package mcpclient + +import ( + "context" + "net/http" + "strings" + "testing" +) + +func TestDialPublicMCPRejectsLocalhostResolution(t *testing.T) { + connection, err := dialPublicMCP( + context.Background(), + "tcp", + "localhost:80", + ) + if connection != nil { + _ = connection.Close() + t.Fatal("localhost connection unexpectedly succeeded") + } + if err == nil || !strings.Contains(err.Error(), "non-public") { + t.Fatalf("expected non-public egress rejection, got %v", err) + } +} + +func TestNewRemoteDefaultTransportDisablesAmbientProxy(t *testing.T) { + remote := NewRemote(nil) + transport, ok := remote.http.Transport.(*http.Transport) + if !ok { + t.Fatalf("transport type = %T", remote.http.Transport) + } + if transport.Proxy != nil { + t.Fatal("default MCP transport inherited an ambient proxy") + } + if transport.DialContext == nil { + t.Fatal("default MCP transport has no egress-enforcing dialer") + } +} diff --git a/internal/model/anthropic.go b/internal/model/anthropic.go index 3d338a0..238e45c 100644 --- a/internal/model/anthropic.go +++ b/internal/model/anthropic.go @@ -95,13 +95,14 @@ type wireBlock struct { IsError bool `json:"is_error,omitempty"` } type wireMessage struct { - Role string `json:"role"` - Content []wireBlock `json:"content"` + Role string `json:"role"` + Content []json.RawMessage `json:"content"` } type wireTool struct { + Type string `json:"type,omitempty"` Name string `json:"name"` Description string `json:"description,omitempty"` - InputSchema map[string]any `json:"input_schema"` + InputSchema map[string]any `json:"input_schema,omitempty"` } type wireRequest struct { Model string `json:"model"` @@ -112,14 +113,14 @@ type wireRequest struct { Stream bool `json:"stream,omitempty"` } type wireResponse struct { - Content []wireBlock `json:"content"` - StopReason string `json:"stop_reason"` + Content []json.RawMessage `json:"content"` + StopReason string `json:"stop_reason"` } // buildWireRequest maps a domain Request to the Anthropic Messages wire request, // applying config defaults for model and max_tokens. stream toggles the // server-sent-events variant ("stream": true). -func (a *Anthropic) buildWireRequest(req Request, stream bool) wireRequest { +func (a *Anthropic) buildWireRequest(req Request, stream bool) (wireRequest, error) { model := req.Model if model == "" { model = a.cfg.Model @@ -131,6 +132,7 @@ func (a *Anthropic) buildWireRequest(req Request, stream bool) wireRequest { body := wireRequest{Model: model, System: req.System, MaxTokens: maxTokens, Stream: stream} for _, t := range req.Tools { body.Tools = append(body.Tools, wireTool{ + Type: t.Type, Name: t.Name, Description: t.Description, InputSchema: t.InputSchema, @@ -139,11 +141,19 @@ func (a *Anthropic) buildWireRequest(req Request, stream bool) wireRequest { for _, m := range req.Messages { wm := wireMessage{Role: string(m.Role)} for _, b := range m.Content { - wm.Content = append(wm.Content, toWireBlock(b)) + raw, err := toWireBlock(b) + if err != nil { + return wireRequest{}, fmt.Errorf( + "model: encode %s content block: %w", + b.Type, + err, + ) + } + wm.Content = append(wm.Content, raw) } body.Messages = append(body.Messages, wm) } - return body + return body, nil } // newHTTPRequest marshals the wire body and builds the POST /v1/messages request @@ -168,7 +178,11 @@ func (a *Anthropic) newHTTPRequest(ctx context.Context, body wireRequest) (*http } func (a *Anthropic) CreateMessage(ctx context.Context, req Request) (Response, error) { - httpReq, err := a.newHTTPRequest(ctx, a.buildWireRequest(req, false)) + body, err := a.buildWireRequest(req, false) + if err != nil { + return Response{}, err + } + httpReq, err := a.newHTTPRequest(ctx, body) if err != nil { return Response{}, err } @@ -187,12 +201,10 @@ func (a *Anthropic) CreateMessage(ctx context.Context, req Request) (Response, e return Response{}, fmt.Errorf("model: decode response: %w", err) } out := Response{StopReason: wr.StopReason} - for _, b := range wr.Content { - cb := domain.ContentBlock{Type: b.Type, Text: b.Text} - if b.Type == "tool_use" { - cb.ToolUseID = b.ID - cb.ToolName = b.Name - cb.Input = b.Input + for _, raw := range wr.Content { + cb, err := parseProviderBlock(raw) + if err != nil { + return Response{}, fmt.Errorf("model: decode content block: %w", err) } out.Content = append(out.Content, cb) } @@ -225,7 +237,32 @@ func (a *Anthropic) CreateMessage(ctx context.Context, req Request) (Response, e // at content_block_stop. This is enough for the tool loop (which needs the // complete tool call), but partial_json is not surfaced incrementally. func (a *Anthropic) CreateMessageStream(ctx context.Context, req Request, onDelta func(index int, text string)) (Response, error) { - httpReq, err := a.newHTTPRequest(ctx, a.buildWireRequest(req, true)) + // Server-tool responses contain provider-private blocks and citation + // structures whose streaming deltas evolve independently of the client-tool + // wire shape. Until the streaming assembler can losslessly retain every + // unknown delta, use the non-streaming endpoint whenever a native tool is + // enabled. This preserves the exact response block while still publishing + // complete text blocks as best-effort preview deltas. + if hasNativeTool(req.Tools) { + resp, err := a.CreateMessage(ctx, req) + if err != nil { + return Response{}, err + } + if onDelta != nil { + for index, block := range resp.Content { + if block.Type == "text" && block.Text != "" { + onDelta(index, block.Text) + } + } + } + return resp, nil + } + + body, err := a.buildWireRequest(req, true) + if err != nil { + return Response{}, err + } + httpReq, err := a.newHTTPRequest(ctx, body) if err != nil { return Response{}, err } @@ -385,6 +422,11 @@ func decodeMessageStream(body io.Reader, onDelta func(index int, text string)) ( default: cb.Text = b.text.String() } + raw, err := marshalTypedBlock(cb) + if err != nil { + return Response{}, fmt.Errorf("model: encode streamed content block: %w", err) + } + cb.Raw = raw out.Content = append(out.Content, cb) } return out, nil @@ -394,19 +436,65 @@ func decodeMessageStream(body io.Reader, onDelta func(index int, text string)) ( // text blocks carry only text; tool_use blocks carry id/name/input; tool_result // blocks carry tool_use_id, is_error, and a content array of text blocks (the // wire shape the API requires, [{type:"text",text:...}]). -func toWireBlock(b domain.ContentBlock) wireBlock { +func toWireBlock(b domain.ContentBlock) (json.RawMessage, error) { + if len(b.Raw) > 0 { + return append(json.RawMessage(nil), b.Raw...), nil + } + return marshalTypedBlock(b) +} + +func marshalTypedBlock(b domain.ContentBlock) (json.RawMessage, error) { + var value any switch b.Type { case "tool_use": - return wireBlock{Type: "tool_use", ID: b.ToolUseID, Name: b.ToolName, Input: b.Input} + value = wireBlock{Type: "tool_use", ID: b.ToolUseID, Name: b.ToolName, Input: b.Input} case "tool_result": wb := wireBlock{Type: "tool_result", ToolUseID: b.ToolResultFor, IsError: b.IsError} if b.Text != "" { wb.Content = []wireBlock{{Type: "text", Text: b.Text}} } - return wb + value = wb default: - return wireBlock{Type: b.Type, Text: b.Text} + value = wireBlock{Type: b.Type, Text: b.Text} + } + raw, err := json.Marshal(value) + return json.RawMessage(raw), err +} + +func parseProviderBlock(raw json.RawMessage) (domain.ContentBlock, error) { + var header struct { + Type string `json:"type"` + Text string `json:"text"` + ID string `json:"id"` + Name string `json:"name"` + Input map[string]any `json:"input"` + } + if err := json.Unmarshal(raw, &header); err != nil { + return domain.ContentBlock{}, err + } + if header.Type == "" { + return domain.ContentBlock{}, errors.New("content block type is required") + } + block := domain.ContentBlock{ + Type: header.Type, + Text: header.Text, + Raw: append(json.RawMessage(nil), raw...), + } + if header.Type == "tool_use" { + block.ToolUseID = header.ID + block.ToolName = header.Name + block.Input = header.Input + } + return block, nil +} + +func hasNativeTool(tools []ToolSchema) bool { + for _, tool := range tools { + if tool.Type != "" { + return true + } } + return false } const maxUpstreamErrorLen = 512 diff --git a/internal/model/anthropic_test.go b/internal/model/anthropic_test.go index 50580d4..f40ad9e 100644 --- a/internal/model/anthropic_test.go +++ b/internal/model/anthropic_test.go @@ -59,6 +59,37 @@ func TestAnthropic_SendsMessagesAndParsesResponse(t *testing.T) { } } +func TestAnthropic_InvalidTypedBlockFailsBeforeHTTPRequest(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + requests++ + })) + defer srv.Close() + c, err := NewAnthropic(AnthropicConfig{ + BaseURL: srv.URL, APIKey: "sk-test", Model: "m", HTTPClient: srv.Client(), + }) + if err != nil { + t.Fatal(err) + } + _, err = c.CreateMessage(context.Background(), Request{ + Messages: []domain.Message{{ + Role: domain.RoleAssistant, + Content: []domain.ContentBlock{{ + Type: "tool_use", + Input: map[string]any{ + "invalid": func() {}, + }, + }}, + }}, + }) + if err == nil || !strings.Contains(err.Error(), "encode tool_use content block") { + t.Fatalf("expected typed block encoding error, got %v", err) + } + if requests != 0 { + t.Fatalf("sent %d requests after local encoding failure", requests) + } +} + func TestAnthropic_BearerAuthAndErrorStatus(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer sk-test" { @@ -254,6 +285,81 @@ func TestAnthropic_ParsesToolUseResponse(t *testing.T) { } } +func TestAnthropic_NativeWebToolsPreserveOpaqueBlocksForReplay(t *testing.T) { + var requests []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + requests = append(requests, body) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{ + "content": [ + {"type":"server_tool_use","id":"srv_1","name":"web_search","input":{"query":"today"}}, + {"type":"web_search_tool_result","tool_use_id":"srv_1","content":[{"type":"web_search_result","url":"https://example.com","title":"Example","encrypted_content":"opaque-token"}]}, + {"type":"text","text":"answer","citations":[{"type":"web_search_result_location","url":"https://example.com","cited_text":"source"}]} + ], + "stop_reason":"end_turn" + }`) + })) + defer srv.Close() + + c, _ := NewAnthropic(AnthropicConfig{ + BaseURL: srv.URL, APIKey: "sk-test", Model: "m", HTTPClient: srv.Client(), + }) + req := Request{ + Tools: []ToolSchema{ + {Type: "web_search_20260318", Name: "web_search"}, + {Type: "web_fetch_20260318", Name: "web_fetch"}, + }, + Messages: []domain.Message{{ + Role: domain.RoleUser, + Content: []domain.ContentBlock{{ + Type: "text", Text: "what happened today?", + }}, + }}, + } + resp, err := c.CreateMessageStream(context.Background(), req, nil) + if err != nil { + t.Fatal(err) + } + if len(resp.Content) != 3 || resp.Content[1].Type != "web_search_tool_result" { + t.Fatalf("response content = %#v", resp.Content) + } + + req.Messages = append(req.Messages, domain.Message{ + Role: domain.RoleAssistant, Content: resp.Content, + }) + req.Messages = append(req.Messages, domain.Message{ + Role: domain.RoleUser, + Content: []domain.ContentBlock{{Type: "text", Text: "continue"}}, + }) + if _, err := c.CreateMessage(context.Background(), req); err != nil { + t.Fatal(err) + } + if len(requests) != 2 { + t.Fatalf("requests = %d, want 2", len(requests)) + } + tools := requests[0]["tools"].([]any) + search := tools[0].(map[string]any) + if search["type"] != "web_search_20260318" || search["name"] != "web_search" { + t.Fatalf("native search declaration = %#v", search) + } + if _, exists := search["input_schema"]; exists { + t.Fatalf("native search must not be encoded as a client tool: %#v", search) + } + replayed := requests[1]["messages"].([]any)[1].(map[string]any)["content"].([]any) + searchResult := replayed[1].(map[string]any) + nested := searchResult["content"].([]any)[0].(map[string]any) + if nested["encrypted_content"] != "opaque-token" { + t.Fatalf("opaque server-tool content was lost: %#v", searchResult) + } + text := replayed[2].(map[string]any) + if len(text["citations"].([]any)) != 1 { + t.Fatalf("citations were lost: %#v", text) + } +} + // An upstream error body long enough to hit the truncation limit, with a // multibyte rune straddling the byte boundary, must be truncated on a rune // boundary so the returned error string is valid UTF-8. diff --git a/internal/model/client.go b/internal/model/client.go index 9dccc9c..4b3cc1f 100644 --- a/internal/model/client.go +++ b/internal/model/client.go @@ -11,6 +11,9 @@ import ( ) type ToolSchema struct { + // Type is set for provider-native server tools. Ordinary client tools leave + // it empty and provide Description/InputSchema. + Type string Name string Description string InputSchema map[string]any diff --git a/internal/model/fake.go b/internal/model/fake.go index de28fb8..9cac074 100644 --- a/internal/model/fake.go +++ b/internal/model/fake.go @@ -30,9 +30,9 @@ func (f *Fake) CreateMessage(_ context.Context, req Request) (Response, error) { return Response{}, err } - if len(req.Tools) > 0 && !hasToolResult(req.Messages) { + if tool, ok := firstClientTool(req.Tools); ok && !hasToolResult(req.Messages) { return Response{ - Content: []domain.ContentBlock{{Type: "tool_use", ToolUseID: "fake_tool_1", ToolName: req.Tools[0].Name, Input: map[string]any{}}}, + Content: []domain.ContentBlock{{Type: "tool_use", ToolUseID: "fake_tool_1", ToolName: tool.Name, Input: map[string]any{}}}, StopReason: "tool_use", }, nil } @@ -54,6 +54,15 @@ func (f *Fake) CreateMessage(_ context.Context, req Request) (Response, error) { }, nil } +func firstClientTool(tools []ToolSchema) (ToolSchema, bool) { + for _, tool := range tools { + if tool.Type == "" { + return tool, true + } + } + return ToolSchema{}, false +} + // SetError makes subsequent calls return err after recording their request. // It is intended for deterministic model-boundary and orchestration tests. func (f *Fake) SetError(err error) { diff --git a/internal/pg/interrupt_test.go b/internal/pg/interrupt_test.go index 2fe74ab..6554b67 100644 --- a/internal/pg/interrupt_test.go +++ b/internal/pg/interrupt_test.go @@ -146,7 +146,47 @@ func TestCompleteWorkflowTurn_InterruptWinsCompletionRace(t *testing.T) { } interruptID := interruptAdmission.Events[0].ID failure := "provider failed after interrupt admission" - completion, err := store.CompleteWorkflowTurn( + transcriptDelta := []domain.Message{ + { + Role: domain.RoleUser, + Content: []domain.ContentBlock{{ + Type: "text", Text: "run tools", + }}, + }, + { + Role: domain.RoleAssistant, + Content: []domain.ContentBlock{ + { + Type: "tool_use", ToolUseID: "provider_orphan", + ToolName: "client_tool", Input: map[string]any{}, + }, + { + Type: "tool_use", ToolUseID: "provider_completed", + ToolName: "read", Input: map[string]any{"path": "done.txt"}, + }, + }, + }, + { + Role: domain.RoleUser, + Content: []domain.ContentBlock{{ + Type: "tool_result", ToolResultFor: "provider_completed", + Text: "done", + }}, + }, + } + mappings := []domain.ProviderToolUseMapping{ + { + PublicEventID: "sevt_orphan_custom", + ProviderToolUseID: "provider_orphan", + ToolName: "client_tool", + }, + { + PublicEventID: "sevt_completed_tool", + ProviderToolUseID: "provider_completed", + ToolName: "read", + }, + } + completion, err := store.CompleteWorkflowTurnWithTranscript( ctx, sessionID, trigger, @@ -195,6 +235,8 @@ func TestCompleteWorkflowTurn_InterruptWinsCompletionRace(t *testing.T) { &failure, nil, nil, + transcriptDelta, + mappings, ) if err != nil { t.Fatalf("complete interrupted turn: %v", err) @@ -270,6 +312,25 @@ func TestCompleteWorkflowTurn_InterruptWinsCompletionRace(t *testing.T) { if interrupt.ProcessedAt == nil { t.Fatal("winning interrupt was not marked processed atomically") } + transcript, err := store.LoadProviderTranscript(ctx, sessionID) + if err != nil { + t.Fatalf("load provider transcript: %v", err) + } + if len(transcript.Messages) != 3 || + len(transcript.Messages[2].Content) != 2 { + t.Fatalf("interrupted provider transcript = %#v", transcript.Messages) + } + synthetic := transcript.Messages[2].Content[1] + if synthetic.ToolResultFor != "provider_orphan" || !synthetic.IsError { + t.Fatalf("interrupted synthetic result = %#v", synthetic) + } + if len(transcript.ToolUseMappings) != 1 || + transcript.ToolUseMappings[0].PublicEventID != "sevt_completed_tool" { + t.Fatalf( + "interrupted provider mappings = %#v", + transcript.ToolUseMappings, + ) + } } func TestCompleteWorkflowTurn_InterruptRedirectPublishesIdleThenRunning(t *testing.T) { diff --git a/internal/pg/mcp.go b/internal/pg/mcp.go new file mode 100644 index 0000000..7aa00c2 --- /dev/null +++ b/internal/pg/mcp.go @@ -0,0 +1,85 @@ +package pg + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "github.com/yanpgwang/managed-agent-go/internal/domain" + "github.com/yanpgwang/managed-agent-go/internal/mcpclient" + "github.com/yanpgwang/managed-agent-go/internal/pg/pgstore" +) + +func (s *Store) GetMCPDiscoverySnapshot( + ctx context.Context, + sessionID string, + server domain.MCPServer, +) ([]mcpclient.Tool, bool, error) { + row, err := s.q.GetMCPDiscoverySnapshot( + ctx, + pgstore.GetMCPDiscoverySnapshotParams{ + SessionID: sessionID, + ServerName: server.Name, + }, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if row.ServerUrl != server.URL { + return nil, false, domain.Conflict( + "MCP server URL differs from the Session discovery snapshot", + ) + } + var tools []mcpclient.Tool + if err := json.Unmarshal(row.Tools, &tools); err != nil { + return nil, false, fmt.Errorf( + "pg: decode MCP discovery snapshot %s/%s: %w", + sessionID, + server.Name, + err, + ) + } + return tools, true, nil +} + +func (s *Store) PutMCPDiscoverySnapshot( + ctx context.Context, + sessionID string, + server domain.MCPServer, + tools []mcpclient.Tool, +) ([]mcpclient.Tool, error) { + raw, err := json.Marshal(tools) + if err != nil { + return nil, err + } + if err := s.q.InsertMCPDiscoverySnapshot( + ctx, + pgstore.InsertMCPDiscoverySnapshotParams{ + SessionID: sessionID, + ServerName: server.Name, + ServerUrl: server.URL, + Tools: raw, + CreatedAt: tsUTC(s.clock.Now().UTC()), + }, + ); err != nil { + return nil, err + } + authoritative, found, err := s.GetMCPDiscoverySnapshot( + ctx, + sessionID, + server, + ) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("pg: MCP discovery snapshot insert was not visible") + } + return authoritative, nil +} diff --git a/internal/pg/mcp_test.go b/internal/pg/mcp_test.go new file mode 100644 index 0000000..851f5e5 --- /dev/null +++ b/internal/pg/mcp_test.go @@ -0,0 +1,51 @@ +package pg + +import ( + "context" + "testing" + + "github.com/yanpgwang/managed-agent-go/internal/domain" + "github.com/yanpgwang/managed-agent-go/internal/mcpclient" +) + +func TestMCPDiscoverySnapshot_IsInsertOncePerSessionServer(t *testing.T) { + store := testStore(t) + ctx := context.Background() + session := newSession("sess_mcp_snapshot") + if _, err := store.CreateSession(ctx, session, nil); err != nil { + t.Fatal(err) + } + server := domain.MCPServer{ + Name: "github", URL: "https://mcp.example.com", + } + first := []mcpclient.Tool{{ + Name: "list_issues", InputSchema: map[string]any{"type": "object"}, + }} + got, err := store.PutMCPDiscoverySnapshot( + ctx, + session.ID, + server, + first, + ) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Name != "list_issues" { + t.Fatalf("first snapshot = %#v", got) + } + second := []mcpclient.Tool{{ + Name: "new_remote_tool", InputSchema: map[string]any{"type": "object"}, + }} + got, err = store.PutMCPDiscoverySnapshot( + ctx, + session.ID, + server, + second, + ) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Name != "list_issues" { + t.Fatalf("snapshot changed after second insert: %#v", got) + } +} diff --git a/internal/pg/migrations/00007_provider_transcript.sql b/internal/pg/migrations/00007_provider_transcript.sql new file mode 100644 index 0000000..fb73707 --- /dev/null +++ b/internal/pg/migrations/00007_provider_transcript.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- +goose StatementBegin + +-- Lossless provider-continuation history. Public events remain the client-facing +-- ledger; these immutable per-turn deltas retain provider-native content blocks +-- (including citations and opaque server-tool fields) for later model calls. +CREATE TABLE provider_transcript_turns ( + session_id text NOT NULL REFERENCES sessions (id) ON DELETE CASCADE, + trigger_event_id text NOT NULL REFERENCES events (id) ON DELETE CASCADE, + turn_ordinal bigint GENERATED ALWAYS AS IDENTITY, + committed_through_seq bigint NOT NULL, + represented_event_ids jsonb NOT NULL, + messages jsonb NOT NULL, + tool_use_mappings jsonb NOT NULL DEFAULT '[]'::jsonb, + created_at timestamptz NOT NULL, + PRIMARY KEY (session_id, trigger_event_id) +); + +CREATE INDEX provider_transcript_turns_order_idx + ON provider_transcript_turns (session_id, turn_ordinal); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS provider_transcript_turns; +-- +goose StatementEnd diff --git a/internal/pg/migrations/00008_mcp_discovery_snapshots.sql b/internal/pg/migrations/00008_mcp_discovery_snapshots.sql new file mode 100644 index 0000000..80cf706 --- /dev/null +++ b/internal/pg/migrations/00008_mcp_discovery_snapshots.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- +goose StatementBegin + +-- MCP discovery is pinned per Session. A remote server adding, removing, or +-- mutating tools must not silently change an already-running agent's tool +-- surface on a later turn. +CREATE TABLE mcp_discovery_snapshots ( + session_id text NOT NULL REFERENCES sessions (id) ON DELETE CASCADE, + server_name text NOT NULL, + server_url text NOT NULL, + tools jsonb NOT NULL, + created_at timestamptz NOT NULL, + PRIMARY KEY (session_id, server_name) +); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS mcp_discovery_snapshots; +-- +goose StatementEnd diff --git a/internal/pg/pgstore/mcp.sql.go b/internal/pg/pgstore/mcp.sql.go new file mode 100644 index 0000000..702c57d --- /dev/null +++ b/internal/pg/pgstore/mcp.sql.go @@ -0,0 +1,65 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: mcp.sql + +package pgstore + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getMCPDiscoverySnapshot = `-- name: GetMCPDiscoverySnapshot :one +SELECT session_id, server_name, server_url, tools, created_at +FROM mcp_discovery_snapshots +WHERE session_id = $1 AND server_name = $2 +` + +type GetMCPDiscoverySnapshotParams struct { + SessionID string + ServerName string +} + +func (q *Queries) GetMCPDiscoverySnapshot(ctx context.Context, arg GetMCPDiscoverySnapshotParams) (McpDiscoverySnapshot, error) { + row := q.db.QueryRow(ctx, getMCPDiscoverySnapshot, arg.SessionID, arg.ServerName) + var i McpDiscoverySnapshot + err := row.Scan( + &i.SessionID, + &i.ServerName, + &i.ServerUrl, + &i.Tools, + &i.CreatedAt, + ) + return i, err +} + +const insertMCPDiscoverySnapshot = `-- name: InsertMCPDiscoverySnapshot :exec +INSERT INTO mcp_discovery_snapshots ( + session_id, server_name, server_url, tools, created_at +) +VALUES ( + $1, $2, $3, $4, $5 +) +ON CONFLICT (session_id, server_name) DO NOTHING +` + +type InsertMCPDiscoverySnapshotParams struct { + SessionID string + ServerName string + ServerUrl string + Tools []byte + CreatedAt pgtype.Timestamptz +} + +func (q *Queries) InsertMCPDiscoverySnapshot(ctx context.Context, arg InsertMCPDiscoverySnapshotParams) error { + _, err := q.db.Exec(ctx, insertMCPDiscoverySnapshot, + arg.SessionID, + arg.ServerName, + arg.ServerUrl, + arg.Tools, + arg.CreatedAt, + ) + return err +} diff --git a/internal/pg/pgstore/models.go b/internal/pg/pgstore/models.go index e514366..73a8973 100644 --- a/internal/pg/pgstore/models.go +++ b/internal/pg/pgstore/models.go @@ -39,6 +39,14 @@ type Event struct { ProcessedAt pgtype.Timestamptz } +type McpDiscoverySnapshot struct { + SessionID string + ServerName string + ServerUrl string + Tools []byte + CreatedAt pgtype.Timestamptz +} + type OrchestrationOutbox struct { SessionID string MaxEventSeq int64 @@ -58,6 +66,17 @@ type PendingAction struct { ResolvedAt pgtype.Timestamptz } +type ProviderTranscriptTurn struct { + SessionID string + TriggerEventID string + TurnOrdinal *int64 + CommittedThroughSeq int64 + RepresentedEventIds []byte + Messages []byte + ToolUseMappings []byte + CreatedAt pgtype.Timestamptz +} + type Session struct { ID string Status string diff --git a/internal/pg/pgstore/transcript.sql.go b/internal/pg/pgstore/transcript.sql.go new file mode 100644 index 0000000..88fa25d --- /dev/null +++ b/internal/pg/pgstore/transcript.sql.go @@ -0,0 +1,104 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: transcript.sql + +package pgstore + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const insertProviderTranscriptTurn = `-- name: InsertProviderTranscriptTurn :exec + +INSERT INTO provider_transcript_turns ( + session_id, + trigger_event_id, + committed_through_seq, + represented_event_ids, + messages, + tool_use_mappings, + created_at +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7 +) +ON CONFLICT (session_id, trigger_event_id) DO NOTHING +` + +type InsertProviderTranscriptTurnParams struct { + SessionID string + TriggerEventID string + CommittedThroughSeq int64 + RepresentedEventIds []byte + Messages []byte + ToolUseMappings []byte + CreatedAt pgtype.Timestamptz +} + +// Provider transcript queries. Turn deltas are inserted in the same transaction +// that commits their public events and marks the trigger processed. +func (q *Queries) InsertProviderTranscriptTurn(ctx context.Context, arg InsertProviderTranscriptTurnParams) error { + _, err := q.db.Exec(ctx, insertProviderTranscriptTurn, + arg.SessionID, + arg.TriggerEventID, + arg.CommittedThroughSeq, + arg.RepresentedEventIds, + arg.Messages, + arg.ToolUseMappings, + arg.CreatedAt, + ) + return err +} + +const listProviderTranscriptTurns = `-- name: ListProviderTranscriptTurns :many +SELECT + session_id, + trigger_event_id, + turn_ordinal, + committed_through_seq, + represented_event_ids, + messages, + tool_use_mappings, + created_at +FROM provider_transcript_turns +WHERE session_id = $1 +ORDER BY turn_ordinal +` + +func (q *Queries) ListProviderTranscriptTurns(ctx context.Context, sessionID string) ([]ProviderTranscriptTurn, error) { + rows, err := q.db.Query(ctx, listProviderTranscriptTurns, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ProviderTranscriptTurn{} + for rows.Next() { + var i ProviderTranscriptTurn + if err := rows.Scan( + &i.SessionID, + &i.TriggerEventID, + &i.TurnOrdinal, + &i.CommittedThroughSeq, + &i.RepresentedEventIds, + &i.Messages, + &i.ToolUseMappings, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/pg/queries/mcp.sql b/internal/pg/queries/mcp.sql new file mode 100644 index 0000000..a2f4d49 --- /dev/null +++ b/internal/pg/queries/mcp.sql @@ -0,0 +1,13 @@ +-- name: GetMCPDiscoverySnapshot :one +SELECT session_id, server_name, server_url, tools, created_at +FROM mcp_discovery_snapshots +WHERE session_id = @session_id AND server_name = @server_name; + +-- name: InsertMCPDiscoverySnapshot :exec +INSERT INTO mcp_discovery_snapshots ( + session_id, server_name, server_url, tools, created_at +) +VALUES ( + @session_id, @server_name, @server_url, @tools, @created_at +) +ON CONFLICT (session_id, server_name) DO NOTHING; diff --git a/internal/pg/queries/transcript.sql b/internal/pg/queries/transcript.sql new file mode 100644 index 0000000..29c7b58 --- /dev/null +++ b/internal/pg/queries/transcript.sql @@ -0,0 +1,37 @@ +-- Provider transcript queries. Turn deltas are inserted in the same transaction +-- that commits their public events and marks the trigger processed. + +-- name: InsertProviderTranscriptTurn :exec +INSERT INTO provider_transcript_turns ( + session_id, + trigger_event_id, + committed_through_seq, + represented_event_ids, + messages, + tool_use_mappings, + created_at +) +VALUES ( + @session_id, + @trigger_event_id, + @committed_through_seq, + @represented_event_ids, + @messages, + @tool_use_mappings, + @created_at +) +ON CONFLICT (session_id, trigger_event_id) DO NOTHING; + +-- name: ListProviderTranscriptTurns :many +SELECT + session_id, + trigger_event_id, + turn_ordinal, + committed_through_seq, + represented_event_ids, + messages, + tool_use_mappings, + created_at +FROM provider_transcript_turns +WHERE session_id = @session_id +ORDER BY turn_ordinal; diff --git a/internal/pg/store.go b/internal/pg/store.go index ae4ea47..c711066 100644 --- a/internal/pg/store.go +++ b/internal/pg/store.go @@ -568,7 +568,7 @@ func (s *Store) CompleteTurn( outputDrafts []domain.EventDraft, status domain.Status, ) (TurnCompletion, error) { - return s.completeTurn(ctx, sessionID, triggerEventID, outputDrafts, status, "", "", nil, nil, nil) + return s.completeTurn(ctx, sessionID, triggerEventID, outputDrafts, status, "", "", nil, nil, nil, nil, nil) } // CompleteWorkflowTurn atomically finalizes a Workflow-owned tool attempt (when @@ -607,6 +607,48 @@ func (s *Store) CompleteWorkflowTurn( attemptError, pendingActionEventIDs, resolutionEventIDs, + nil, + nil, + ) +} + +// CompleteWorkflowTurnWithTranscript extends CompleteWorkflowTurn with the +// provider-private model-continuation delta and public/provider tool-id +// mappings. All records commit atomically with the public events. +func (s *Store) CompleteWorkflowTurnWithTranscript( + ctx context.Context, + sessionID string, + triggerEventID string, + outputDrafts []domain.EventDraft, + status domain.Status, + attemptID string, + attemptState domain.RunAttemptState, + attemptError *string, + pendingActionEventIDs []string, + resolutionEventIDs []string, + transcriptDelta []domain.Message, + toolUseMappings []domain.ProviderToolUseMapping, +) (TurnCompletion, error) { + if attemptID == "" { + if attemptState != "" || attemptError != nil { + return TurnCompletion{}, domain.Validation("attempt state requires an attempt id") + } + } else if err := validateAttemptFinish(attemptState, attemptError); err != nil { + return TurnCompletion{}, err + } + return s.completeTurn( + ctx, + sessionID, + triggerEventID, + outputDrafts, + status, + attemptID, + attemptState, + attemptError, + pendingActionEventIDs, + resolutionEventIDs, + transcriptDelta, + toolUseMappings, ) } @@ -621,6 +663,8 @@ func (s *Store) completeTurn( attemptError *string, pendingActionEventIDs []string, resolutionEventIDs []string, + transcriptDelta []domain.Message, + toolUseMappings []domain.ProviderToolUseMapping, ) (TurnCompletion, error) { var result TurnCompletion err := s.withTx(ctx, func(q *pgstore.Queries) error { @@ -718,6 +762,13 @@ func (s *Store) completeTurn( // idle/end_turn and never publishes requires_action, session.error, or // terminated. A named attempt is fenced as interrupted below. outputDrafts = interruptedTurnDrafts(outputDrafts) + transcriptDelta = closeInterruptedProviderTranscript( + transcriptDelta, + ) + toolUseMappings = retainCommittedProviderMappings( + toolUseMappings, + outputDrafts, + ) outputDrafts = append(outputDrafts, domain.EventDraft{ Type: domain.EvSessionStatusIdle, Payload: map[string]any{ @@ -850,6 +901,38 @@ func (s *Store) completeTurn( return err } now := s.clock.Now().UTC() + if transcriptDelta != nil { + representedEventIDs := resolutionEventIDs + if len(representedEventIDs) == 0 { + representedEventIDs = []string{triggerEventID} + } + representedJSON, err := json.Marshal(representedEventIDs) + if err != nil { + return err + } + messagesJSON, err := json.Marshal(transcriptDelta) + if err != nil { + return err + } + mappingsJSON, err := json.Marshal(toolUseMappings) + if err != nil { + return err + } + if err := q.InsertProviderTranscriptTurn( + ctx, + pgstore.InsertProviderTranscriptTurnParams{ + SessionID: sessionID, + TriggerEventID: triggerEventID, + CommittedThroughSeq: finalMaxSeq, + RepresentedEventIds: representedJSON, + Messages: messagesJSON, + ToolUseMappings: mappingsJSON, + CreatedAt: tsUTC(now), + }, + ); err != nil { + return err + } + } processedIDs := resolutionEventIDs if len(processedIDs) == 0 { processedIDs = []string{triggerEventID} diff --git a/internal/pg/transcript.go b/internal/pg/transcript.go new file mode 100644 index 0000000..d90e265 --- /dev/null +++ b/internal/pg/transcript.go @@ -0,0 +1,183 @@ +package pg + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/yanpgwang/managed-agent-go/internal/domain" +) + +// LoadProviderTranscript returns the immutable committed turn deltas in model +// continuation order. The caller decides whether the represented trigger set is +// complete enough to use instead of the legacy public-event projection. +func (s *Store) LoadProviderTranscript( + ctx context.Context, + sessionID string, +) (domain.ProviderTranscript, error) { + rows, err := s.q.ListProviderTranscriptTurns(ctx, sessionID) + if err != nil { + return domain.ProviderTranscript{}, err + } + out := domain.ProviderTranscript{ + TriggerEventIDs: make([]string, 0, len(rows)), + } + publicIDs := make(map[string]struct{}) + representedIDs := make(map[string]struct{}) + for _, row := range rows { + var represented []string + if err := json.Unmarshal(row.RepresentedEventIds, &represented); err != nil { + return domain.ProviderTranscript{}, fmt.Errorf( + "pg: decode represented provider events for turn %s: %w", + row.TriggerEventID, + err, + ) + } + for _, eventID := range represented { + if eventID == "" { + return domain.ProviderTranscript{}, fmt.Errorf( + "pg: empty represented provider event in turn %s", + row.TriggerEventID, + ) + } + if _, duplicate := representedIDs[eventID]; duplicate { + return domain.ProviderTranscript{}, fmt.Errorf( + "pg: duplicate represented provider event %s", + eventID, + ) + } + representedIDs[eventID] = struct{}{} + out.TriggerEventIDs = append(out.TriggerEventIDs, eventID) + } + var messages []domain.Message + if err := json.Unmarshal(row.Messages, &messages); err != nil { + return domain.ProviderTranscript{}, fmt.Errorf( + "pg: decode provider transcript turn %s: %w", + row.TriggerEventID, + err, + ) + } + var mappings []domain.ProviderToolUseMapping + if err := json.Unmarshal(row.ToolUseMappings, &mappings); err != nil { + return domain.ProviderTranscript{}, fmt.Errorf( + "pg: decode provider tool mappings for turn %s: %w", + row.TriggerEventID, + err, + ) + } + for _, mapping := range mappings { + if mapping.PublicEventID == "" || mapping.ProviderToolUseID == "" { + return domain.ProviderTranscript{}, fmt.Errorf( + "pg: invalid provider tool mapping in turn %s", + row.TriggerEventID, + ) + } + if _, duplicate := publicIDs[mapping.PublicEventID]; duplicate { + return domain.ProviderTranscript{}, fmt.Errorf( + "pg: duplicate provider mapping for public event %s", + mapping.PublicEventID, + ) + } + publicIDs[mapping.PublicEventID] = struct{}{} + } + out.Messages = appendProviderMessages(out.Messages, messages) + out.ToolUseMappings = append(out.ToolUseMappings, mappings...) + } + return out, nil +} + +func appendProviderMessages( + base []domain.Message, + added []domain.Message, +) []domain.Message { + for _, message := range added { + if len(message.Content) == 0 { + continue + } + if n := len(base); n > 0 && base[n-1].Role == message.Role { + base[n-1].Content = append(base[n-1].Content, message.Content...) + continue + } + base = append(base, message) + } + return base +} + +// closeInterruptedProviderTranscript pairs every client tool_use that has no +// result when the PostgreSQL completion lock, rather than the Workflow watcher, +// observes a winning interrupt. Provider server-tool blocks are not client +// tool_use blocks and are therefore left unchanged. +func closeInterruptedProviderTranscript( + messages []domain.Message, +) []domain.Message { + if messages == nil { + // nil means this completion did not opt into private transcript + // persistence. Preserve that distinction from an explicitly empty delta + // so the interrupt path cannot create a false represented-event row. + return nil + } + cloned := make([]domain.Message, len(messages)) + for index, message := range messages { + cloned[index] = domain.Message{ + Role: message.Role, + Content: append( + []domain.ContentBlock(nil), + message.Content..., + ), + } + } + answered := make(map[string]struct{}) + for _, message := range cloned { + for _, block := range message.Content { + if block.Type == "tool_result" && block.ToolResultFor != "" { + answered[block.ToolResultFor] = struct{}{} + } + } + } + var synthetic []domain.ContentBlock + for _, message := range cloned { + for _, block := range message.Content { + if block.Type != "tool_use" || block.ToolUseID == "" { + continue + } + if _, ok := answered[block.ToolUseID]; ok { + continue + } + answered[block.ToolUseID] = struct{}{} + synthetic = append(synthetic, domain.ContentBlock{ + Type: "tool_result", + ToolResultFor: block.ToolUseID, + Text: "Tool execution was interrupted before a result was committed.", + IsError: true, + }) + } + } + if len(synthetic) == 0 { + return cloned + } + return appendProviderMessages(cloned, []domain.Message{{ + Role: domain.RoleUser, Content: synthetic, + }}) +} + +func retainCommittedProviderMappings( + mappings []domain.ProviderToolUseMapping, + drafts []domain.EventDraft, +) []domain.ProviderToolUseMapping { + committed := make(map[string]struct{}) + for _, draft := range drafts { + switch draft.Type { + case domain.EvAgentToolUse, domain.EvAgentCustomToolUse: + if draft.ID != "" { + committed[draft.ID] = struct{}{} + } + } + } + out := make([]domain.ProviderToolUseMapping, 0, len(mappings)) + for _, mapping := range mappings { + if _, ok := committed[mapping.PublicEventID]; ok { + out = append(out, mapping) + } + } + return out +} diff --git a/internal/pg/transcript_test.go b/internal/pg/transcript_test.go new file mode 100644 index 0000000..2bf526b --- /dev/null +++ b/internal/pg/transcript_test.go @@ -0,0 +1,151 @@ +package pg + +import ( + "context" + "encoding/json" + "testing" + + "github.com/yanpgwang/managed-agent-go/internal/domain" +) + +func TestCompleteWorkflowTurn_CommitsLosslessTranscriptAtomically(t *testing.T) { + store := testStore(t) + ctx := context.Background() + session := newSession("sess_transcript") + if _, err := store.CreateSession(ctx, session, nil); err != nil { + t.Fatal(err) + } + admission, err := store.AdmitEvents(ctx, session.ID, []domain.EventDraft{{ + Type: domain.EvUserMessage, + Payload: map[string]any{"content": []any{ + map[string]any{"type": "text", "text": "search"}, + }}, + }}) + if err != nil { + t.Fatal(err) + } + triggerID := admission.Events[0].ID + opaque := json.RawMessage( + `{"type":"web_search_tool_result","tool_use_id":"srv_1","content":[{"type":"web_search_result","encrypted_content":"opaque"}]}`, + ) + delta := []domain.Message{ + { + Role: domain.RoleUser, + Content: []domain.ContentBlock{{ + Type: "text", Text: "search", + }}, + }, + { + Role: domain.RoleAssistant, + Content: []domain.ContentBlock{{ + Type: "web_search_tool_result", Raw: opaque, + }}, + }, + } + mappings := []domain.ProviderToolUseMapping{{ + PublicEventID: "sevt_public", + ProviderToolUseID: "toolu_provider", + ToolName: "read", + }} + _, err = store.CompleteWorkflowTurnWithTranscript( + ctx, + session.ID, + triggerID, + []domain.EventDraft{{ + Type: domain.EvSessionStatusIdle, + Payload: map[string]any{ + "stop_reason": map[string]any{"type": "end_turn"}, + }, + }}, + domain.StatusIdle, + "", + "", + nil, + nil, + nil, + delta, + mappings, + ) + if err != nil { + t.Fatal(err) + } + + got, err := store.LoadProviderTranscript(ctx, session.ID) + if err != nil { + t.Fatal(err) + } + if len(got.TriggerEventIDs) != 1 || got.TriggerEventIDs[0] != triggerID { + t.Fatalf("trigger ids = %#v", got.TriggerEventIDs) + } + if len(got.Messages) != 2 || + string(got.Messages[1].Content[0].Raw) != string(opaque) { + t.Fatalf("transcript = %#v", got.Messages) + } + if len(got.ToolUseMappings) != 1 || + got.ToolUseMappings[0] != mappings[0] { + t.Fatalf("mappings = %#v", got.ToolUseMappings) + } +} + +func TestCloseInterruptedProviderTranscript_PairsDanglingTools(t *testing.T) { + messages := []domain.Message{ + { + Role: domain.RoleAssistant, + Content: []domain.ContentBlock{ + {Type: "tool_use", ToolUseID: "provider_done"}, + {Type: "tool_use", ToolUseID: "provider_pending"}, + {Type: "server_tool_use", ToolUseID: "server_native"}, + }, + }, + { + Role: domain.RoleUser, + Content: []domain.ContentBlock{{ + Type: "tool_result", ToolResultFor: "provider_done", + Text: "done", + }}, + }, + } + + got := closeInterruptedProviderTranscript(messages) + + if len(got) != 2 || got[1].Role != domain.RoleUser { + t.Fatalf("closed transcript = %#v", got) + } + if len(got[1].Content) != 2 { + t.Fatalf("result blocks = %#v", got[1].Content) + } + synthetic := got[1].Content[1] + if synthetic.ToolResultFor != "provider_pending" || + !synthetic.IsError { + t.Fatalf("synthetic result = %#v", synthetic) + } + if len(messages[1].Content) != 1 { + t.Fatal("helper mutated its input transcript") + } + if got := closeInterruptedProviderTranscript(nil); got != nil { + t.Fatalf("nil transcript became represented: %#v", got) + } +} + +func TestRetainCommittedProviderMappings_DropsInterruptedActions(t *testing.T) { + mappings := []domain.ProviderToolUseMapping{ + { + PublicEventID: "public_done", + ProviderToolUseID: "provider_done", + }, + { + PublicEventID: "public_pending", + ProviderToolUseID: "provider_pending", + }, + } + got := retainCommittedProviderMappings( + mappings, + []domain.EventDraft{ + {ID: "public_done", Type: domain.EvAgentToolUse}, + {Type: domain.EvAgentToolResult}, + }, + ) + if len(got) != 1 || got[0] != mappings[0] { + t.Fatalf("retained mappings = %#v", got) + } +} diff --git a/internal/temporal/activities.go b/internal/temporal/activities.go index 2de1740..f581fc9 100644 --- a/internal/temporal/activities.go +++ b/internal/temporal/activities.go @@ -2,9 +2,12 @@ package temporal import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "sort" + "strings" "sync" "time" @@ -14,6 +17,7 @@ import ( "github.com/yanpgwang/managed-agent-go/internal/agentruntime" "github.com/yanpgwang/managed-agent-go/internal/agentruntime/tools" "github.com/yanpgwang/managed-agent-go/internal/domain" + "github.com/yanpgwang/managed-agent-go/internal/mcpclient" "github.com/yanpgwang/managed-agent-go/internal/model" "github.com/yanpgwang/managed-agent-go/internal/sandbox" ) @@ -61,6 +65,52 @@ type EventSource interface { ) (TurnCompletionResult, error) } +// ProviderTranscriptSource is the optional private-context capability supplied +// by the PostgreSQL adapter. Tests and legacy stores that do not implement it +// continue to use the public-event projection. +type ProviderTranscriptSource interface { + LoadProviderTranscript( + ctx context.Context, + sessionID string, + ) (domain.ProviderTranscript, error) +} + +// ProviderTranscriptCompletionSource atomically commits the public turn and +// the private provider transcript delta. +type ProviderTranscriptCompletionSource interface { + CompleteWorkflowTurnWithTranscript( + ctx context.Context, + sessionID string, + triggerEventID string, + output []domain.EventDraft, + status domain.Status, + attemptID string, + attemptState domain.RunAttemptState, + attemptError *string, + pendingActionEventIDs []string, + resolutionEventIDs []string, + transcriptDelta []domain.Message, + toolUseMappings []domain.ProviderToolUseMapping, + ) (TurnCompletionResult, error) +} + +// MCPDiscoveryStore pins the discovered tool surface for each Session/server. +// The first PrepareTurn discovers remotely; later turns reuse the durable +// snapshot even if the remote server changes. +type MCPDiscoveryStore interface { + GetMCPDiscoverySnapshot( + ctx context.Context, + sessionID string, + server domain.MCPServer, + ) ([]mcpclient.Tool, bool, error) + PutMCPDiscoverySnapshot( + ctx context.Context, + sessionID string, + server domain.MCPServer, + tools []mcpclient.Tool, + ) ([]mcpclient.Tool, error) +} + // JournalStore is the durable tool-execution journal used by the granular // ExecuteTool Activity. It preserves the prepared/started/completed/ambiguous // boundary across Activity retries. *pg.Store implements it. @@ -127,6 +177,7 @@ type Activities struct { sandboxes SandboxLease ids domain.IDGenerator previews PreviewPublisher + mcp mcpclient.Client } func NewActivities( @@ -140,6 +191,7 @@ func NewActivities( activities := &Activities{ modelClient: modelClient, source: source, journal: journal, sandboxes: sandboxes, ids: ids, + mcp: mcpclient.NewRemote(nil), } if len(previewPublisher) > 0 { activities.previews = previewPublisher[0] @@ -147,6 +199,13 @@ func NewActivities( return activities } +// WithMCPClient replaces the remote MCP adapter. Production uses the official +// Go SDK-backed client by default; tests can inject a deterministic fake. +func (a *Activities) WithMCPClient(client mcpclient.Client) *Activities { + a.mcp = client + return a +} + // LoadEvents returns the ordered public event references after a cursor. Only // metadata (id, seq, type) crosses back into workflow history; payloads stay in // PostgreSQL. @@ -268,6 +327,17 @@ func (a *Activities) PrepareTurn(ctx context.Context, in PrepareTurnInput) (Prep if err != nil { return PrepareTurnResult{FatalError: "invalid toolset: " + err.Error()}, nil } + if err := domain.ValidateToolConfiguration( + session.AgentSnapshot.Tools, + session.AgentSnapshot.MCPServers, + ); err != nil { + return PrepareTurnResult{ + FatalError: "invalid tool configuration: " + err.Error(), + }, nil + } + if err := agentruntime.ValidateToolCapabilities(toolSet); err != nil { + return PrepareTurnResult{FatalError: "unsupported tool capability: " + err.Error()}, nil + } system := "" if session.AgentSnapshot.System != nil { @@ -276,12 +346,12 @@ func (a *Activities) PrepareTurn(ctx context.Context, in PrepareTurnInput) (Prep result := PrepareTurnResult{ AttemptID: a.ids.NewID(domain.PrefixRunAttempt), Request: model.Request{ - Model: session.AgentSnapshot.Model.ID, - System: system, - Messages: domain.ProjectMessages(history), - Tools: agentruntime.EnabledToolSchemas(toolSet), + Model: session.AgentSnapshot.Model.ID, + System: system, + Tools: agentruntime.EnabledToolSchemas(toolSet), }, } + originalHistory := history if len(in.ResolutionEventIDs) > 0 { resumeActions, err := a.prepareResumeActions( ctx, @@ -301,7 +371,45 @@ func (a *Activities) PrepareTurn(ctx context.Context, in PrepareTurnInput) (Prep } result.ResumeActions = resumeActions history = withoutResumeEvents(history, resumeActions) - result.Request.Messages = domain.ProjectMessages(history) + } + result.Request.Messages = domain.ProjectMessages(history) + if transcriptSource, ok := a.source.(ProviderTranscriptSource); ok { + transcript, err := transcriptSource.LoadProviderTranscript(ctx, in.SessionID) + if err != nil { + return PrepareTurnResult{}, err + } + if transcriptCoversPriorTurns( + transcript, + originalHistory, + trigger.ID, + in.ResolutionEventIDs, + ) { + mappings := make(map[string]string, len(transcript.ToolUseMappings)) + for _, mapping := range transcript.ToolUseMappings { + mappings[mapping.PublicEventID] = mapping.ProviderToolUseID + } + usable := true + for i := range result.ResumeActions { + providerID := mappings[result.ResumeActions[i].ActionEventID] + if providerID == "" { + usable = false + break + } + result.ResumeActions[i].ProviderToolUseID = providerID + } + if usable { + var delta []domain.Message + if len(in.ResolutionEventIDs) == 0 { + delta = domain.ProjectMessages([]domain.Event{trigger}) + } + result.UsesProviderTranscript = true + result.TranscriptDelta = delta + result.Request.Messages = agentruntime.AppendMerging( + append([]domain.Message(nil), transcript.Messages...), + delta, + ) + } + } } for _, name := range domain.BuiltinToolNames { enabled, policy := toolSet.BuiltinEnabled(name) @@ -316,9 +424,227 @@ func (a *Activities) PrepareTurn(ctx context.Context, in PrepareTurnInput) (Prep Name: custom.Name, Kind: TurnToolCustom, }) } + if len(toolSet.MCP) > 0 || len(session.AgentSnapshot.MCPServers) > 0 { + setupEvents, err := a.addMCPTools( + ctx, + in.SessionID, + session.AgentSnapshot.MCPServers, + toolSet, + &result, + ) + if err != nil { + var domainErr *domain.DomainError + if errors.As(err, &domainErr) { + return PrepareTurnResult{ + FatalError: "mcp capability resolution failed: " + err.Error(), + }, nil + } + return PrepareTurnResult{}, err + } + result.PreludeEvents = append(result.PreludeEvents, setupEvents...) + } return result, nil } +func (a *Activities) addMCPTools( + ctx context.Context, + sessionID string, + rawServers []any, + toolSet domain.ToolSet, + result *PrepareTurnResult, +) ([]domain.EventDraft, error) { + if a.mcp == nil { + return nil, domain.Validation("MCP client is not configured") + } + servers, err := domain.ParseMCPServers(rawServers) + if err != nil { + return nil, domain.Validation(err.Error()) + } + var setupEvents []domain.EventDraft + referenced := make(map[string]struct{}, len(toolSet.MCP)) + aliases := make(map[string]struct{}) + for _, configured := range result.Request.Tools { + aliases[configured.Name] = struct{}{} + } + for _, configured := range toolSet.MCP { + server, ok := servers[configured.ServerName] + if !ok { + return nil, domain.Validation(fmt.Sprintf( + "mcp_toolset references unknown server %q", + configured.ServerName, + )) + } + referenced[server.Name] = struct{}{} + var discovered []mcpclient.Tool + if snapshots, ok := a.source.(MCPDiscoveryStore); ok { + var found bool + discovered, found, err = snapshots.GetMCPDiscoverySnapshot( + ctx, + sessionID, + server, + ) + if err != nil { + return nil, err + } + if !found { + discovered, err = a.mcp.Discover(ctx, server) + if err != nil { + setupEvents = append( + setupEvents, + mcpConnectionFailureEvent(server), + ) + continue + } + discovered, err = snapshots.PutMCPDiscoverySnapshot( + ctx, + sessionID, + server, + discovered, + ) + if err != nil { + return nil, err + } + } + } else { + discovered, err = a.mcp.Discover(ctx, server) + if err != nil { + setupEvents = append( + setupEvents, + mcpConnectionFailureEvent(server), + ) + continue + } + } + for _, remoteTool := range discovered { + enabled, policy := configured.ToolEnabled(remoteTool.Name) + if !enabled { + continue + } + if policy.Type != "always_allow" && policy.Type != "always_ask" { + return nil, domain.Validation(fmt.Sprintf( + "mcp tool %s/%s has unsupported permission %q", + server.Name, + remoteTool.Name, + policy.Type, + )) + } + alias := mcpModelToolName(server.Name, remoteTool.Name) + if _, duplicate := aliases[alias]; duplicate { + return nil, domain.Validation(fmt.Sprintf( + "MCP model tool name collision %q", + alias, + )) + } + aliases[alias] = struct{}{} + result.Request.Tools = append( + result.Request.Tools, + model.ToolSchema{ + Name: alias, + Description: remoteTool.Description, + InputSchema: remoteTool.InputSchema, + }, + ) + result.Tools = append(result.Tools, TurnTool{ + Name: alias, + Kind: TurnToolMCP, + Permission: policy, + MCPServer: server, + MCPToolName: remoteTool.Name, + }) + } + } + for name := range servers { + if _, ok := referenced[name]; !ok { + return nil, domain.Validation(fmt.Sprintf( + "MCP server %q has no matching mcp_toolset", + name, + )) + } + } + return setupEvents, nil +} + +func mcpConnectionFailureEvent(server domain.MCPServer) domain.EventDraft { + return domain.EventDraft{ + Type: domain.EvSessionError, + Payload: map[string]any{ + "error": map[string]any{ + "type": "mcp_connection_failed_error", + "message": "Could not connect to MCP server " + server.Name + ".", + "mcp_server_name": server.Name, + "retry_status": map[string]any{ + "type": "exhausted", + }, + }, + }, + } +} + +func mcpModelToolName(serverName, toolName string) string { + sanitize := func(value string) string { + var b strings.Builder + for _, r := range value { + switch { + case r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', + r == '_', r == '-': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + return b.String() + } + name := "mcp__" + sanitize(serverName) + "__" + sanitize(toolName) + if len(name) <= 64 { + return name + } + sum := sha256.Sum256([]byte(serverName + "\x00" + toolName)) + suffix := "_" + hex.EncodeToString(sum[:6]) + return name[:64-len(suffix)] + suffix +} + +func transcriptCoversPriorTurns( + transcript domain.ProviderTranscript, + history []domain.Event, + currentTriggerID string, + currentResolutionIDs []string, +) bool { + represented := make(map[string]struct{}, len(transcript.TriggerEventIDs)) + for _, id := range transcript.TriggerEventIDs { + represented[id] = struct{}{} + } + current := make(map[string]struct{}, len(currentResolutionIDs)+1) + current[currentTriggerID] = struct{}{} + for _, id := range currentResolutionIDs { + current[id] = struct{}{} + } + for _, event := range history { + if _, ok := current[event.ID]; ok { + continue + } + if !drivesPreparedModelTurn(event.Type) { + continue + } + if _, ok := represented[event.ID]; !ok { + return false + } + } + return true +} + +func drivesPreparedModelTurn(eventType string) bool { + switch eventType { + case domain.EvUserMessage, + domain.EvUserCustomToolResult, + domain.EvUserToolConfirmation: + return true + default: + return false + } +} + func pendingBarrierContainsTrigger(pending []domain.PendingAction, triggerEventID string) bool { for _, action := range pending { if action.ResolvingEventID != nil && *action.ResolvingEventID == triggerEventID { @@ -378,6 +704,9 @@ func (a *Activities) prepareResumeActions( if name == "" || !inputOK { return nil, domain.Validation("pending action has invalid tool name or input") } + if serverName, _ := action.Payload["mcp_server_name"].(string); serverName != "" { + name = mcpModelToolName(serverName, name) + } resolution, err := a.source.GetEvent(ctx, sessionID, resolutionID) if err != nil { return nil, err @@ -509,12 +838,11 @@ func (a *Activities) CallModel(ctx context.Context, in CallModelInput) (CallMode result.FatalError = "model returned a tool_use without an input object" return result, nil } - // The provider's transient tool id is replaced with the public - // server-owned event id used by the journal and event ledger. - normalized[i].ToolUseID = a.ids.NewID(domain.PrefixEvent) + publicEventID := a.ids.NewID(domain.PrefixEvent) result.ToolSteps = append(result.ToolSteps, PlannedToolStep{ - ToolUseEventID: normalized[i].ToolUseID, - ToolStepID: a.ids.NewID(domain.PrefixToolStep), + ToolUseEventID: publicEventID, + ProviderToolUseID: normalized[i].ToolUseID, + ToolStepID: a.ids.NewID(domain.PrefixToolStep), }) } } @@ -558,7 +886,7 @@ func (a *Activities) ExecuteTool(ctx context.Context, in ExecuteToolInput) (Exec if step.Result == nil { return ExecuteToolResult{}, fmt.Errorf("temporal: completed tool step %s has no result", step.ID) } - out.Result = *step.Result + out.Result = workflowToolResult(*step.Result) return out, nil case domain.ToolStepAmbiguous: out.Ambiguous = true @@ -578,13 +906,32 @@ func (a *Activities) ExecuteTool(ctx context.Context, in ExecuteToolInput) (Exec return ExecuteToolResult{}, fmt.Errorf("temporal: invalid tool step state %q", step.State) } - executor, ok := tools.Registry()[in.ToolName] - if !ok { - out.FatalError = "built-in tool is not registered: " + in.ToolName + kind := in.ToolKind + if kind == "" { + kind = TurnToolBuiltin + } + var executor tools.Executor + switch kind { + case TurnToolBuiltin: + var ok bool + executor, ok = tools.Registry()[in.ToolName] + if !ok { + out.FatalError = "built-in tool is not registered: " + in.ToolName + return out, nil + } + case TurnToolMCP: + if a.mcp == nil || in.MCPServer.Name == "" || + in.MCPServer.URL == "" || in.MCPToolName == "" { + out.FatalError = "MCP tool execution is missing its pinned server definition" + return out, nil + } + default: + out.FatalError = "tool execution owner is not server-executable: " + string(kind) return out, nil } // Provisioning happens before Start: a transient sandbox failure cannot turn - // a never-executed tool into an ambiguous side effect. + // a never-executed tool into an ambiguous side effect. MCP also uses the + // Session sandbox to materialize binary and oversized results. box, err := a.sandboxes.Acquire(ctx, in.SessionID, sandbox.Spec{Timeout: sandboxTurnTimeout}) if err != nil { return ExecuteToolResult{}, err @@ -596,14 +943,79 @@ func (a *Activities) ExecuteTool(ctx context.Context, in ExecuteToolInput) (Exec return ExecuteToolResult{}, err } - executed := executor(ctx, box, in.Input) - out.Result = domain.ToolStepResult{Content: executed.Content, IsError: executed.IsError} + if kind == TurnToolMCP { + // Crossing StartToolStep is the side-effect uncertainty boundary. A + // transport failure after this point may have happened after the remote + // server executed the tool, so the Activity error intentionally becomes + // ambiguous on retry rather than blindly calling the MCP tool again. + called, err := a.mcp.Call( + ctx, + in.MCPServer, + in.MCPToolName, + in.Input, + ) + if err != nil { + return ExecuteToolResult{}, err + } + executed, raw, rawPath, projectErr := tools.ProjectMCPResult( + context.WithoutCancel(ctx), + box, + in.ToolUseEventID, + called, + ) + if projectErr != nil { + executed = tools.Result{ + Content: []any{map[string]any{ + "type": "text", + "text": projectErr.Error(), + }}, + IsError: true, + } + } + out.Result = domain.ToolStepResult{ + Content: executed.Content, + IsError: executed.IsError, + Raw: raw, + RawPath: rawPath, + } + } else { + executed := executor(ctx, box, in.Input) + executed, materializeErr := tools.MaterializeLargeResult( + context.WithoutCancel(ctx), + box, + in.ToolUseEventID, + executed, + ) + if materializeErr != nil { + executed = tools.Result{ + Content: []any{map[string]any{ + "type": "text", + "text": materializeErr.Error(), + }}, + IsError: true, + } + } + out.Result = domain.ToolStepResult{ + Content: executed.Content, + IsError: executed.IsError, + } + } if err := completeToolResultDurably(ctx, a.journal, step.ID, out.Result); err != nil { return ExecuteToolResult{}, err } + out.Result = workflowToolResult(out.Result) return out, nil } +// workflowToolResult is the bounded model/public projection returned through +// Temporal. Executor-native Raw/RawPath stay in the PostgreSQL journal and do +// not need to inflate Workflow history. +func workflowToolResult(result domain.ToolStepResult) domain.ToolStepResult { + result.Raw = nil + result.RawPath = "" + return result +} + // ReleaseSandbox completes the provider side of session deletion. It is a // standalone Activity so Temporal durably retries provider or PostgreSQL // outages without making the HTTP control plane own sandbox credentials. @@ -666,18 +1078,37 @@ func (a *Activities) CompleteWorkflowTurn( ) (RunTurnResult, error) { dctx, cancel := durableCtx(ctx) defer cancel() - completion, err := a.source.CompleteWorkflowTurn( - dctx, - in.SessionID, - in.TriggerEventID, - in.Output, - in.Status, - in.AttemptID, - in.AttemptState, - in.AttemptError, - in.PendingActionEventIDs, - in.ResolutionEventIDs, - ) + var completion TurnCompletionResult + var err error + if source, ok := a.source.(ProviderTranscriptCompletionSource); ok { + completion, err = source.CompleteWorkflowTurnWithTranscript( + dctx, + in.SessionID, + in.TriggerEventID, + in.Output, + in.Status, + in.AttemptID, + in.AttemptState, + in.AttemptError, + in.PendingActionEventIDs, + in.ResolutionEventIDs, + in.TranscriptDelta, + in.ToolUseMappings, + ) + } else { + completion, err = a.source.CompleteWorkflowTurn( + dctx, + in.SessionID, + in.TriggerEventID, + in.Output, + in.Status, + in.AttemptID, + in.AttemptState, + in.AttemptError, + in.PendingActionEventIDs, + in.ResolutionEventIDs, + ) + } if err != nil { return RunTurnResult{}, err } diff --git a/internal/temporal/activities_resume_test.go b/internal/temporal/activities_resume_test.go index 2343c4f..855fa0a 100644 --- a/internal/temporal/activities_resume_test.go +++ b/internal/temporal/activities_resume_test.go @@ -2,6 +2,7 @@ package temporal import ( "context" + "encoding/json" "testing" "time" @@ -10,6 +11,30 @@ import ( "github.com/yanpgwang/managed-agent-go/internal/domain" ) +type transcriptFakeSource struct { + *fakeSource + transcript domain.ProviderTranscript +} + +func (s *transcriptFakeSource) LoadProviderTranscript( + context.Context, + string, +) (domain.ProviderTranscript, error) { + return s.transcript, nil +} + +type configuredTranscriptFakeSource struct { + *transcriptFakeSource + session domain.Session +} + +func (s *configuredTranscriptFakeSource) GetSession( + context.Context, + string, +) (domain.Session, error) { + return s.session, nil +} + func TestPrepareTurn_ProcessedOnReceiptCustomResultStillResumesPendingBarrier(t *testing.T) { processedAt := time.Now().UTC() resolutionID := "sevt_custom_result" @@ -85,3 +110,225 @@ func TestPrepareTurn_ProcessedOnReceiptCustomResultStillResumesPendingBarrier(t }, }}, prepared.ResumeActions) } + +func TestPrepareTurn_UsesLosslessTranscriptAndMapsResumeToProviderID(t *testing.T) { + processedAt := time.Now().UTC() + resolutionID := "sevt_custom_result" + base := newFakeSource([]domain.Event{ + { + ID: "sevt_user", Sequence: 1, Type: domain.EvUserMessage, + Payload: map[string]any{"content": []any{ + map[string]any{"type": "text", "text": "inspect"}, + }}, + ProcessedAt: &processedAt, + }, + { + ID: "sevt_custom", Sequence: 2, Type: domain.EvAgentCustomToolUse, + Payload: map[string]any{ + "name": "ask_client", + "input": map[string]any{"question": "continue?"}, + }, + }, + { + ID: "sevt_custom_result", Sequence: 3, + Type: domain.EvUserCustomToolResult, + Payload: map[string]any{ + "custom_tool_use_id": "sevt_custom", + "content": []any{ + map[string]any{"type": "text", "text": "yes"}, + }, + }, + ProcessedAt: &processedAt, + }, + }) + base.pendingActions = []domain.PendingAction{{ + ID: "pact_1", + SessionID: "sess_resume", + ActionEventID: "sevt_custom", + Kind: domain.PendingCustomToolResult, + ResolvingEventID: &resolutionID, + }} + rawToolUse := json.RawMessage( + `{"type":"tool_use","id":"toolu_provider","name":"ask_client","input":{"question":"continue?"},"future_field":"keep"}`, + ) + source := &transcriptFakeSource{ + fakeSource: base, + transcript: domain.ProviderTranscript{ + TriggerEventIDs: []string{"sevt_user"}, + Messages: []domain.Message{ + { + Role: domain.RoleUser, + Content: []domain.ContentBlock{{ + Type: "text", Text: "inspect", + }}, + }, + { + Role: domain.RoleAssistant, + Content: []domain.ContentBlock{{ + Type: "tool_use", + ToolUseID: "toolu_provider", + ToolName: "ask_client", + Input: map[string]any{ + "question": "continue?", + }, + Raw: rawToolUse, + }}, + }, + }, + ToolUseMappings: []domain.ProviderToolUseMapping{{ + PublicEventID: "sevt_custom", + ProviderToolUseID: "toolu_provider", + ToolName: "ask_client", + }}, + }, + } + activities := NewActivities(nil, source, nil, nil, &testIDGen{}) + prepared, err := activities.PrepareTurn( + context.Background(), + PrepareTurnInput{ + SessionID: "sess_resume", + TriggerEventID: "sevt_custom_result", + ResolutionEventIDs: []string{"sevt_custom_result"}, + }, + ) + require.NoError(t, err) + require.True(t, prepared.UsesProviderTranscript) + require.Len(t, prepared.Request.Messages, 2) + require.Equal( + t, + "toolu_provider", + prepared.ResumeActions[0].ProviderToolUseID, + ) + + turn := &workflowTurnState{ + usesProviderTranscript: true, + } + messages, _, failure, err := resumeWorkflowTurn( + turn, + prepared, + map[string]TurnTool{ + "ask_client": {Name: "ask_client", Kind: TurnToolCustom}, + }, + prepared.Request.Messages, + ) + require.NoError(t, err) + require.Empty(t, failure) + require.Len(t, messages, 3) + result := messages[2].Content[0] + require.Equal(t, "toolu_provider", result.ToolResultFor) + require.Len(t, turn.transcriptDelta, 1) + require.Equal( + t, + "toolu_provider", + turn.transcriptDelta[0].Content[0].ToolResultFor, + ) +} + +func TestPrepareTurn_MultiActionResumeKeepsLosslessTranscript(t *testing.T) { + processedAt := time.Now().UTC() + resolutionA := "sevt_result_a" + resolutionB := "sevt_result_b" + base := newFakeSource([]domain.Event{ + { + ID: "sevt_user", Sequence: 1, Type: domain.EvUserMessage, + Payload: map[string]any{"content": []any{ + map[string]any{"type": "text", "text": "do both"}, + }}, + ProcessedAt: &processedAt, + }, + { + ID: "sevt_action_a", Sequence: 2, Type: domain.EvAgentCustomToolUse, + Payload: map[string]any{ + "name": "tool_a", "input": map[string]any{"value": "a"}, + }, + }, + { + ID: "sevt_action_b", Sequence: 3, Type: domain.EvAgentCustomToolUse, + Payload: map[string]any{ + "name": "tool_b", "input": map[string]any{"value": "b"}, + }, + }, + { + ID: resolutionA, Sequence: 4, Type: domain.EvUserCustomToolResult, + Payload: map[string]any{ + "custom_tool_use_id": "sevt_action_a", + "content": []any{map[string]any{"type": "text", "text": "A"}}, + }, + ProcessedAt: &processedAt, + }, + { + ID: resolutionB, Sequence: 5, Type: domain.EvUserCustomToolResult, + Payload: map[string]any{ + "custom_tool_use_id": "sevt_action_b", + "content": []any{map[string]any{"type": "text", "text": "B"}}, + }, + ProcessedAt: &processedAt, + }, + }) + base.pendingActions = []domain.PendingAction{ + { + ID: "pact_a", SessionID: "sess_multi", + ActionEventID: "sevt_action_a", Kind: domain.PendingCustomToolResult, + ResolvingEventID: &resolutionA, + }, + { + ID: "pact_b", SessionID: "sess_multi", + ActionEventID: "sevt_action_b", Kind: domain.PendingCustomToolResult, + ResolvingEventID: &resolutionB, + }, + } + source := &configuredTranscriptFakeSource{ + transcriptFakeSource: &transcriptFakeSource{ + fakeSource: base, + transcript: domain.ProviderTranscript{ + TriggerEventIDs: []string{"sevt_user"}, + Messages: []domain.Message{ + { + Role: domain.RoleUser, + Content: []domain.ContentBlock{{Type: "text", Text: "do both"}}, + }, + { + Role: domain.RoleAssistant, + Content: []domain.ContentBlock{ + {Type: "tool_use", ToolUseID: "provider_a", ToolName: "tool_a", Input: map[string]any{"value": "a"}}, + {Type: "tool_use", ToolUseID: "provider_b", ToolName: "tool_b", Input: map[string]any{"value": "b"}}, + }, + }, + }, + ToolUseMappings: []domain.ProviderToolUseMapping{ + {PublicEventID: "sevt_action_a", ProviderToolUseID: "provider_a", ToolName: "tool_a"}, + {PublicEventID: "sevt_action_b", ProviderToolUseID: "provider_b", ToolName: "tool_b"}, + }, + }, + }, + session: domain.Session{ + ID: "sess_multi", Status: domain.StatusRunning, + AgentSnapshot: domain.Agent{ + Model: domain.Model{ID: "model"}, + Tools: []any{ + map[string]any{"type": "custom", "name": "tool_a"}, + map[string]any{"type": "custom", "name": "tool_b"}, + }, + }, + }, + } + + prepared, err := NewActivities( + nil, + source, + nil, + nil, + &testIDGen{}, + ).PrepareTurn(context.Background(), PrepareTurnInput{ + SessionID: "sess_multi", + TriggerEventID: resolutionB, + ResolutionEventIDs: []string{resolutionA, resolutionB}, + }) + + require.NoError(t, err) + require.Empty(t, prepared.FatalError) + require.True(t, prepared.UsesProviderTranscript) + require.Len(t, prepared.ResumeActions, 2) + require.Equal(t, "provider_a", prepared.ResumeActions[0].ProviderToolUseID) + require.Equal(t, "provider_b", prepared.ResumeActions[1].ProviderToolUseID) +} diff --git a/internal/temporal/agent_workflow.go b/internal/temporal/agent_workflow.go index 6d8a625..6c2bfa4 100644 --- a/internal/temporal/agent_workflow.go +++ b/internal/temporal/agent_workflow.go @@ -68,11 +68,17 @@ func runWorkflowTurnInternal( } turn := &workflowTurnState{ - actx: actx, - sessionID: sessionID, - triggerEventID: triggerEventID, - resolutionEventIDs: resolutionEventIDs, - interrupts: interrupts, + actx: actx, + sessionID: sessionID, + triggerEventID: triggerEventID, + resolutionEventIDs: resolutionEventIDs, + interrupts: interrupts, + usesProviderTranscript: prepared.UsesProviderTranscript, + output: append([]domain.EventDraft(nil), prepared.PreludeEvents...), + transcriptDelta: append( + []domain.Message(nil), + prepared.TranscriptDelta..., + ), } if prepared.FatalError != "" { return turn.terminate(failTurn(prepared.FatalError)) @@ -98,6 +104,7 @@ func runWorkflowTurnInternal( for round := 0; round < maxWorkflowToolRounds; round++ { request := prepared.Request request.Messages = messages + mappingCheckpoint := len(turn.toolUseMappings) called, activityOutcome, err := turn.callModel(CallModelInput{ SessionID: sessionID, @@ -115,6 +122,32 @@ func runWorkflowTurnInternal( } return turn.terminate(failTurn(called.FatalError)) } + if prepared.UsesProviderTranscript { + turn.transcriptDelta = agentruntime.AppendMerging( + turn.transcriptDelta, + []domain.Message{{ + Role: domain.RoleAssistant, + Content: called.Response.Content, + }}, + ) + for _, planned := range called.ToolSteps { + providerID := planned.ProviderToolUseID + if providerID == "" { + providerID = planned.ToolUseEventID + } + turn.toolUseMappings = append( + turn.toolUseMappings, + domain.ProviderToolUseMapping{ + PublicEventID: planned.ToolUseEventID, + ProviderToolUseID: providerID, + ToolName: toolNameForProviderID( + called.Response.Content, + providerID, + ), + }, + ) + } + } if content := agentruntime.TextBlocksToContent(called.Response.Content); len(content) > 0 { if called.MessageEventID == "" { @@ -143,24 +176,53 @@ func runWorkflowTurnInternal( } if prepared.AttemptID == "" { if activityOutcome.Interrupted { + closeInterruptedProviderToolRound( + turn, + toolUses, + nil, + mappingCheckpoint, + ) return turn.complete(nil) } return turn.terminate(failTurn("tool-using turn has no durable attempt id")) } - stepIDsByEvent := make(map[string]string, len(called.ToolSteps)) + stepsByProviderID := make( + map[string]PlannedToolStep, + len(called.ToolSteps), + ) for _, planned := range called.ToolSteps { - stepIDsByEvent[planned.ToolUseEventID] = planned.ToolStepID + providerID := planned.ProviderToolUseID + if providerID == "" { + providerID = planned.ToolUseEventID + } + stepsByProviderID[providerID] = planned } - plan, failure := planToolBatch(toolUses, toolsByName, stepIDsByEvent) + plan, failure := planToolBatch( + toolUses, + toolsByName, + stepsByProviderID, + ) if failure != "" { if activityOutcome.Interrupted { + closeInterruptedProviderToolRound( + turn, + toolUses, + nil, + mappingCheckpoint, + ) return turn.complete(nil) } return turn.terminate(failure) } turn.output = append(turn.output, plan.actionDrafts...) if activityOutcome.Interrupted { + closeInterruptedProviderToolRound( + turn, + toolUses, + nil, + mappingCheckpoint, + ) return turn.complete(nil) } @@ -174,12 +236,31 @@ func runWorkflowTurnInternal( } turn.output = append(turn.output, executed.resultDrafts...) if interrupted { + closeInterruptedProviderToolRound( + turn, + toolUses, + executed.resultBlocks, + mappingCheckpoint, + ) return turn.complete(nil) } if failure != "" { return turn.terminate(failure) } + // A model may request an executable tool and an approval/custom tool in + // the same assistant block. Persist completed executable results before + // parking so every tool_use already executed has a matching tool_result + // when the remaining pending actions resume. + if prepared.UsesProviderTranscript && len(executed.resultBlocks) > 0 { + turn.transcriptDelta = agentruntime.AppendMerging( + turn.transcriptDelta, + []domain.Message{{ + Role: domain.RoleUser, + Content: executed.resultBlocks, + }}, + ) + } if len(plan.pendingActionEventIDs) > 0 { return turn.complete(plan.pendingActionEventIDs) } @@ -196,3 +277,15 @@ func runWorkflowTurnInternal( // allowing unbounded Workflow history. return turn.complete(nil) } + +func toolNameForProviderID( + blocks []domain.ContentBlock, + providerID string, +) string { + for _, block := range blocks { + if block.Type == "tool_use" && block.ToolUseID == providerID { + return block.ToolName + } + } + return "" +} diff --git a/internal/temporal/agent_workflow_test.go b/internal/temporal/agent_workflow_test.go index 05e3a91..f2c7999 100644 --- a/internal/temporal/agent_workflow_test.go +++ b/internal/temporal/agent_workflow_test.go @@ -171,6 +171,91 @@ func TestWorkflowTurn_PreservesTextAndMultipleTools(t *testing.T) { require.Equal(t, domain.RunAttemptCompleted, completed.AttemptState) } +func TestWorkflowTurn_MixedExecutableAndPendingToolsCommitExecutedTranscriptResult(t *testing.T) { + var suite testsuite.WorkflowTestSuite + env := suite.NewTestWorkflowEnvironment() + env.RegisterWorkflow(workflowTurnHarness) + + initial := []domain.Message{{ + Role: domain.RoleUser, + Content: []domain.ContentBlock{{ + Type: "text", Text: "read then ask", + }}, + }} + prepare := func(context.Context, PrepareTurnInput) (PrepareTurnResult, error) { + return PrepareTurnResult{ + AttemptID: "ratm_mixed", + UsesProviderTranscript: true, + TranscriptDelta: initial, + Request: model.Request{ + Model: "test-model", + Messages: initial, + Tools: []model.ToolSchema{ + {Name: "read"}, + {Name: "ask_client"}, + }, + }, + Tools: []TurnTool{ + { + Name: "read", Kind: TurnToolBuiltin, + Permission: domain.PermissionPolicy{Type: "always_allow"}, + }, + {Name: "ask_client", Kind: TurnToolCustom}, + }, + }, nil + } + callModel := func(context.Context, CallModelInput) (CallModelResult, error) { + return CallModelResult{ + ToolSteps: []PlannedToolStep{ + { + ToolUseEventID: "public_read", ProviderToolUseID: "provider_read", + ToolStepID: "tstep_read", + }, + { + ToolUseEventID: "public_ask", ProviderToolUseID: "provider_ask", + ToolStepID: "tstep_ask", + }, + }, + Response: model.Response{Content: []domain.ContentBlock{ + { + Type: "tool_use", ToolUseID: "provider_read", ToolName: "read", + Input: map[string]any{"path": "a.txt"}, + }, + { + Type: "tool_use", ToolUseID: "provider_ask", ToolName: "ask_client", + Input: map[string]any{"question": "continue?"}, + }, + }}, + }, nil + } + executeTool := func(context.Context, ExecuteToolInput) (ExecuteToolResult, error) { + return ExecuteToolResult{Result: domain.ToolStepResult{ + Content: []any{map[string]any{ + "type": "text", "text": "file contents", + }}, + }}, nil + } + var completed CompleteWorkflowTurnInput + complete := func(_ context.Context, in CompleteWorkflowTurnInput) (RunTurnResult, error) { + completed = in + return RunTurnResult{Disposition: TurnParked}, nil + } + registerWorkflowTurnActivities(env, prepare, callModel, executeTool, complete) + + env.ExecuteWorkflow(workflowTurnHarness, PrepareTurnInput{ + SessionID: "sess_mixed", TriggerEventID: "sevt_trigger", + }) + require.NoError(t, env.GetWorkflowError()) + require.Equal(t, []string{"public_ask"}, completed.PendingActionEventIDs) + require.Len(t, completed.TranscriptDelta, 3) + require.Equal(t, domain.RoleUser, completed.TranscriptDelta[2].Role) + require.Equal(t, []domain.ContentBlock{{ + Type: "tool_result", + ToolResultFor: "provider_read", + Text: "file contents", + }}, completed.TranscriptDelta[2].Content) +} + func TestWorkflowTurn_ToolActivityRetryDoesNotRepeatModelStep(t *testing.T) { var suite testsuite.WorkflowTestSuite env := suite.NewTestWorkflowEnvironment() diff --git a/internal/temporal/agent_workflow_tools.go b/internal/temporal/agent_workflow_tools.go index 570749d..b331f02 100644 --- a/internal/temporal/agent_workflow_tools.go +++ b/internal/temporal/agent_workflow_tools.go @@ -21,14 +21,17 @@ func failTurn(message string) turnFailure { // manually passing the same session, trigger, output, attempt, and resolution // arguments in positional order. type workflowTurnState struct { - actx workflow.Context - sessionID string - triggerEventID string - resolutionEventIDs []string - output []domain.EventDraft - attemptID string - ordinal int - interrupts *turnInterruptWatcher + actx workflow.Context + sessionID string + triggerEventID string + resolutionEventIDs []string + output []domain.EventDraft + attemptID string + ordinal int + interrupts *turnInterruptWatcher + usesProviderTranscript bool + transcriptDelta []domain.Message + toolUseMappings []domain.ProviderToolUseMapping } func (t *workflowTurnState) callModel( @@ -54,7 +57,9 @@ func (t *workflowTurnState) callModel( func (t *workflowTurnState) executeTool( attemptID string, use domain.ContentBlock, + publicEventID string, stepID string, + definition TurnTool, ) (ExecuteToolResult, interruptibleActivityOutcome, error) { t.attemptID = attemptID input := ExecuteToolInput{ @@ -62,9 +67,12 @@ func (t *workflowTurnState) executeTool( TriggerEventID: t.triggerEventID, AttemptID: attemptID, Ordinal: t.ordinal, - ToolUseEventID: use.ToolUseID, + ToolUseEventID: publicEventID, ToolStepID: stepID, ToolName: use.ToolName, + ToolKind: definition.Kind, + MCPServer: definition.MCPServer, + MCPToolName: definition.MCPToolName, Input: use.Input, } var executed ExecuteToolResult @@ -117,6 +125,10 @@ func (t *workflowTurnState) complete( PendingActionEventIDs: pendingActionEventIDs, ResolutionEventIDs: t.resolutionEventIDs, } + if t.usesProviderTranscript { + input.TranscriptDelta = t.transcriptDelta + input.ToolUseMappings = t.toolUseMappings + } if t.attemptID != "" { input.AttemptState = domain.RunAttemptCompleted } @@ -147,6 +159,10 @@ func (t *workflowTurnState) terminate( AttemptID: t.attemptID, ResolutionEventIDs: t.resolutionEventIDs, } + if t.usesProviderTranscript { + input.TranscriptDelta = t.transcriptDelta + input.ToolUseMappings = t.toolUseMappings + } if t.attemptID != "" { input.AttemptState = domain.RunAttemptFailed input.AttemptError = &message @@ -194,12 +210,18 @@ func resumeWorkflowTurn( "pending action names a tool that is not enabled: " + action.ToolName, ), nil } - actionBlocks = append(actionBlocks, domain.ContentBlock{ - Type: "tool_use", - ToolUseID: action.ActionEventID, - ToolName: action.ToolName, - Input: action.Input, - }) + providerToolUseID := action.ProviderToolUseID + if providerToolUseID == "" { + providerToolUseID = action.ActionEventID + } + if !prepared.UsesProviderTranscript { + actionBlocks = append(actionBlocks, domain.ContentBlock{ + Type: "tool_use", + ToolUseID: providerToolUseID, + ToolName: action.ToolName, + Input: action.Input, + }) + } content := action.Content isError := action.IsError @@ -211,7 +233,8 @@ func resumeWorkflowTurn( ), nil } case domain.PendingToolConfirmation: - if definition.Kind != TurnToolBuiltin || + if (definition.Kind != TurnToolBuiltin && + definition.Kind != TurnToolMCP) || definition.Permission.Type != "always_ask" { return nil, false, failTurn( "tool confirmation does not reference an always_ask built-in", @@ -234,11 +257,13 @@ func resumeWorkflowTurn( prepared.AttemptID, domain.ContentBlock{ Type: "tool_use", - ToolUseID: action.ActionEventID, + ToolUseID: providerToolUseID, ToolName: action.ToolName, Input: action.Input, }, + action.ActionEventID, action.ToolStepID, + definition, ) if err != nil { return nil, false, "", err @@ -288,21 +313,35 @@ func resumeWorkflowTurn( } resultBlocks = append(resultBlocks, domain.ContentBlock{ Type: "tool_result", - ToolResultFor: action.ActionEventID, + ToolResultFor: providerToolUseID, Text: agentruntime.FlattenResultText(content), IsError: isError, }) } - return agentruntime.AppendMerging(messages, []domain.Message{ - {Role: domain.RoleAssistant, Content: actionBlocks}, - {Role: domain.RoleUser, Content: resultBlocks}, - }), false, "", nil + var added []domain.Message + if len(actionBlocks) > 0 { + added = append(added, domain.Message{ + Role: domain.RoleAssistant, Content: actionBlocks, + }) + } + added = append(added, domain.Message{ + Role: domain.RoleUser, Content: resultBlocks, + }) + if prepared.UsesProviderTranscript { + turn.transcriptDelta = agentruntime.AppendMerging( + turn.transcriptDelta, + []domain.Message{{Role: domain.RoleUser, Content: resultBlocks}}, + ) + } + return agentruntime.AppendMerging(messages, added), false, "", nil } type plannedToolUse struct { - use domain.ContentBlock - stepID string + use domain.ContentBlock + publicEventID string + stepID string + definition TurnTool } type toolBatchPlan struct { @@ -317,10 +356,10 @@ type toolBatchPlan struct { func planToolBatch( toolUses []domain.ContentBlock, toolsByName map[string]TurnTool, - stepIDsByEvent map[string]string, + stepsByProviderID map[string]PlannedToolStep, ) (toolBatchPlan, turnFailure) { for _, use := range toolUses { - if stepIDsByEvent[use.ToolUseID] == "" { + if stepsByProviderID[use.ToolUseID].ToolStepID == "" { return toolBatchPlan{}, failTurn( "model tool request has no durable operation id", ) @@ -346,32 +385,39 @@ func planToolBatch( } for _, use := range toolUses { definition := toolsByName[use.ToolName] + planned := stepsByProviderID[use.ToolUseID] draft := domain.EventDraft{ - ID: use.ToolUseID, + ID: planned.ToolUseEventID, Payload: map[string]any{ "name": use.ToolName, "input": use.Input, }, } + if definition.Kind == TurnToolMCP { + draft.Payload["name"] = definition.MCPToolName + draft.Payload["mcp_server_name"] = definition.MCPServer.Name + } switch { case definition.Kind == TurnToolCustom: draft.Type = domain.EvAgentCustomToolUse plan.pendingActionEventIDs = append( plan.pendingActionEventIDs, - use.ToolUseID, + planned.ToolUseEventID, ) case definition.Permission.Type == "always_ask": draft.Type = domain.EvAgentToolUse draft.Payload["evaluated_permission"] = "ask" plan.pendingActionEventIDs = append( plan.pendingActionEventIDs, - use.ToolUseID, + planned.ToolUseEventID, ) default: draft.Type = domain.EvAgentToolUse plan.executable = append(plan.executable, plannedToolUse{ - use: use, - stepID: stepIDsByEvent[use.ToolUseID], + use: use, + publicEventID: planned.ToolUseEventID, + stepID: planned.ToolStepID, + definition: definition, }) } plan.actionDrafts = append(plan.actionDrafts, draft) @@ -384,6 +430,63 @@ type toolBatchExecution struct { resultBlocks []domain.ContentBlock } +// closeInterruptedProviderToolRound keeps the private transcript legal when an +// interrupt wins after a provider response containing client tool_use blocks. +// Completed tools retain their real result; every remaining provider tool id +// receives a synthetic error result. Public output still follows the interrupt +// projection and drops uncompleted tool-use events. +func closeInterruptedProviderToolRound( + turn *workflowTurnState, + toolUses []domain.ContentBlock, + completed []domain.ContentBlock, + mappingCheckpoint int, +) { + if !turn.usesProviderTranscript { + return + } + byProviderID := make(map[string]domain.ContentBlock, len(completed)) + for _, block := range completed { + if block.Type == "tool_result" && block.ToolResultFor != "" { + byProviderID[block.ToolResultFor] = block + } + } + results := make([]domain.ContentBlock, 0, len(toolUses)) + for _, use := range toolUses { + if block, ok := byProviderID[use.ToolUseID]; ok { + results = append(results, block) + continue + } + results = append(results, domain.ContentBlock{ + Type: "tool_result", + ToolResultFor: use.ToolUseID, + Text: "Tool execution was interrupted before a result was committed.", + IsError: true, + }) + } + if len(results) > 0 { + turn.transcriptDelta = agentruntime.AppendMerging( + turn.transcriptDelta, + []domain.Message{{ + Role: domain.RoleUser, Content: results, + }}, + ) + } + + if mappingCheckpoint < 0 || mappingCheckpoint > len(turn.toolUseMappings) { + mappingCheckpoint = len(turn.toolUseMappings) + } + filtered := append( + []domain.ProviderToolUseMapping(nil), + turn.toolUseMappings[:mappingCheckpoint]..., + ) + for _, mapping := range turn.toolUseMappings[mappingCheckpoint:] { + if _, completed := byProviderID[mapping.ProviderToolUseID]; completed { + filtered = append(filtered, mapping) + } + } + turn.toolUseMappings = filtered +} + // executeToolBatch remains Workflow-scoped: each tool execution is a durable // Activity and its command order and ordinal are part of replay history. func executeToolBatch( @@ -399,7 +502,9 @@ func executeToolBatch( executed, activityOutcome, err := turn.executeTool( attemptID, planned.use, + planned.publicEventID, planned.stepID, + planned.definition, ) if err != nil { return toolBatchExecution{}, false, "", err @@ -425,7 +530,7 @@ func executeToolBatch( execution.resultDrafts = append(execution.resultDrafts, domain.EventDraft{ Type: domain.EvAgentToolResult, Payload: map[string]any{ - "tool_use_id": planned.use.ToolUseID, + "tool_use_id": planned.publicEventID, "content": executed.Result.Content, "is_error": executed.Result.IsError, }, diff --git a/internal/temporal/agent_workflow_tools_test.go b/internal/temporal/agent_workflow_tools_test.go index e2d1663..f53577c 100644 --- a/internal/temporal/agent_workflow_tools_test.go +++ b/internal/temporal/agent_workflow_tools_test.go @@ -34,10 +34,16 @@ func TestPlanToolBatch_ClassifiesWholeRoundBeforeExecution(t *testing.T) { Permission: domain.PermissionPolicy{Type: "always_ask"}, }, }) - steps := map[string]string{ - "sevt_custom": "tstep_custom", - "sevt_builtin": "tstep_builtin", - "sevt_ask": "tstep_ask", + steps := map[string]PlannedToolStep{ + "sevt_custom": { + ToolUseEventID: "sevt_custom", ToolStepID: "tstep_custom", + }, + "sevt_builtin": { + ToolUseEventID: "sevt_builtin", ToolStepID: "tstep_builtin", + }, + "sevt_ask": { + ToolUseEventID: "sevt_ask", ToolStepID: "tstep_ask", + }, } plan, failure := planToolBatch(uses, tools, steps) @@ -50,8 +56,10 @@ func TestPlanToolBatch_ClassifiesWholeRoundBeforeExecution(t *testing.T) { }, draftTypes(plan.actionDrafts)) require.Equal(t, []string{"sevt_custom", "sevt_ask"}, plan.pendingActionEventIDs) require.Equal(t, []plannedToolUse{{ - use: uses[1], - stepID: "tstep_builtin", + use: uses[1], + publicEventID: "sevt_builtin", + stepID: "tstep_builtin", + definition: tools["bash"], }}, plan.executable) require.Equal( t, @@ -65,7 +73,7 @@ func TestPlanToolBatch_RejectsInvalidRoundBeforePlanning(t *testing.T) { name string use domain.ContentBlock tools []TurnTool - steps map[string]string + steps map[string]PlannedToolStep wantFailure turnFailure }{ { @@ -77,7 +85,7 @@ func TestPlanToolBatch_RejectsInvalidRoundBeforePlanning(t *testing.T) { Name: "bash", Kind: TurnToolBuiltin, Permission: domain.PermissionPolicy{Type: "always_allow"}, }}, - steps: map[string]string{}, + steps: map[string]PlannedToolStep{}, wantFailure: failTurn("model tool request has no durable operation id"), }, { @@ -85,7 +93,9 @@ func TestPlanToolBatch_RejectsInvalidRoundBeforePlanning(t *testing.T) { use: domain.ContentBlock{ Type: "tool_use", ToolUseID: "sevt_missing", ToolName: "missing", }, - steps: map[string]string{"sevt_missing": "tstep_missing"}, + steps: map[string]PlannedToolStep{"sevt_missing": { + ToolUseEventID: "sevt_missing", ToolStepID: "tstep_missing", + }}, wantFailure: failTurn( "model requested a tool that is not enabled: missing", ), @@ -99,7 +109,9 @@ func TestPlanToolBatch_RejectsInvalidRoundBeforePlanning(t *testing.T) { Name: "bash", Kind: TurnToolBuiltin, Permission: domain.PermissionPolicy{Type: "always_deny"}, }}, - steps: map[string]string{"sevt_bash": "tstep_bash"}, + steps: map[string]PlannedToolStep{"sevt_bash": { + ToolUseEventID: "sevt_bash", ToolStepID: "tstep_bash", + }}, wantFailure: failTurn( "built-in tool has unsupported permission policy: always_deny", ), @@ -132,3 +144,60 @@ func TestIndexTurnTools_PreservesFirstOwner(t *testing.T) { require.Equal(t, TurnToolBuiltin, tools["read"].Kind) } + +func TestCloseInterruptedProviderToolRound_PairsEveryProviderToolUse(t *testing.T) { + turn := &workflowTurnState{ + usesProviderTranscript: true, + transcriptDelta: []domain.Message{{ + Role: domain.RoleAssistant, + Content: []domain.ContentBlock{ + {Type: "tool_use", ToolUseID: "provider_1", ToolName: "read"}, + {Type: "tool_use", ToolUseID: "provider_2", ToolName: "write"}, + }, + }}, + toolUseMappings: []domain.ProviderToolUseMapping{ + { + PublicEventID: "public_prior", ProviderToolUseID: "provider_prior", + ToolName: "grep", + }, + { + PublicEventID: "public_1", ProviderToolUseID: "provider_1", + ToolName: "read", + }, + { + PublicEventID: "public_2", ProviderToolUseID: "provider_2", + ToolName: "write", + }, + }, + } + uses := append( + []domain.ContentBlock(nil), + turn.transcriptDelta[0].Content..., + ) + completed := []domain.ContentBlock{{ + Type: "tool_result", ToolResultFor: "provider_1", Text: "done", + }} + + closeInterruptedProviderToolRound(turn, uses, completed, 1) + + require.Len(t, turn.transcriptDelta, 2) + require.Equal(t, domain.RoleUser, turn.transcriptDelta[1].Role) + require.Equal(t, []domain.ContentBlock{ + {Type: "tool_result", ToolResultFor: "provider_1", Text: "done"}, + { + Type: "tool_result", ToolResultFor: "provider_2", + Text: "Tool execution was interrupted before a result was committed.", + IsError: true, + }, + }, turn.transcriptDelta[1].Content) + require.Equal(t, []domain.ProviderToolUseMapping{ + { + PublicEventID: "public_prior", ProviderToolUseID: "provider_prior", + ToolName: "grep", + }, + { + PublicEventID: "public_1", ProviderToolUseID: "provider_1", + ToolName: "read", + }, + }, turn.toolUseMappings) +} diff --git a/internal/temporal/mcp_test.go b/internal/temporal/mcp_test.go new file mode 100644 index 0000000..4ba81fd --- /dev/null +++ b/internal/temporal/mcp_test.go @@ -0,0 +1,367 @@ +package temporal + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/yanpgwang/managed-agent-go/internal/domain" + "github.com/yanpgwang/managed-agent-go/internal/mcpclient" + "github.com/yanpgwang/managed-agent-go/internal/model" + "github.com/yanpgwang/managed-agent-go/internal/sandbox" +) + +type fakeMCPClient struct { + tools []mcpclient.Tool + result mcpclient.Result + server domain.MCPServer + name string + input map[string]any + err error +} + +func (f *fakeMCPClient) Discover( + context.Context, + domain.MCPServer, +) ([]mcpclient.Tool, error) { + if f.err != nil { + return nil, f.err + } + return append([]mcpclient.Tool(nil), f.tools...), nil +} + +func (f *fakeMCPClient) Call( + _ context.Context, + server domain.MCPServer, + name string, + input map[string]any, +) (mcpclient.Result, error) { + f.server = server + f.name = name + f.input = input + return f.result, nil +} + +type mcpPrepareSource struct { + *fakeSource + session domain.Session +} + +func (s *mcpPrepareSource) GetSession( + context.Context, + string, +) (domain.Session, error) { + return s.session, nil +} + +func TestPrepareTurn_DiscoversAndPinsMCPTools(t *testing.T) { + source := &mcpPrepareSource{ + fakeSource: newFakeSource([]domain.Event{{ + ID: "sevt_user", Sequence: 1, Type: domain.EvUserMessage, + Payload: map[string]any{"content": []any{ + map[string]any{"type": "text", "text": "check github"}, + }}, + }}), + session: domain.Session{ + ID: "sess_mcp", + Status: domain.StatusRunning, + AgentSnapshot: domain.Agent{ + Model: domain.Model{ID: "model"}, + MCPServers: []any{map[string]any{ + "type": "url", "name": "github", + "url": "https://mcp.example.com", + }}, + Tools: []any{map[string]any{ + "type": "mcp_toolset", + "mcp_server_name": "github", + }}, + }, + }, + } + client := &fakeMCPClient{tools: []mcpclient.Tool{{ + Name: "list_issues", + Description: "List repository issues", + InputSchema: map[string]any{"type": "object"}, + }}} + activities := NewActivities( + nil, + source, + nil, + nil, + &testIDGen{}, + ).WithMCPClient(client) + prepared, err := activities.PrepareTurn( + context.Background(), + PrepareTurnInput{ + SessionID: "sess_mcp", + TriggerEventID: "sevt_user", + }, + ) + require.NoError(t, err) + require.Empty(t, prepared.FatalError) + require.Equal(t, []modelToolSummary{{ + Name: "mcp__github__list_issues", + Type: "", + }}, summarizeModelTools(prepared.Request.Tools)) + require.Equal(t, []TurnTool{{ + Name: "mcp__github__list_issues", + Kind: TurnToolMCP, + Permission: domain.PermissionPolicy{ + Type: "always_ask", + }, + MCPServer: domain.MCPServer{ + Name: "github", URL: "https://mcp.example.com", + }, + MCPToolName: "list_issues", + }}, prepared.Tools) +} + +func TestPrepareTurn_MCPDiscoveryFailureIsRecoverable(t *testing.T) { + source := &mcpPrepareSource{ + fakeSource: newFakeSource([]domain.Event{{ + ID: "sevt_user", Sequence: 1, Type: domain.EvUserMessage, + Payload: map[string]any{"content": []any{ + map[string]any{"type": "text", "text": "continue without github"}, + }}, + }}), + session: domain.Session{ + ID: "sess_mcp", + Status: domain.StatusRunning, + AgentSnapshot: domain.Agent{ + Model: domain.Model{ID: "model"}, + MCPServers: []any{map[string]any{ + "type": "url", "name": "github", + "url": "https://mcp.example.com", + }}, + Tools: []any{map[string]any{ + "type": "mcp_toolset", + "mcp_server_name": "github", + }}, + }, + }, + } + activities := NewActivities( + nil, + source, + nil, + nil, + &testIDGen{}, + ).WithMCPClient(&fakeMCPClient{err: errors.New("dial failed")}) + + prepared, err := activities.PrepareTurn( + context.Background(), + PrepareTurnInput{ + SessionID: "sess_mcp", + TriggerEventID: "sevt_user", + }, + ) + require.NoError(t, err) + require.Empty(t, prepared.FatalError) + require.Empty(t, prepared.Request.Tools) + require.Empty(t, prepared.Tools) + require.Len(t, prepared.PreludeEvents, 1) + require.Equal(t, domain.EvSessionError, prepared.PreludeEvents[0].Type) + errorPayload := prepared.PreludeEvents[0].Payload["error"].(map[string]any) + require.Equal(t, "mcp_connection_failed_error", errorPayload["type"]) + require.Equal(t, "github", errorPayload["mcp_server_name"]) + require.Equal(t, "exhausted", errorPayload["retry_status"].(map[string]any)["type"]) +} + +func TestPrepareTurn_MCPAliasCollisionIsFatalNotRetryable(t *testing.T) { + source := &mcpPrepareSource{ + fakeSource: newFakeSource([]domain.Event{{ + ID: "sevt_user", Sequence: 1, Type: domain.EvUserMessage, + Payload: map[string]any{"content": []any{ + map[string]any{"type": "text", "text": "use MCP"}, + }}, + }}), + session: domain.Session{ + ID: "sess_collision", Status: domain.StatusRunning, + AgentSnapshot: domain.Agent{ + Model: domain.Model{ID: "model"}, + MCPServers: []any{map[string]any{ + "type": "url", "name": "server", + "url": "https://mcp.example.com", + }}, + Tools: []any{map[string]any{ + "type": "mcp_toolset", "mcp_server_name": "server", + }}, + }, + }, + } + client := &fakeMCPClient{tools: []mcpclient.Tool{ + {Name: "get.user", InputSchema: map[string]any{"type": "object"}}, + {Name: "get user", InputSchema: map[string]any{"type": "object"}}, + }} + + prepared, err := NewActivities( + nil, + source, + nil, + nil, + &testIDGen{}, + ).WithMCPClient(client).PrepareTurn( + context.Background(), + PrepareTurnInput{ + SessionID: "sess_collision", TriggerEventID: "sevt_user", + }, + ) + + require.NoError(t, err) + require.Contains(t, prepared.FatalError, "MCP model tool name collision") +} + +type modelToolSummary struct { + Name string + Type string +} + +func summarizeModelTools(tools []model.ToolSchema) []modelToolSummary { + out := make([]modelToolSummary, 0, len(tools)) + for _, tool := range tools { + out = append(out, modelToolSummary{Name: tool.Name, Type: tool.Type}) + } + return out +} + +type memoryMCPJournal struct { + mu sync.Mutex + step domain.ToolStep + result domain.ToolStepResult +} + +func (*memoryMCPJournal) EnsureAttempt( + context.Context, + string, + string, + string, +) error { + return nil +} + +func (j *memoryMCPJournal) EnsureToolStep( + _ context.Context, + attemptID string, + stepID string, + ordinal int, + eventID string, + name string, + input map[string]any, +) (domain.ToolStep, error) { + j.mu.Lock() + defer j.mu.Unlock() + if j.step.ID == "" { + j.step = domain.ToolStep{ + ID: stepID, AttemptID: attemptID, Ordinal: ordinal, + ToolUseEventID: eventID, ToolName: name, Input: input, + State: domain.ToolStepPrepared, + } + } + return j.step, nil +} + +func (j *memoryMCPJournal) StartToolStep(context.Context, string) error { + j.mu.Lock() + j.step.State = domain.ToolStepStarted + j.mu.Unlock() + return nil +} + +func (j *memoryMCPJournal) CompleteToolStep( + _ context.Context, + _ string, + result domain.ToolStepResult, +) error { + j.mu.Lock() + j.step.State = domain.ToolStepCompleted + j.step.Result = &result + j.result = result + j.mu.Unlock() + return nil +} + +func (j *memoryMCPJournal) MarkToolStepAmbiguous( + context.Context, + string, +) error { + j.mu.Lock() + j.step.State = domain.ToolStepAmbiguous + j.mu.Unlock() + return nil +} + +type fixedSandboxLease struct { + box sandbox.Sandbox +} + +func (l fixedSandboxLease) Acquire( + context.Context, + string, + sandbox.Spec, +) (sandbox.Sandbox, error) { + return l.box, nil +} + +func (fixedSandboxLease) Release(context.Context, string) error { return nil } + +func TestExecuteTool_MCPJournalsRawAndProjectsModelContent(t *testing.T) { + _, box, err := sandbox.NewLocalProvider().Create( + context.Background(), + t.Name(), + sandbox.Spec{}, + ) + require.NoError(t, err) + t.Cleanup(func() { _ = box.Destroy(context.Background()) }) + journal := &memoryMCPJournal{} + client := &fakeMCPClient{result: mcpclient.Result{ + Raw: json.RawMessage(`{ + "_meta":{"trace":"private"}, + "content":[{"type":"text","text":"42"}] + }`), + }} + activities := NewActivities( + nil, + nil, + journal, + fixedSandboxLease{box: box}, + &testIDGen{}, + ).WithMCPClient(client) + result, err := activities.ExecuteTool( + context.Background(), + ExecuteToolInput{ + SessionID: "sess_mcp", + TriggerEventID: "sevt_trigger", + AttemptID: "ratm_1", + ToolUseEventID: "sevt_tool", + ToolStepID: "tstep_1", + ToolName: "mcp__github__answer", + ToolKind: TurnToolMCP, + MCPServer: domain.MCPServer{ + Name: "github", URL: "https://mcp.example.com", + }, + MCPToolName: "answer", + Input: map[string]any{"question": "life"}, + }, + ) + require.NoError(t, err) + require.False(t, result.Result.IsError) + require.Equal(t, "github", client.server.Name) + require.Equal(t, "answer", client.name) + require.Contains( + t, + result.Result.Content[0].(map[string]any)["text"], + "42", + ) + require.Contains(t, string(journal.result.Raw), `"trace":"private"`) + require.Empty(t, result.Result.Raw) + require.Empty(t, result.Result.RawPath) + require.NotContains( + t, + result.Result.Content[0].(map[string]any)["text"], + "private", + ) +} diff --git a/internal/temporal/store_source.go b/internal/temporal/store_source.go index b71798d..2ceb956 100644 --- a/internal/temporal/store_source.go +++ b/internal/temporal/store_source.go @@ -4,9 +4,27 @@ import ( "context" "github.com/yanpgwang/managed-agent-go/internal/domain" + "github.com/yanpgwang/managed-agent-go/internal/mcpclient" "github.com/yanpgwang/managed-agent-go/internal/pg" ) +func (s storeSource) GetMCPDiscoverySnapshot( + ctx context.Context, + sessionID string, + server domain.MCPServer, +) ([]mcpclient.Tool, bool, error) { + return s.store.GetMCPDiscoverySnapshot(ctx, sessionID, server) +} + +func (s storeSource) PutMCPDiscoverySnapshot( + ctx context.Context, + sessionID string, + server domain.MCPServer, + tools []mcpclient.Tool, +) ([]mcpclient.Tool, error) { + return s.store.PutMCPDiscoverySnapshot(ctx, sessionID, server, tools) +} + // storeSource adapts *pg.Store to the EventSource interface the Activities // depend on. It exists so the temporal package depends on a narrow interface // rather than the pg package's concrete completion type, keeping the domain @@ -48,6 +66,13 @@ func (s storeSource) UnresolvedPendingActions( return s.store.UnresolvedPendingActions(ctx, sessionID) } +func (s storeSource) LoadProviderTranscript( + ctx context.Context, + sessionID string, +) (domain.ProviderTranscript, error) { + return s.store.LoadProviderTranscript(ctx, sessionID) +} + func (s storeSource) CompleteWorkflowTurn( ctx context.Context, sessionID string, @@ -84,6 +109,46 @@ func (s storeSource) CompleteWorkflowTurn( }, nil } +func (s storeSource) CompleteWorkflowTurnWithTranscript( + ctx context.Context, + sessionID string, + triggerEventID string, + output []domain.EventDraft, + status domain.Status, + attemptID string, + attemptState domain.RunAttemptState, + attemptError *string, + pendingActionEventIDs []string, + resolutionEventIDs []string, + transcriptDelta []domain.Message, + toolUseMappings []domain.ProviderToolUseMapping, +) (TurnCompletionResult, error) { + res, err := s.store.CompleteWorkflowTurnWithTranscript( + ctx, + sessionID, + triggerEventID, + output, + status, + attemptID, + attemptState, + attemptError, + pendingActionEventIDs, + resolutionEventIDs, + transcriptDelta, + toolUseMappings, + ) + if err != nil { + return TurnCompletionResult{}, err + } + parked := res.Parked + return TurnCompletionResult{ + Events: res.Events, + Applied: res.Applied, + Status: res.Session.Status, + Parked: &parked, + }, nil +} + // storeSource also satisfies JournalStore; the methods below delegate directly. func (s storeSource) EnsureAttempt(ctx context.Context, sessionID, triggerEventID, attemptID string) error { diff --git a/internal/temporal/types.go b/internal/temporal/types.go index 2b6bea6..909f832 100644 --- a/internal/temporal/types.go +++ b/internal/temporal/types.go @@ -78,13 +78,16 @@ type TurnToolKind string const ( TurnToolBuiltin TurnToolKind = "builtin" TurnToolCustom TurnToolKind = "custom" + TurnToolMCP TurnToolKind = "mcp" ) // TurnTool is the immutable, Workflow-facing classification of an offered tool. type TurnTool struct { - Name string `json:"name"` - Kind TurnToolKind `json:"kind"` - Permission domain.PermissionPolicy `json:"permission"` + Name string `json:"name"` + Kind TurnToolKind `json:"kind"` + Permission domain.PermissionPolicy `json:"permission"` + MCPServer domain.MCPServer `json:"mcp_server,omitempty"` + MCPToolName string `json:"mcp_tool_name,omitempty"` } // PrepareTurnInput identifies one public trigger whose model turn should run. @@ -105,6 +108,16 @@ type PrepareTurnResult struct { Request model.Request `json:"request"` Tools []TurnTool `json:"tools,omitempty"` ResumeActions []ResumeAction `json:"resume_actions,omitempty"` + // PreludeEvents are recoverable setup diagnostics, such as one unavailable + // MCP server. The Workflow commits them with the turn while continuing with + // the remaining tool surface. + PreludeEvents []domain.EventDraft `json:"prelude_events,omitempty"` + // UsesProviderTranscript is true when Request.Messages came from the + // lossless private transcript rather than the legacy public-event + // projection. TranscriptDelta contains only the new input represented by + // this turn; Workflow code appends provider responses and tool results. + UsesProviderTranscript bool `json:"uses_provider_transcript,omitempty"` + TranscriptDelta []domain.Message `json:"transcript_delta,omitempty"` } // ResumeAction is the server-owned reconstruction of one parked tool call and @@ -122,6 +135,7 @@ type ResumeAction struct { Confirmation string `json:"confirmation,omitempty"` DenyMessage string `json:"deny_message,omitempty"` ToolStepID string `json:"tool_step_id,omitempty"` + ProviderToolUseID string `json:"provider_tool_use_id,omitempty"` } // CallModelInput is one plan/observe step. Each call is its own Activity so its @@ -135,13 +149,15 @@ type CallModelInput struct { // Both ids are Activity output recorded in Workflow history; retries therefore // reuse explicit operation ids rather than deriving one namespace from another. type PlannedToolStep struct { - ToolUseEventID string `json:"tool_use_event_id"` - ToolStepID string `json:"tool_step_id"` + ToolUseEventID string `json:"tool_use_event_id"` + ProviderToolUseID string `json:"provider_tool_use_id"` + ToolStepID string `json:"tool_step_id"` } -// CallModelResult carries a normalized model response. ToolUseID values have -// been replaced with server-owned public event IDs, and MessageEventID names the -// public agent.message when the response contains non-empty text. +// CallModelResult carries a normalized model response. Provider tool-use IDs +// remain untouched for exact replay; ToolSteps maps each one to server-owned +// public/journal IDs. MessageEventID names the public agent.message when the +// response contains non-empty text. type CallModelResult struct { Response model.Response `json:"response"` MessageEventID string `json:"message_event_id,omitempty"` @@ -152,14 +168,17 @@ type CallModelResult struct { // ExecuteToolInput identifies one logical built-in tool step. ToolUseEventID is // stable because it came from the completed CallModel Activity result. type ExecuteToolInput struct { - SessionID string `json:"session_id"` - TriggerEventID string `json:"trigger_event_id"` - AttemptID string `json:"attempt_id"` - Ordinal int `json:"ordinal"` - ToolUseEventID string `json:"tool_use_event_id"` - ToolStepID string `json:"tool_step_id"` - ToolName string `json:"tool_name"` - Input map[string]any `json:"input"` + SessionID string `json:"session_id"` + TriggerEventID string `json:"trigger_event_id"` + AttemptID string `json:"attempt_id"` + Ordinal int `json:"ordinal"` + ToolUseEventID string `json:"tool_use_event_id"` + ToolStepID string `json:"tool_step_id"` + ToolName string `json:"tool_name"` + ToolKind TurnToolKind `json:"tool_kind,omitempty"` + MCPServer domain.MCPServer `json:"mcp_server,omitempty"` + MCPToolName string `json:"mcp_tool_name,omitempty"` + Input map[string]any `json:"input"` } // ExecuteToolResult is the durable result of one tool Activity. Ambiguous is a @@ -187,6 +206,10 @@ type CompleteWorkflowTurnInput struct { // ResolutionEventIDs names every client event that closes the current // pending-action barrier. PostgreSQL validates the set atomically. ResolutionEventIDs []string `json:"resolution_event_ids,omitempty"` + // TranscriptDelta and ToolUseMappings are provider-private continuation + // state. PostgreSQL commits them atomically with Output when supported. + TranscriptDelta []domain.Message `json:"transcript_delta,omitempty"` + ToolUseMappings []domain.ProviderToolUseMapping `json:"tool_use_mappings,omitempty"` } // LoadEventsInput requests the ordered public events after a cursor. diff --git a/website/sidebars.ts b/website/sidebars.ts index b2060f3..d2fb700 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -14,6 +14,7 @@ const sidebars: SidebarsConfig = { 'architecture/domain-model', 'architecture/session-lifecycle', 'architecture/runtime-and-sandbox', + 'architecture/storage-context-and-tools', ], }, {