diff --git a/apps/daemon/internal/agent/claudesdk/executor.go b/apps/daemon/internal/agent/claudesdk/executor.go index d895e9082..428816001 100644 --- a/apps/daemon/internal/agent/claudesdk/executor.go +++ b/apps/daemon/internal/agent/claudesdk/executor.go @@ -135,7 +135,7 @@ func (e *executor) read() { break } if !ready { - if event.Type != "executor_ready" || event.Protocol != 3 || event.TurnID != "" { + if event.Type != "executor_ready" || event.Protocol != 4 || event.TurnID != "" { if event.Type == "error" { readyFailure = bridgeFailure(event.Code) } @@ -179,7 +179,7 @@ func (e *executor) read() { close(e.done) } -func (e *executor) StartTurn(ctx context.Context, run string, _ proto.TurnSettings, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { +func (e *executor) StartTurn(ctx context.Context, run string, turn proto.TurnSettings, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { if ctx == nil { ctx = context.Background() } @@ -199,7 +199,11 @@ func (e *executor) StartTurn(ctx context.Context, run string, _ proto.TurnSettin go s.runTurn(start, out) // Once the write is attempted, a lost receipt has an unknown input outcome. stopWrite := context.AfterFunc(ctx, s.invalidate) - err := e.write(turnStartFrame{"turn_start", run, input}) + settings := turnSettings{Model: turn.Model, Review: turn.Review} + if turn.ReasoningEffort != "" { + settings.Effort = &turn.ReasoningEffort + } + err := e.write(turnStartFrame{"turn_start", run, input, settings}) stopWrite() if err != nil { s.invalidate() diff --git a/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go b/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go index 80a8b5164..48723ebd4 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go @@ -31,7 +31,7 @@ func startSingleTurn(ctx context.Context, config Config, req proto.PromptRequest } func helperTurn(scanner *bufio.Scanner, request *startRequest) (func(bridgeEvent), func()) { - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "executor_ready", Protocol: 3}) + _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "executor_ready", Protocol: 4}) if !scanner.Scan() { os.Exit(4) } diff --git a/apps/daemon/internal/agent/claudesdk/executor_test.go b/apps/daemon/internal/agent/claudesdk/executor_test.go index a938d076d..121f5a83d 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_test.go @@ -51,7 +51,7 @@ func runPersistentExecutorHelper() { } _ = os.WriteFile(filepath.Join(root, "prepared"), []byte(strconv.Itoa(os.Getpid())), 0600) encode := func(event bridgeEvent) { _ = json.NewEncoder(os.Stdout).Encode(event) } - encode(bridgeEvent{Type: "executor_ready", Protocol: 3}) + encode(bridgeEvent{Type: "executor_ready", Protocol: 4}) if os.Getenv("SDK_EXECUTOR_MODE") == "block" { time.Sleep(time.Hour) return diff --git a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go index 58805600b..7234139ba 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go @@ -84,14 +84,14 @@ func runPreparationHelper() { time.Sleep(time.Millisecond) } } - emit(bridgeEvent{Type: "executor_ready", Protocol: 3}) + emit(bridgeEvent{Type: "executor_ready", Protocol: 4}) if !scanner.Scan() { return } raw = append([]byte{}, scanner.Bytes()...) _ = os.WriteFile(filepath.Join(state, "start.json"), raw, 0o600) fields = nil - if json.Unmarshal(raw, &fields) != nil || len(fields) != 3 || string(fields["type"]) != `"turn_start"` || string(fields["input"]) != `[{"content":[{"type":"input_text","text":"hello"}]}]` { + if json.Unmarshal(raw, &fields) != nil || len(fields) != 4 || string(fields["type"]) != `"turn_start"` || string(fields["input"]) != `[{"content":[{"type":"input_text","text":"hello"}]}]` { os.Exit(4) } var turnID string diff --git a/apps/daemon/internal/agent/claudesdk/preparation_test.go b/apps/daemon/internal/agent/claudesdk/preparation_test.go index 8fa71d7e4..24c369d74 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_test.go @@ -69,11 +69,15 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { req.AgentSessionID = "changed" out := make(chan proto.Envelope, 16) operation, stopOperation := context.WithCancel(ctx) - turn, err := e.StartTurn(operation, "actual-run", proto.TurnSettings{}, proto.TextInput("hello"), out) + turn, err := e.StartTurn(operation, "actual-run", proto.TurnSettings{Model: "turn-model", Review: "auto_review"}, proto.TextInput("hello"), out) stopOperation() if err != nil { t.Fatal(err) } + var sent struct{ Settings json.RawMessage } + if json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "start.json")), &sent) != nil || string(sent.Settings) != `{"model":"turn-model","effort":null,"review":"auto_review"}` { + t.Fatalf("turn_start settings = %s", sent.Settings) + } if turn.(*session).owner != e || turn.(*session).process.Cmd.Process.Pid != pid { t.Fatal("StartTurn replaced the prepared native process") } diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index d0bea805c..4fb87ba72 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -37,6 +37,15 @@ type turnStartFrame struct { Type string `json:"type"` TurnID string `json:"turn_id"` Input proto.MessageInput `json:"input"` + // Settings are the Turn's model, effort and review; the bridge applies what differs from the last Turn. + Settings turnSettings `json:"settings"` +} + +// turnSettings carries a null Effort to return to the model's default effort. +type turnSettings struct { + Model string `json:"model"` + Effort *string `json:"effort"` + Review string `json:"review"` } type turnCancelFrame struct { diff --git a/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go b/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go index 861312450..39abe7d2c 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go @@ -27,7 +27,7 @@ case "$1" in printf '%s\n' '{"type":"runtime_ready","protocol":3,"node":"fixture","sdk":"fixture","mcp":"fixture","native":"fixture","features":["workspace_tools","workspace_prepare","workspace_command_observations"]}' ;; *) IFS= read -r request - printf '%s\n' '{"type":"executor_ready","protocol":3}' + printf '%s\n' '{"type":"executor_ready","protocol":4}' IFS= read -r request printf '%s\n' '{"type":"result","turn_id":"run","session_id":"native","text":"completed"}' printf '%s\n' '{"type":"turn_settled","turn_id":"run","confirmed":true,"reusable":true,"reason":""}' ;; diff --git a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go index 6a4fb98ad..aa905e201 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go @@ -52,7 +52,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T } } var started map[string]json.RawMessage - if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "start.json")), &started); err != nil || len(started) != 3 || started["output_format"] != nil { + if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "start.json")), &started); err != nil || len(started) != 4 || started["output_format"] != nil { t.Fatal("Start replaced the prepared configuration", err) } }) diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md index fc9357e4f..c9dec9136 100644 --- a/contracts/agents-api/harness-capabilities.md +++ b/contracts/agents-api/harness-capabilities.md @@ -27,7 +27,7 @@ Placements are `none` (no Environment), hosted (`openai_hosted`) and self-hosted | `reasoning.summary` | `concise`, `detailed`, `auto`: admitted | `auto`: admitted | Rejected | | `service_tier` other than `auto` | `default`, `flex`, `priority`, `fast`: admitted; `ultrafast` rejected | Rejected | Rejected | | `text.verbosity` other than `medium` | Admitted; the native model decides | Rejected | Rejected | -| Session update of `agent.model`, `reasoning.effort`, `service_tier` and `review: auto_review` | Applied on the next root Turn; `reasoning.effort` cannot be reset to null | Rejected | Rejected | +| Session update of `agent.model`, `reasoning.effort` (including null), `service_tier` and `review: auto_review` | Applied on the next root Turn; `reasoning.effort` cannot be reset to null | Applied on the next root Turn, except `service_tier`, which is rejected | Rejected | | Session update of `agent.tools` | Rejected | Rejected | Rejected | | `compaction.async: true`, `experimental.time_awareness: true` and the `automations` and `computer_use` tools | Rejected | Rejected | Rejected | | [Public token usage](./sessions-events.md) | Measured counters | Null | Null | diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index b509cf003..6f135c251 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -214,7 +214,7 @@ The daemon's `clirunner` starts every native child in its own Unix process group Owned output pipes stay readable after the leader exits. Consumers drain stdout and stderr before calling `Wait`, which joins the cached process result and closes the readers. `Done` reports leader reaping and group cleanup signals; it is not a native execution receipt or proof of persisted history. SDK adapters settle each Turn and drain its observations before publishing completion, and Executor close also closes the query and awaits the native child. Process groups are lifecycle supervision, not isolation or containment of descendants that leave the group. -Adapters run native tools unattended with the launching user's permissions, and a Harness never asks a human. Codex runs with approval policy `never`, under which it settles MCP elicitation itself without reaching the client, and with full access; under review `auto_review` every Turn instead sends `on-request` with the `auto_review` reviewer, whose denials stay inside Codex, and any request that still reaches the client gets JSON-RPC -32601, a denial; the adapter also disables its blocking `request_user_input` tool. Claude runs through the adapter's tool callback, which allows or denies without asking. MiniMax runs with bypassed permissions; the adapter disables `askUser`, advertises no elicitation and answers `session/request_permission` with the ACP `cancelled` outcome, which MiniMax treats as a denial. No native permission or question request reaches Core: a human in the loop goes through a function tool, the Session reads [`requires_action`](./sessions-events.md#session-status) and the application submits the function result. Do not add permission profiles, bubblewrap wrappers or native sandbox settings; there is one execution path for every Environment origin. Resource paths are operator configuration, not a permission boundary. +Adapters run native tools unattended with the launching user's permissions, and a Harness never asks a human. Codex runs with approval policy `never`, under which it settles MCP elicitation itself without reaching the client, and with full access; under review `auto_review` every Turn instead sends `on-request` with the `auto_review` reviewer, whose denials stay inside Codex, and any request that still reaches the client gets JSON-RPC -32601, a denial; the adapter also disables its blocking `request_user_input` tool. Claude runs through the adapter's tool callback, which allows or denies without asking; under `auto_review` it denies every request the native `auto` mode escalates to a person. MiniMax runs with bypassed permissions; the adapter disables `askUser`, advertises no elicitation and answers `session/request_permission` with the ACP `cancelled` outcome, which MiniMax treats as a denial. No native permission or question request reaches Core: a human in the loop goes through a function tool, the Session reads [`requires_action`](./sessions-events.md#session-status) and the application submits the function result. Do not add permission profiles, bubblewrap wrappers or native sandbox settings; there is one execution path for every Environment origin. Resource paths are operator configuration, not a permission boundary. An unreachable model host must not hold a Turn open. The adapter keeps the Harness's own connection retries bounded through native settings, and the Harness's final connection error becomes `connection_failed` when the Harness reports it as structured data ([native failure classification](../../docs/runtime-protocol.md#native-failure-classification)). Core adds no model timeout of its own. Each adapter guide states its Harness's bound. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index e825ec100..7fe863379 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -209,6 +209,7 @@ Each item is Core's deliberate or native behavior where the official service beh - [Conflict and rejection cases](./automations.md#state-changes). - [Target validation and saved references](./automations.md#targets). - [Paging](./automations.md): `limit` clamping and unresolved `after`. +- Whether a Claude Code Subagent from an earlier Turn, resumed by `SendMessage`, runs in the current Turn's permission mode rather than the mode it started under: the native mode is session-wide, so the adapter assumes the current one. - The order of errors when one request has several faults, and error, default and payload-limit parity in general. - A Subagent's function call: Core makes it a required action of the root Turn that runs the child, with `turn_id` set to that Turn, and publishes the child's `function_call` Item once as `agent.session.turn.item.added` with the child Turn ID and a null `output_index`; a result may name either Turn. The Turn is `waiting` and the Session `requires_action` while root output may continue, and the `call_id` equals the `call_id` of the child's `function_call` Item. Claude Code publishes the child's Items when the root Turn settles, after the required action. - Whether the official service emits Environment `pending` and when: Core emits it when a hosted Environment's initialization starts, and the one official sample showed only `ready`, about 29 s after creation. diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md index 9f1bdb794..e93b7b6bd 100644 --- a/contracts/agents-api/zh/harness-capabilities.md +++ b/contracts/agents-api/zh/harness-capabilities.md @@ -1,7 +1,7 @@ --- title: "Harness 能力" source: contracts/agents-api/harness-capabilities.md -source_hash: aa70b282e3e6d03965bd92c82a2af983a4e3fed1e643cba7289eef6414e3a6ea +source_hash: c7b4dae594607c91790c8d7d5d346bff1f0f57cd75147ab520bcf016174b68d4 --- 本页列出每个 Harness 在每种部署位置支持的能力。Core 根据 `internal/harnessconfig//declaration.go` 中 Harness 的声明决定准入,运行 Session 的 Runtime 也必须声明操作。所链接契约定义各操作;[Harness 接入](harness-onboarding.md#qualify-the-adapter)说明资格验证方法。 @@ -29,7 +29,7 @@ source_hash: aa70b282e3e6d03965bd92c82a2af983a4e3fed1e643cba7289eef6414e3a6ea | `reasoning.summary` | `concise`、`detailed`、`auto`:已准入 | `auto`:已准入 | 已拒绝 | | 非 `auto` 的 `service_tier` | `default`、`flex`、`priority`、`fast`:已准入;`ultrafast` 已拒绝 | 已拒绝 | 已拒绝 | | 非 `medium` 的 `text.verbosity` | 已准入;由原生模型决定 | 已拒绝 | 已拒绝 | -| Session 更新 `agent.model`、`reasoning.effort`、`service_tier` 和 `review: auto_review` | 在下一个根 Turn 应用;`reasoning.effort` 不能重置为 null | 已拒绝 | 已拒绝 | +| Session 更新 `agent.model`、`reasoning.effort`(含 null)、`service_tier` 和 `review: auto_review` | 在下一个根 Turn 应用;`reasoning.effort` 不能重置为 null | 在下一个根 Turn 应用,但 `service_tier` 已拒绝 | 已拒绝 | | Session 更新 `agent.tools` | 已拒绝 | 已拒绝 | 已拒绝 | | `compaction.async: true`、`experimental.time_awareness: true`,以及 `automations` 和 `computer_use` 工具 | 已拒绝 | 已拒绝 | 已拒绝 | | [公开 token 用量](sessions-events.md) | 测量计数器 | Null | Null | diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 79ab2813d..3b081909d 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "Harness 协议" source: contracts/agents-api/harness-onboarding.md -source_hash: bfbacab5d0653177ea0c440454f85e5ac68dbea374d58e5119c00010665620d6 +source_hash: 27ab04dbb3aaf2792d54710372b370588d35db931af8f2e6b88a34d78030b7f9 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。 @@ -216,7 +216,7 @@ daemon 的 `clirunner` 让每个原生子进程在自己的 Unix 进程组中启 所属输出管道在主进程退出后仍可读取。消费者在调用 `Wait` 之前耗尽 stdout 和 stderr;`Wait` 会汇合缓存的进程结果并关闭读取器。`Done` 报告主进程回收和进程组清理信号;它不是原生执行回执,也不是历史已持久化的证据。SDK 适配器会结算每个 Turn,并在发布完成状态前耗尽其观察结果;Executor 关闭还会关闭 Query 并等待原生子进程。进程组用于生命周期监管,而不是隔离或遏制离开进程组的后代进程。 -适配器以启动用户的权限无人值守运行原生工具,Harness 从不询问人类。Codex 使用批准策略 `never` 和完全访问权限,在该策略下 Codex 自行处理 MCP elicitation,不会发给客户端;在 `auto_review` 下,每个 Turn 改为发送 `on-request` 和 `auto_review` 审查者,其拒绝留在 Codex 内部,仍到达客户端的请求会得到 JSON-RPC -32601,即拒绝;适配器还禁用其阻塞式 `request_user_input` 工具。Claude 通过适配器的工具回调运行,回调直接允许或拒绝、不会询问。MiniMax 绕过权限;适配器禁用 `askUser`、不声明 elicitation,并以 ACP `cancelled` 结果答复 `session/request_permission`,MiniMax 将其视为拒绝。原生权限或问题请求都不会到达 Core:需要人工介入时通过 function 工具完成,Session 读取 [`requires_action`](./sessions-events.md#session-status),由应用提交 function 结果。不要添加权限 profile、bubblewrap 包装器或原生 sandbox 设置;每种 Environment 来源都只有一条执行路径。资源路径属于操作员配置,而不是权限边界。 +适配器以启动用户的权限无人值守运行原生工具,Harness 从不询问人类。Codex 使用批准策略 `never` 和完全访问权限,在该策略下 Codex 自行处理 MCP elicitation,不会发给客户端;在 `auto_review` 下,每个 Turn 改为发送 `on-request` 和 `auto_review` 审查者,其拒绝留在 Codex 内部,仍到达客户端的请求会得到 JSON-RPC -32601,即拒绝;适配器还禁用其阻塞式 `request_user_input` 工具。Claude 通过适配器的工具回调运行,回调直接允许或拒绝、不会询问;`auto_review` 下,凡是原生 `auto` 模式升级给人的请求,回调一律拒绝。MiniMax 绕过权限;适配器禁用 `askUser`、不声明 elicitation,并以 ACP `cancelled` 结果答复 `session/request_permission`,MiniMax 将其视为拒绝。原生权限或问题请求都不会到达 Core:需要人工介入时通过 function 工具完成,Session 读取 [`requires_action`](./sessions-events.md#session-status),由应用提交 function 结果。不要添加权限 profile、bubblewrap 包装器或原生 sandbox 设置;每种 Environment 来源都只有一条执行路径。资源路径属于操作员配置,而不是权限边界。 模型主机不可达时不能让 Turn 一直挂起。适配器通过原生设置让 Harness 自身的连接重试保持有界;Harness 以结构化数据报告最终连接错误时,该错误归为 `connection_failed`([原生失败分类](../../../docs/zh/runtime-protocol.md#native-failure-classification))。Core 不另设模型超时。各适配器指南写明其 Harness 的上限。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 17e215621..cef69a49d 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 82331da66db2d0caf6f64c861a065948c1a1332a8306e99133dfc9af9a165e3d +source_hash: 9b3a9fc6f679b7ec35ca8a37978010c76d26d42e3822b4fb0ee42c3ccfdd5f79 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -212,6 +212,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - [冲突与拒绝的情形](automations.md#state-changes)。 - [目标校验与保存的引用](automations.md#targets)。 - [分页](automations.md):`limit` 截断与无法解析的 `after`。 +- Claude Code 中,由 `SendMessage` 恢复的早先 Turn 的 Subagent 是否按当前 Turn 的权限模式运行,而不是按其启动时的模式:原生模式作用于整个 Session,适配器假定其为当前模式。 - 当一个请求存在多项故障时的错误顺序,以及错误处理、默认值和载荷限制的总体一致性。 - Subagent 的函数调用:Core 将其作为运行该子级的根 Turn 的待执行操作,`turn_id` 为该 Turn,并把子级的 `function_call` Item 以 `agent.session.turn.item.added` 发布一次,`turn_id` 为子级 Turn ID,`output_index` 为 null;结果可以指定其中任一 Turn。该 Turn 为 `waiting`、Session 为 `requires_action`,根输出可继续,`call_id` 等于该子级 `function_call` Item 的 `call_id`。Claude Code 在根 Turn 收敛时才发布子级的 Item,晚于该待执行操作。 - 官方服务是否发出 Environment `pending` 以及何时发出:Core 在 hosted Environment 初始化开始时发出,而仅有的一个官方样本只显示了创建约 29 秒后的 `ready`。 diff --git a/internal/harnessconfig/claudesdk/declaration.go b/internal/harnessconfig/claudesdk/declaration.go index 032174f17..d75c6743e 100644 --- a/internal/harnessconfig/claudesdk/declaration.go +++ b/internal/harnessconfig/claudesdk/declaration.go @@ -69,10 +69,11 @@ var Declaration = harnessconfig.Declaration{ ReasoningSummaries: []string{"auto"}, ProgrammaticToolCalling: []bool{false}, TextVerbosities: []string{"medium"}, + Reviews: []string{"auto_review"}, }, Placements: []string{"none", "openai_hosted", "self_hosted"}, MCPOrigins: []string{"service", "environment"}, - SessionUpdates: harnessconfig.SessionUpdates{Model: proto.CapabilityUnsupported, ReasoningEffort: proto.CapabilityUnsupported, ReasoningEffortReset: proto.CapabilityUnsupported, ServiceTier: proto.CapabilityUnsupported}, + SessionUpdates: harnessconfig.SessionUpdates{Model: proto.CapabilitySupported, ReasoningEffort: proto.CapabilitySupported, ReasoningEffortReset: proto.CapabilitySupported, ServiceTier: proto.CapabilityUnsupported}, WhitespaceOnlyText: proto.CapabilitySupported, ValidateConfiguration: validateConfiguration, ValidateTools: validateTools, diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index 61b934f02..86a58ad6d 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -32,7 +32,7 @@ It also emits the neutral `output_message` start/completion snapshots and tags d ### Executor preparation and Turns -The private bridge accepts `executor_prepare` without model input. It freezes validated configuration and resume identity, checks required history, and retains one native process and SDK Query across Turns. Preparation requires initialization and acknowledgement of required hooks while the input iterator remains empty. An `executor_ready` receipt permits later `turn_start` messages containing only Turn identity and ordered input; configuration replacement and concurrent starts are rejected. Every Turn event carries its originating `turn_id`. Native Session identity and the tool inventory the profile requires are checked before `input_ready`. +The private bridge accepts `executor_prepare` without model input. It freezes validated configuration and resume identity, checks required history, and retains one native process and SDK Query across Turns. Preparation requires initialization and acknowledgement of required hooks while the input iterator remains empty. An `executor_ready` receipt permits later `turn_start` messages containing only Turn identity, ordered input and `settings`: the Turn's `model`, `effort` (null for the model's default) and `review`. Once the Turn is active and before it queues the input, the bridge applies the settings that differ from the last applied ones with `Query.setModel`, `applyFlagSettings({effortLevel})` and `setPermissionMode` (`default`, or `auto` for `auto_review`). Under `auto_review` the tool callback denies what the native classifier escalates to a person; configuration replacement and concurrent starts are rejected. Every Turn event carries its originating `turn_id`. Native Session identity and the tool inventory the profile requires are checked before `input_ready`. Each Turn ends with a result/error and `turn_settled`, independently of process exit. The outer input iterator remains open for later Turns. `turn_cancel` invokes the native interrupt control for that exact Turn. Unconfirmed input, native child work or queue state invalidates the Executor and requires close before replacement. EOF, owner signals and invalid control input close owned resources. Preparation may write native metadata and perform startup traffic; readiness does not prove provider authentication, complete sandbox health or placement authorization. diff --git a/packages/claude-sdk-adapter/fixtures/bridge-frames.json b/packages/claude-sdk-adapter/fixtures/bridge-frames.json index c4b645c96..c0d89aeb9 100644 --- a/packages/claude-sdk-adapter/fixtures/bridge-frames.json +++ b/packages/claude-sdk-adapter/fixtures/bridge-frames.json @@ -50,13 +50,13 @@ "env_names": ["ANTHROPIC_API_KEY"] } }, - { "type": "turn_start", "turn_id": "run-1", "input": [{ "content": [{ "type": "input_text", "text": "Hello" }] }] }, + { "type": "turn_start", "turn_id": "run-1", "input": [{ "content": [{ "type": "input_text", "text": "Hello" }] }], "settings": { "model": "claude-fixture", "effort": "high", "review": "auto_review" } }, { "type": "turn_cancel", "turn_id": "run-1" }, { "type": "steer", "turn_id": "run-1", "input_id": "input-1", "input": [{ "content": [{ "type": "input_text", "text": "Also this" }] }] }, { "type": "function_result", "turn_id": "run-1", "call_id": "call-1", "delivery_id": "delivery-1", "success": true, "content": [{ "type": "input_text", "text": "42" }] } ], "from_bridge": [ - { "type": "executor_ready", "protocol": 3 }, + { "type": "executor_ready", "protocol": 4 }, { "type": "turn_started", "turn_id": "run-1" }, { "type": "turn_settled", "turn_id": "run-1", "confirmed": true, "reusable": false, "reason": "native_error" }, { "type": "input_ready", "turn_id": "run-1", "session_id": "native-session" }, diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index 142c41c51..3f7cee958 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -143,7 +143,8 @@ export async function execute(request: ExecutorPrepare, ownerEmit: (event: Event matcher.hooks=matcher.hooks.map(hook=>(...args)=>turns.track(()=>hook(...args))); } const canUseTool=options.canUseTool; - if(canUseTool) options.canUseTool=(...args)=>turns.track(()=>canUseTool(...args)); + // Under auto_review the native classifier decides; whatever it escalates to a person is denied. + if(canUseTool) options.canUseTool=(...args)=>turns.track(async()=>turns.review==="auto_review" ? {behavior:"deny" as const,message:"Manual approval is unavailable under automatic review."} : canUseTool(...args)); warm = await startup({ options, initializeTimeoutMs: 15000 }); stream = warm.query(turns); const initialized = await stream.initializationResult(); @@ -166,8 +167,8 @@ export async function execute(request: ExecutorPrepare, ownerEmit: (event: Event },async value=>{ if(value.type==="steer") for(const event of inputs.submit(value)) await emit(event); else functions.submit(JSON.stringify(value)); - }); - await ownerEmit({type:"executor_ready",protocol:3}); + },{model:request.model,effort:request.native_model_options?.effort??null,review:""}); + await ownerEmit({type:"executor_ready",protocol:4}); for await (const message of stream) { // Native command queue telemetry can follow a result. It is not model // output or a consumed-input receipt, and has no authority over a Turn. diff --git a/packages/claude-sdk-adapter/src/executor_protocol.ts b/packages/claude-sdk-adapter/src/executor_protocol.ts index b8e715adf..f6e250213 100644 --- a/packages/claude-sdk-adapter/src/executor_protocol.ts +++ b/packages/claude-sdk-adapter/src/executor_protocol.ts @@ -1,12 +1,13 @@ -import type { Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { EffortLevel, Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { Inputs } from "./inputs.js"; import { parseMessageInput, type MessageInput } from "./message_input.js"; export type ExecutorEvent = - | { type: "executor_ready"; protocol: 3 } + | { type: "executor_ready"; protocol: 4 } | { type: "turn_started"; turn_id: string } | { type: "turn_settled"; turn_id: string; confirmed: boolean; reusable: boolean; reason: string }; type WireEvent = { type: string; [key: string]: unknown }; +export type TurnSettings = { model: string; effort: string | null; review: string }; type Turn = { id: string; inputs: Inputs; cancelled: boolean; interrupt?: Promise; callbacks: Set> }; // The SDK sees one iterator for the Executor lifetime. Each yielded batch belongs @@ -21,9 +22,12 @@ export class ExecutorTurns implements AsyncIterable { private begin?: (input: MessageInput, emit: (event: WireEvent) => Promise) => Inputs; private submitInput?: (value: Record) => Promise; private nativeID = ""; + private applied: TurnSettings = { model: "", effort: null, review: "" }; constructor(private readonly output: (event: WireEvent) => Promise, private readonly abort: AbortController) {} - configure(stream: Query, begin: NonNullable, submit: (value: Record)=>Promise): void { this.stream = stream; this.begin = begin; this.submitInput=submit; } + // `applied` is what the native Query was started with; each Turn applies only what differs. + configure(stream: Query, begin: NonNullable, submit: (value: Record)=>Promise, applied: TurnSettings): void { this.stream = stream; this.begin = begin; this.submitInput=submit; this.applied = applied; } + get review(): string { return this.applied.review; } async submit(value: Record): Promise { const payload=this.assertCurrent(value); if(!this.submitInput || (payload.type!=="steer" && payload.type!=="function_result")) throw new Error("invalid turn input"); @@ -40,8 +44,10 @@ export class ExecutorTurns implements AsyncIterable { async start(value: Record): Promise { const id = value.turn_id; if (this.ended || this.active || !this.stream || !this.begin || typeof id !== "string" || !id.trim() || id.length > 256 || - this.used.has(id) || Object.keys(value).some(key => !["type", "turn_id", "input"].includes(key))) throw new Error("invalid turn start"); + this.used.has(id) || Object.keys(value).some(key => !["type", "turn_id", "input", "settings"].includes(key))) throw new Error("invalid turn start"); const input = parseMessageInput(value.input); + const next = value.settings as TurnSettings; + if (!next || typeof next.model !== "string" || !(next.effort === null || typeof next.effort === "string") || typeof next.review !== "string") throw new Error("invalid turn start"); const output = async (event: WireEvent) => { if (this.active?.id !== id) throw new Error("late turn event"); await this.output({ ...event, turn_id: id }); @@ -50,6 +56,13 @@ export class ExecutorTurns implements AsyncIterable { this.active = { id, inputs, cancelled: false, callbacks: new Set() }; this.used.add(id); await this.output({ type: "turn_started", turn_id: id }); + // Native answers to these requests are Turn events, so the Turn is active first; the input is not queued yet. + if (next.model !== this.applied.model) await this.stream.setModel(next.model || undefined); + this.applied.model = next.model; + if (next.effort !== this.applied.effort) await this.stream.applyFlagSettings({ effortLevel: next.effort as EffortLevel | null }); + this.applied.effort = next.effort; + if (next.review !== this.applied.review) await this.stream.setPermissionMode(next.review ? "auto" : "default"); + this.applied.review = next.review; if (this.nativeID) for (const event of inputs.start(this.nativeID)) await output(event); this.queue.push(inputs); this.wake?.(); diff --git a/packages/claude-sdk-adapter/tests/bridge_frames.test.mjs b/packages/claude-sdk-adapter/tests/bridge_frames.test.mjs index 259de9e67..2a4ab718b 100644 --- a/packages/claude-sdk-adapter/tests/bridge_frames.test.mjs +++ b/packages/claude-sdk-adapter/tests/bridge_frames.test.mjs @@ -16,7 +16,7 @@ test("the bridge accepts every frame Go sends", async () => { turns.configure({ interrupt: async () => ({ still_queued: [] }) }, input => (inputs = new Inputs(input)), async payload => { if (payload.type === "steer") events.push(...inputs.submit(payload)); else functions.submit(JSON.stringify(payload)); - }); + }, { model: "claude-fixture", effort: "high", review: "auto_review" }); const [start, cancel, steer, result] = ["turn_start", "turn_cancel", "steer", "function_result"].map(type => frames.to_bridge.find(frame => frame.type === type)); await turns.start(start); events.push(...inputs.start("native-session")); diff --git a/packages/claude-sdk-adapter/tests/executor.test.mjs b/packages/claude-sdk-adapter/tests/executor.test.mjs index 199aebe5c..02d6f5eca 100644 --- a/packages/claude-sdk-adapter/tests/executor.test.mjs +++ b/packages/claude-sdk-adapter/tests/executor.test.mjs @@ -89,7 +89,7 @@ async function launch(t,mode="normal") { t.after(async()=>{child.kill("SIGKILL");await closed}); const wait=async predicate=>{const until=Date.now()+5000;while(!predicate()){assert.ok(Date.now()setTimeout(resolve,5))}}; const send=value=>child.stdin.write(JSON.stringify(value)+"\n"); - const start=(id,text)=>send({type:"turn_start",turn_id:id,input:[{content:[{type:"input_text",text}]}]}); + const start=(id,text)=>send({type:"turn_start",turn_id:id,input:[{content:[{type:"input_text",text}]}],settings:{model:"fixture",effort:null,review:""}}); send({type:"executor_prepare",cwd:"/tmp",model:"fixture",system_prompt:"", ...(mode==="features" || mode==="later-function" || mode.startsWith("pending-function") ? {functions:[{name:"lookup",description:"lookup",parameters:{type:"object",properties:{text:{type:"string"}}}}]} : {})}); await wait(()=>events.some(event=>event.type==="executor_ready")); diff --git a/packages/claude-sdk-adapter/tests/executor_run.mjs b/packages/claude-sdk-adapter/tests/executor_run.mjs index 592866583..78fbfc661 100644 --- a/packages/claude-sdk-adapter/tests/executor_run.mjs +++ b/packages/claude-sdk-adapter/tests/executor_run.mjs @@ -13,13 +13,13 @@ export function startupFrom(queryFixture, { exitCode = 0, args } = {}) { const close = () => child.stdin.end(); return { close, query: prompt => Object.assign(queryFixture({ prompt, options }), { close, initializationResult: async () => ({ hooks_applied: true }), mcpServerStatus: async () => [], - interrupt: async () => ({ still_queued: [] }) }) }; + interrupt: async () => ({ still_queued: [] }), setPermissionMode: async () => {} }) }; }; } // Runs one Turn through execute(). Returns the frames in order, without the lifecycle frames of readiness and settlement. // onFrame may call turns.submit() or globalThis.cancelTurn() while the Turn runs. -export async function runTurn(request, abort, input, onFrame = async () => {}) { +export async function runTurn(request, abort, input, onFrame = async () => {}, review = "") { const { execute } = await import("../dist/adapter.js"); const { ExecutorTurns } = await import("../dist/executor_protocol.js"); const frames = []; @@ -37,7 +37,7 @@ export async function runTurn(request, abort, input, onFrame = async () => {}) { const done = execute(request, async frame => { assertFrame(frame); if (frame.type === "executor_ready") ready(); else frames.push(frame); }, abort, turns).finally(() => { finished = true; }); await Promise.race([readied, done]); if (finished) return { frames, settled }; - await turns.start({ type: "turn_start", turn_id: "run", input }); + await turns.start({ type: "turn_start", turn_id: "run", input, settings: { model: request.model, effort: request.native_model_options?.effort ?? null, review } }); await done; return { frames, settled }; } diff --git a/packages/claude-sdk-adapter/tests/mcp_required.test.mjs b/packages/claude-sdk-adapter/tests/mcp_required.test.mjs index fac4eae96..67518ac53 100644 --- a/packages/claude-sdk-adapter/tests/mcp_required.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_required.test.mjs @@ -99,7 +99,7 @@ for (const mode of ["connected", "pending", "failed", "missing", "duplicate", "m if (ready) { const until = Date.now() + 5000; while (!stdout.includes("executor_ready") && Date.now() < until) await new Promise(resolve => setTimeout(resolve, 10)); - child.stdin.write(JSON.stringify({ type: "turn_start", turn_id: "run", input: [{ content: [{ type: "input_text", text: "one input" }] }] }) + "\n"); + child.stdin.write(JSON.stringify({ type: "turn_start", turn_id: "run", input: [{ content: [{ type: "input_text", text: "one input" }] }], settings: { model: "fixed", effort: null, review: "" } }) + "\n"); while (!stdout.includes("turn_settled") && Date.now() < until) await new Promise(resolve => setTimeout(resolve, 10)); child.stdin.end(); } diff --git a/packages/claude-sdk-adapter/tests/preparation.test.mjs b/packages/claude-sdk-adapter/tests/preparation.test.mjs index c1c49f85a..9ea418a9d 100644 --- a/packages/claude-sdk-adapter/tests/preparation.test.mjs +++ b/packages/claude-sdk-adapter/tests/preparation.test.mjs @@ -126,7 +126,7 @@ async function launch(t, mode) { } finally { clearTimeout(timer); notify = () => {}; } }; const send = value => child.stdin.write(JSON.stringify(value) + "\n"); - const turn = text => send({ type: "turn_start", turn_id: "run", input: [{ content: [{ type: "input_text", text }] }] }); + const turn = text => send({ type: "turn_start", turn_id: "run", input: [{ content: [{ type: "input_text", text }] }], settings: { model: "fixed-model", effort: null, review: "" } }); const ready = () => wait(() => events.some(event => event.type === "executor_ready")); const finish = async code => { await wait(() => events.some(event => event.type === "turn_settled")); diff --git a/packages/claude-sdk-adapter/tests/turn_settings.test.mjs b/packages/claude-sdk-adapter/tests/turn_settings.test.mjs new file mode 100644 index 000000000..38b0f1cf3 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/turn_settings.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { ExecutorTurns } from "../dist/executor_protocol.js"; +import { Inputs } from "../dist/inputs.js"; + +test("a Turn applies only the settings that changed", async () => { + const calls = []; + const stream = { setModel: async model => calls.push(["model", model]), setPermissionMode: async mode => calls.push(["mode", mode]), + applyFlagSettings: async settings => calls.push(["flags", settings]) }; + const turns = new ExecutorTurns(async () => {}, new AbortController()); + turns.configure(stream, input => { calls.push(["input"]); return new Inputs(input); }, async () => {}, { model: "a", effort: "high", review: "" }); + const input = [{ content: [{ type: "input_text", text: "hi" }] }]; + const run = async (id, settings) => { await turns.start({ type: "turn_start", turn_id: id, input, settings }); await turns.settled(true, true, ""); }; + await run("one", { model: "a", effort: "high", review: "" }); + assert.deepEqual(calls, [["input"]]); + calls.length = 0; + await run("two", { model: "b", effort: null, review: "auto_review" }); + assert.deepEqual(calls, [["input"], ["model", "b"], ["flags", { effortLevel: null }], ["mode", "auto"]]); + calls.length = 0; + await run("three", { model: "b", effort: "max", review: "" }); + assert.deepEqual(calls, [["input"], ["flags", { effortLevel: "max" }], ["mode", "default"]]); +}); + +const fixture = ` +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { sdkSource, startupFrom, runTurn } from ${JSON.stringify(new URL("./executor_run.mjs", import.meta.url).href)}; +registerHooks({ resolve(specifier, context, next) { + if (specifier === "@anthropic-ai/claude-agent-sdk") return {url:"data:text/javascript,"+encodeURIComponent(sdkSource),shortCircuit:true}; + return next(specifier,context); +}}); +const root = realpathSync(mkdtempSync(join(tmpdir(), "oac-review-"))); +const dirs = Object.fromEntries(["workspace", "home", "state", "scratch"].map(name => { const path = join(root, name); mkdirSync(path); return [name, path]; })); +process.env.HOME = dirs.home; process.env.CLAUDE_CONFIG_DIR = dirs.state; process.env.ANTHROPIC_API_KEY = "fixture"; +const request = { type: "executor_prepare", model: "fixture", system_prompt: "", cwd: dirs.workspace, + workspace: { home: dirs.home, state: dirs.state, scratch: dirs.scratch, env_names: ["ANTHROPIC_API_KEY"] } }; +const review = process.argv[1]; +let canUseTool, answer; +globalThis.startupFixture = startupFrom(({prompt, options}) => { + canUseTool = options.canUseTool; + return { async *[Symbol.asyncIterator]() { + const first = (await prompt[Symbol.asyncIterator]().next()).value; + yield { type: "system", subtype: "init", session_id: "native", mcp_servers: [], tools: ["Bash", "Read", "Edit"], skills: [] }; + yield { type: "result", uuid: "result", session_id: "native", user_message_uuids: [first.uuid], subtype: "success", is_error: false, result: "ok", usage: {input_tokens:1,output_tokens:1}, modelUsage:{} }; + } }; +}); +try { + await runTurn(request, new AbortController(), [{ content: [{ type: "input_text", text: "hi" }] }], async frame => { + if (frame.type === "usage") answer = await canUseTool("Bash", { command: "ls" }, { signal: new AbortController().signal }); + }, review); + assert.equal(answer.behavior, review ? "deny" : "allow"); +} finally { rmSync(root, { recursive: true, force: true }); } +`; + +for (const review of ["", "auto_review"]) { + test(`the permission callback under review ${review || "none"}`, { timeout: 15000 }, () => { + const child = spawnSync(process.execPath, ["--input-type=module", "-e", fixture, review], { encoding: "utf8", timeout: 10000 }); + assert.equal(child.status, 0, child.stderr || child.error?.message); + }); +} diff --git a/services/core/deploy/claude/README.md b/services/core/deploy/claude/README.md index a502d7970..b00a32753 100644 --- a/services/core/deploy/claude/README.md +++ b/services/core/deploy/claude/README.md @@ -2,6 +2,8 @@ The Claude adapter runs Claude Code through the pinned Claude Agent SDK. The SDK owns the model and tool loop. Two parts make up the adapter: the private TypeScript bridge in [`packages/claude-sdk-adapter`](../../../../packages/claude-sdk-adapter/README.md), which owns the bridge protocol and native SDK configuration, and the Go adapter in [`agent/claudesdk`](../../../../apps/daemon/internal/agent/claudesdk), which owns the bridge process. This page holds the Runtime-level rules and the Claude Runtime image. [Harness protocol](../../../../contracts/agents-api/harness-onboarding.md) owns the obligations shared by all adapters. +A retained Executor serves Turns whose model, reasoning effort or review differ: the bridge applies the Turn's values natively before its input ([bridge protocol](../../../../packages/claude-sdk-adapter/README.md)). `review: auto_review` switches the native permission mode to `auto`, and the tool callback denies every request the classifier escalates to a person. + Native tools run with the daemon user's permissions; the outer sandbox provides isolation ([Runtime and outer isolation](../../../../docs/concepts.md#runtime-and-outer-isolation)). ## Native pin and readiness