diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index e29006b5..e4c7143b 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -155,11 +155,10 @@ workspace;除非 Pod 重建、Codex supervisor 重启、用户显式清空对 可用时优先使用;server 内存 session 因 Pod/replica 切换丢失时,只要本地 session 未过期, 刷新页面不得要求用户重新登录。 -当后端使用 AgentRun v0.1 短连接 runner 时,Web 仍可把历史 `threadId` 作为会话连续性元数据 -提交,但 HWLAB adapter 不得把该历史 `threadId` 直接透传给新 runner 做 Codex app-server -`thread/resume`。AgentRun runner Job 是新执行面,旧 Web/Codex thread 可能没有对应 rollout; -adapter 应保留 `conversationId/sessionId` 和 redacted `requestedThreadId` 用于追踪,同时让新 -runner 建立自己的 app-server thread,避免 `no rollout found for thread id`。 +当后端使用 AgentRun v0.1 短连接 runner 时,Web 必须把当前 conversation 的 `threadId` 作为 +唯一会话连续性字段提交;HWLAB adapter 必须把它写入 AgentRun command `payload.threadId` +和 `SessionRef.threadId`,runner 只按该标准字段执行 Codex app-server `thread/resume`。resume 失败 +必须作为标准 `threadId` 路径上的结构化错误/trace 事件暴露。 恢复出的历史消息如果携带 `traceId`,前端必须先对 `/v1/agent/chat/result/` 做 终态归一,再回放 `/trace`。旧的 `running` 消息不能只因为 localStorage restore 被降级成 diff --git a/docs/reference/g14-gitops-cicd.md b/docs/reference/g14-gitops-cicd.md index 903571cd..279c6553 100644 --- a/docs/reference/g14-gitops-cicd.md +++ b/docs/reference/g14-gitops-cicd.md @@ -119,7 +119,7 @@ G14 Code Agent 通过同一个 repo-owned Codex app-server stdio runner 承载 | `codex-api` | 否 | `HWLAB_CODE_AGENT_CODEX_API_MODEL`,默认 `gpt-5.5` | `HWLAB_CODE_AGENT_CODEX_API_BASE_URL`,G14 默认应指向同 Pod `127.0.0.1` loopback forwarder,forwarder upstream 为 hyueapi | 独立 Codex/OpenAI-compatible Responses API 通道;不得依赖 DeepSeek bridge。 | | `minimax-m3` | 否 | `HWLAB_CODE_AGENT_MINIMAX_M3_MODEL`,默认 `MiniMax-M3` | 不由 HWLAB 配置;由 AgentRun `backendProfile=minimax-m3` 的 Secret/profile 承接 | HWLAB 只把该 profile 委托给 AgentRun v0.1,不在 HWLAB 新增 MiniMax API key、base URL、proxy 或 provider 实现。 | -`hwlab-cloud-api` 仍以 `HWLAB_CODE_AGENT_PROVIDER=codex-stdio` 运行。`HWLAB_CODE_AGENT_MODEL` 与 `HWLAB_CODE_AGENT_OPENAI_BASE_URL` 可作为 runtime-default 兜底,但前端默认 profile 是 `deepseek`,用户可以切到 `codex-api` 或 `minimax-m3`。Codex app-server 启动参数必须同时设置 provider base URL、provider `name`、`model` 和 `review_model`,否则 Codex/DeepSeek 链路可能在模型目录或 `/v1/responses` 阶段退化成 `model=None`。`minimax-m3` 是 AgentRun 委托 profile,HWLAB 只负责把用户选择透传为 AgentRun `backendProfile=minimax-m3`,不得为了该 profile 在 HWLAB 里重新读取 MiniMax Secret 或维护第二套 provider bridge。G14 cloud-api 的 `NO_PROXY/no_proxy` 必须包含 `hyueapi.com` 和 `.hyueapi.com`;`codex-api` 的 hyueapi upstream 由同 Pod loopback forwarder 直连,不能被 proxy 注入污染。 +`hwlab-cloud-api` 仍以 `HWLAB_CODE_AGENT_PROVIDER=codex-stdio` 运行。`HWLAB_CODE_AGENT_MODEL` 与 `HWLAB_CODE_AGENT_OPENAI_BASE_URL` 可作为 runtime-default 兜底,但前端默认 profile 是 `deepseek`,用户可以切到 `codex-api` 或 `minimax-m3`。Codex app-server 启动参数必须同时设置 provider base URL、provider `name`、`model` 和 `review_model`,否则 Codex/DeepSeek 链路可能在模型目录或 `/v1/responses` 阶段退化成 `model=None`。`minimax-m3` 是 AgentRun 委托 profile,HWLAB 只负责把用户选择透传为 AgentRun `backendProfile=minimax-m3`,不得为了该 profile 在 HWLAB 里重新读取 MiniMax Secret 或维护第二套 provider bridge。HWLAB 对外的 conversation/session/thread 合同不随 AgentRun backend 切换而改变;adapter 内部传给 AgentRun 的 `sessionRef.sessionId` 必须按 `backendProfile` 分域,`accessController` 持久化记录仍以 HWLAB 原始 session 归属为准,嵌套 `agentRun.sessionId` 保存 AgentRun scoped sessionRef。G14 cloud-api 的 `NO_PROXY/no_proxy` 必须包含 `hyueapi.com` 和 `.hyueapi.com`;`codex-api` 的 hyueapi upstream 由同 Pod loopback forwarder 直连,不能被 proxy 注入污染。 G14 `codex-api` 不得把 `http://172.26.26.227:17680/v1/responses` 作为默认 base URL;该地址只保留为 D601 legacy Code Queue runner 或历史 egress 对照线索。G14 上的 `codex-api` 应先进入同 Pod `127.0.0.1` loopback forwarder,再由 forwarder 直连 `hyueapi.com` / `.hyueapi.com`;这两个域名必须同时进入 `NO_PROXY` 和 `no_proxy`。DeepSeek profile 可以通过集群内 bridge/Moon Bridge 转换,`codex-api` 不能用 DeepSeek bridge 伪装通过,也不能因为默认 profile 切到 DeepSeek 而删除或覆盖 `codex-api` 的独立模型、base URL、auth 和最小闭环验证。 diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md index 1f396e20..d0eda426 100644 --- a/docs/reference/spec-device-pod.md +++ b/docs/reference/spec-device-pod.md @@ -252,14 +252,14 @@ manages: many devicePodId - code agent 不能通过修改本地文件改变 gateway session、resource、host workspace、probe UID 或 UART port。 - `hwlab-device-pod` 不接受无内部服务凭据的 profile snapshot 或 job 请求。 - `hwlab-device-pod` 一个实例可以列出并执行多个 `devicePodId` 的状态/job。 -- cloud-api compatibility fallback 只能返回 blocked authority payload,不得合成 fake device pod 数据或作为正式 device-pod DEV-LIVE 证据。 +- cloud-api legacy compatibility entry 只能返回 blocked authority payload,不得合成 fake device pod 数据或作为正式 device-pod DEV-LIVE 证据。 - 强副作用 job 必须有 reason,并在物理互斥需要时获取 `device_leases`。 - `POST /v1/device-pods/{devicePodId}/leases` 只对已授权 actor 创建或刷新互斥租约,响应只返回一次性 `leaseToken`;后续强副作用 job 必须通过 `leaseToken` 或 `x-hwlab-device-lease-token` 证明持有租约。 - 撤销 device pod grant 必须释放该用户对同一 `devicePodId` 的活动 lease。 ## CLI 实现口径 -`tools/device-pod-cli.ts` 是 v0.2 正式 CLI 实现;HWLAB code-agent runner 内的稳定短入口是 `hwpod`。`/app/skills/device-pod-cli/scripts/device-pod-cli.mjs` 只作为兼容启动器,在 runner PATH 缺少 `hwpod` 时临时 fallback,并应记录为 runner image/package 摩擦点。正式 CLI 的默认行为是: +`tools/device-pod-cli.ts` 是 v0.2 正式 CLI 实现;HWLAB code-agent runner 内的唯一稳定短入口是 `hwpod`。runner 镜像必须把 `hwpod` 放入 PATH;缺少 `hwpod` 时应判定为 runner image/package 错误并修复镜像或封装,不得改走 `/app/skills/device-pod-cli/scripts/device-pod-cli.mjs` 长路径。正式 CLI 的默认行为是: - `profile list/show` 调用 cloud-api `/v1/device-pods` 和 `/status`,只显示服务端脱敏 profile 摘要和 `profileHash`。 - `setup first-admin` 和 `admin device-pod upsert/grant` 只作为 cloud-api REST wrapper,用于首次空库 seed 或 admin profile/grant 管理;它们接受显式 `--profile-json` 或 `--device-pod-json`,不得读取本地 `.device-pod/*.json` 作为权威 profile。 diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index a2f0e361..17a51739 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -10,7 +10,7 @@ - 提供 WEB 等价的非视觉业务入口:登录鉴权、session 恢复、Device Pod 看板、Code Agent 对话、trace/result 轮询、logout 和工作台 live summary。 - 只走 Cloud Web 同源 API surface;默认 base URL 是 `http://74.48.78.17:19666`,也可通过 `--base-url` 或 `HWLAB_CLIENT_BASE_URL` 指向其他 Cloud Web 入口。 -- Web/CLI 路径一致性优先于继续 Web 修复。Cloud Web 暴露 Code Agent、AgentRun、continuation、trace/result 或 provider 问题后,必须先能用 `bun tools/hwlab-cli/bin/hwlab-cli.ts client agent send/result/trace/inspect --base-url http://74.48.78.17:19666 ...` 对同一 Cloud Web base URL、同一 `/v1/agent/chat`、同一 `conversationId/sessionId/threadId/retryOf` 复现或解释,再继续修 Web 状态机。`19667` Cloud API 只用于显式 admin/setup/gateway 诊断,不得替代 WEB 同源路径验收。 +- Web/CLI 路径一致性优先于继续 Web 修复。Cloud Web 暴露 Code Agent、AgentRun、continuation、steer、trace/result 或 provider 问题后,必须先能用 `bun tools/hwlab-cli/bin/hwlab-cli.ts client agent send/result/trace/inspect/steer --base-url http://74.48.78.17:19666 ...` 对同一 Cloud Web base URL、同一 `/v1/agent/chat*`、同一 `conversationId/sessionId/threadId/retryOf` 复现或解释,再继续修 Web 状态机。`19667` Cloud API 只用于显式 admin/setup/gateway 诊断,不得替代 WEB 同源路径验收。 - 从 Web trace 回放 Code Agent 问题时,优先用 `client agent send --from-trace ` 读取 Cloud Web 的 `/v1/agent/chat/inspect`,自动带回原 `conversationId/sessionId/threadId` 并把 `retryOf` 指向来源 trace;只有 inspect 缺失时才手动传 `--conversation-id`、`--session-id`、`--thread-id` 和 `--retry-of`。CLI 输出必须包含 replay 来源、inspect 状态和 redacted continuation 摘要。 - 默认业务子命令不直连 Postgres、Kubernetes Service、Secret、device-pod 内部 Service、gateway RPC 或本地 fixture;需要鉴权的请求使用 `/auth/*` 返回的 cookie 或显式 `--cookie`。唯一例外是 `client gateway` 诊断族:它允许显式 `--api-base-url` 指向 Cloud API,用于短连接观测 gateway session、单次 shell invoke 和 transport 压测;该入口只验证底层传输稳定性,不替代 Web 用户流程授权,也不发布镜像或常驻服务。 - Pod 内透传执行不放进 `hwlab-cli`;需要进入正在工作的 Code Agent/Cloud API pod 时,`hwlab-cli` 只查询并输出 UniDesk 标准 route,实际透传由 UniDesk `bun scripts/cli.ts ssh 'G14:k3s:hwlab-v02:pod::' ...` 完成。`pod:` 是 route 语法,`/` 只用于 pod 内文件系统路径。 @@ -24,7 +24,9 @@ - CLI 本地登录态必须支持 `--profile NAME` 隔离,同一 base URL 下不同 profile 写入 `.state/hwlab-cli/profiles//.json`。切换到其他账号再切回原账号时,`client workbench restore/status` 必须从服务端账号 workspace 恢复之前的 `workspaceId`、`conversationId`、`sessionId`、`threadId`、`activeTraceId` 和 revision,而不是只依赖本地文件。 - `client workbench restore/status/watch/reset` 是账号 workspace 的非视觉入口:`restore/status` 对应 `GET /v1/workbench/workspace`,`watch` 对应 `/events?afterRevision=`,`reset --confirm` 对应服务端 reset。输出必须显示 workspace revision、selected conversation/session、active trace 和本地 state file,且不得保存 password、session token 原文以外的 Secret 值。 - `client agent send` 是 Cloud Web Code Agent composer 的非视觉等价入口。它必须支持 `--from-trace`、`--conversation-id`、`--session-id`、`--thread-id` 和 `--retry-of`,并在输出中返回 redacted continuation 摘要,证明本次 CLI 请求是否覆盖 Web 的继续会话路径。浏览器 issue 中已经给出 traceId 时,复现命令优先使用 `--from-trace `,让 CLI 先走 `/v1/agent/chat/inspect` 读取 Web 上下文,再提交同源 `/v1/agent/chat`。 +- Code Agent continuation 的 thread 字段只有 `threadId` 一个标准名称。CLI 读取 inspect、`--from-trace` 回放、手动 `--thread-id` 提交和输出摘要都必须以该字段为唯一 thread identity;服务端响应也应保持同一字段口径。 - `client agent send` 默认先恢复账号 workspace,再向 `/v1/agent/chat` 发送 `workspaceId` 和 `expectedWorkspaceRevision`;服务端接受后 CLI 保存新的 workspace revision,终态轮询后再 PATCH workspace 清理终态 `activeTraceId`。只有显式 `--no-workspace` 才跳过这一默认恢复路径。 +- `client agent steer ` 是运行中引导入口,必须调用 Cloud Web 同源 `POST /v1/agent/chat/steer`,把 steer 文本装配成 AgentRun `type=steer` command 作用到目标 trace 的 active turn。CLI 不手动穿内部 URL;验收使用当前 runtime namespace/lane 自动解析的 `19666` Web 入口,并通过原 trace 的 result/trace 观察 steer 是否被 runner 接收和应用。 - `client agent trace --render web` 必须调用 Cloud Web trace row 的同一纯转换路径,输出 `render="web"`、renderer 标识、source event count、rendered row count、默认压制的 noise event count 和 row 摘要。浏览器 trace 展示错乱时,必须先用该 CLI 入口确认 Web 渲染转换是否已经乱序、重复、缺 final response、吞掉关键 row 或只显示泛化 tool call,再继续修浏览器 DOM/CSS。 - AgentRun v0.1 短连接 runner 已要求支持同 run/runner 多轮 command。CLI 仍应把 Web 提交的 `conversationId/sessionId/threadId` 原样送到 Cloud Web API,用于验证 adapter 是否在 runner lease 有效时复用同一个 AgentRun `runId` / `jobName` 并创建新 `commandId`;每轮都新建 runner 或重新 bundle 不是通过状态,trace 中的原因说明只能用于定位。 - `client harness`、`client harness-ops` 和 `client harness-opt` 吸收 G14 harness-ops 的短连接业务能力:health、submit、result、trace、wait 和 audit。 @@ -57,6 +59,7 @@ | `hwlab-cli client gateway pressure` | `POST Cloud API /v1/rpc/hardware.invoke.shell` | 显式 Cloud API 压测入口,真实验证大输出、长单行、stderr、timeout 和并发超容量不会造成黑洞。 | | `hwlab-cli client agent send` | `GET /v1/agent/chat/inspect` + `POST /v1/agent/chat` + `GET /result/{trace}` | 以 short connection 提交 Code Agent 消息并轮询结果,默认输出 assistant 回复文本摘要;支持 `--from-trace` 自动复现 Web continuation,也支持手动 `--conversation-id`、`--session-id`、`--thread-id`、`--retry-of`。 | | `hwlab-cli client agent trace TRACE [--render web]` | `GET /v1/agent/chat/trace/{trace}` | 回放 trace,默认输出状态、事件摘要和 assistant stream 文本;`--render web` 复用 Cloud Web trace row 转换,便于 CLI 复现 Web trace 渲染问题。 | +| `hwlab-cli client agent steer TRACE` | `POST /v1/agent/chat/steer` | 对目标 trace 的运行中 Code Agent turn 发送 steer 文本;默认短连接返回 accepted 和 steer command 摘要,后续观察原 trace 的 result/trace。 | | `hwlab-cli client agent cancel TRACE` | `POST /v1/agent/chat/cancel` | 取消当前 Code Agent 请求。 | | `hwlab-cli client harness submit` | `POST /v1/agent/chat` | G14 harness-ops 的短连接提交入口,默认 provider profile 为 `deepseek`,返回 trace/result URL;`harness-ops` 和 `harness-opt` 是同义别名。 | | `hwlab-cli client harness wait/result/trace` | `GET /v1/agent/chat/result/{trace}`、`GET /trace/{trace}` | 轮询或读取一次 Code Agent 结果和 trace;单次 wait 最长 60 秒。 | @@ -86,6 +89,10 @@ 阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:对一个来自 Cloud Web 的失败 trace 运行 `client agent send --from-trace --message "重试上一条" --provider-profile deepseek --no-wait`,确认 CLI 先访问 `/v1/agent/chat/inspect?traceId=`,再提交 `/v1/agent/chat`,输出包含 `replay.source`、`continuation.replayedFromTrace`、`conversationId/sessionId/threadId/retryOf`,且不输出 cookie、token 或 secret 原文。 +## T5.1 + +阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:复测取消后追问场景时,`client agent send/result/trace --render web` 输出只允许出现标准 `threadId`,不允许出现历史 thread 别名字段;第二轮必须是新 `commandId` 且 trace row 不包含上一 command 的尾部 assistant/tool/terminal 文本。 + ## T3A 阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:分别用 `--profile admin-a` 和 `--profile admin-a-second` 登录同一个账号,运行 `client workbench restore/status`,确认两个 profile 看到相同 `workspaceId` 和 revision;再用 `--profile other-user` 登录另一个账号,确认 workspace 不同;最后切回 `admin-a`,确认原 workspace、conversation/session/thread 仍能恢复。该验收必须使用真实 `http://74.48.78.17:19666`,不能 mock。 @@ -94,6 +101,10 @@ 阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:对一个来自 Cloud Web 的真实 trace 运行 `client agent trace --render web --limit 80`,确认输出 `renderer=web/hwlab-cloud-web/app-trace:traceDisplayRows`、source event count、rendered row count 和 row 摘要;如果 Web trace 展示错乱,应先用该命令复现并定位到 Web row 转换还是浏览器 DOM/CSS 层。 +## T3.3 + +阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:先运行 `client agent send --message "执行一个会持续运行的任务,等待后续 steer" --provider-profile deepseek` 获得运行中 trace,再运行 `client agent steer --message "请把最终回复包含 STEER_ACCEPTED 标记"`,最后用 `client agent result ` 和 `client agent trace --render web` 确认同一 target trace 出现 AgentRun steer command 事件且最终回复或 trace 可见 steer 处理结果。该验收必须使用真实 `http://74.48.78.17:19666` 或 runtime namespace 自动解析出的同源 Web 入口,不能 mock,也不能用自动交互脚本。 + ## T4 阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:运行 `client request GET /v1/access/status`、`client request GET /v1/diagnostics/gate` 和 `client rpc system.health`,确认它们全部走 `19666` Cloud Web 同源 API,返回 JSON route/httpStatus/body,且 `client request` 传入绝对 URL 会被拒绝。 diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index c1bac533..e80625b1 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -16,7 +16,9 @@ - `internal/cloud/access-control.ts` 也是账号 workspace authority:`account_workspaces` 记录同一账号的 Workbench 当前 workspace、selected conversation/session、active trace、provider profile 和 revision。 - `internal/db/runtime-store.ts` 和 `internal/cloud/db-contract.ts` 负责 Postgres runtime store 与 readiness 分层。 - `internal/cloud/code-agent-*.ts` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。 -- Cloud API 通过 AgentRun v0.1 `runner-jobs.transientEnv` 传递本次 Code Agent turn 的短期上下文,例如 `HWLAB_RUNTIME_*`、`HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME` 和 device-pod session token。`transientEnv` 不设固定 8 项上限,新增短期上下文时必须按 name 去重、只传本次 Job 需要的 value,并继续禁止承载 GitHub token、provider key、长期 SSH key 或其他可复用 credential。 +- AgentRun v0.1 接入只使用标准 `threadId` 路径:`POST /v1/agent/chat` 收到的 `conversationId/sessionId/threadId` 必须写入 AgentRun command `payload.threadId` 和 `SessionRef.threadId`;协议字段、trace、result 和 conversation facts 都以该字段为唯一 thread identity。 +- AgentRun run 级 events 写回 HWLAB trace 时必须按当前 `commandId` 归属过滤;同一 run 的旧 command 尾部事件不能混入后续 command trace。取消、失败或 blocked 轮次如果已有 assistant/tool 可读上下文,必须以脱敏、限长的 partial context 写入 conversation facts,供后续 `inspect`/`--from-trace` 和同一 thread 的下一轮使用。 +- Cloud API 通过 AgentRun v0.1 `runner-jobs.transientEnv` 传递本次 Code Agent turn 的短期上下文,例如 `HWLAB_RUNTIME_*`、`HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME` 和 device-pod session token。`transientEnv` 不设固定 8 项上限,新增短期上下文时必须按 name 去重、只传本次 Job 需要的 value,并继续禁止承载 GitHub token、provider key、长期 SSH key 或其他可复用 credential;文档、日志和 trace 只允许保留脱敏后的 name、来源或摘要,不打印 Secret 值。 - 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。 - `hwlab-code-agent-workspace` PVC 挂载到 `/workspace/hwlab`,用于长会话 workspace;它是 cloud-api 运行资源,不是独立用户入口。 @@ -38,7 +40,7 @@ | `GET /v1/diagnostics/gate`、`GET /v1/live-builds` | 诊断和 live build inventory。 | | `GET /v1/gateway/sessions`、`POST /v1/gateway/poll`、`POST /v1/gateway/result` | gateway 主动出站注册、取任务和回传结果。 | | `POST /v1/internal/device-pod/gateway-dispatch` | 仅接受 `hwlab-device-pod` 内部服务凭据,用于把 executor job dispatch 到 gateway poll/result;普通用户和 Code Agent 不可调用。 | -| `POST /v1/agent/chat`、`GET /v1/agent/chat/result/{traceId}`、`GET /v1/agent/chat/trace/{traceId}`、`POST /v1/agent/chat/cancel` | Code Agent 短连接提交、轮询、trace 和取消。 | +| `POST /v1/agent/chat`、`POST /v1/agent/chat/steer`、`GET /v1/agent/chat/result/{traceId}`、`GET /v1/agent/chat/trace/{traceId}`、`POST /v1/agent/chat/cancel` | Code Agent 短连接提交、运行中 steer、轮询、trace 和取消。 | 用户、权限、device-pod 管理 API 的最终规格见 [spec-user-access.md](spec-user-access.md) 和 [spec-device-pod.md](spec-device-pod.md)。 @@ -50,7 +52,11 @@ ## T2 -阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:使用短连接 `POST /v1/agent/chat` 提交一次对话,再轮询 `/v1/agent/chat/result/{traceId}`,只有 `status=completed` 且 assistant reply 非空才算 Code Agent 通过。 +阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:使用短连接 `POST /v1/agent/chat` 提交一次对话,再轮询 `/v1/agent/chat/result/{traceId}`,只有 `status=completed` 且 assistant reply 非空才算 Code Agent 通过;对运行中请求使用 `POST /v1/agent/chat/steer` 时,响应必须包含 target trace、steerTraceId、AgentRun runId/targetCommandId/steerCommandId 和继续观察原 trace 的 result/trace URL。 + +## T2.1 + +阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:在同一 `conversationId/sessionId/threadId` 中先发起一轮会产生可见 assistant/tool 进展的 Code Agent 请求并取消,再发送“回答一下刚才调查结果”这类后续消息;第二轮必须继续使用同一个标准 `threadId`,且 trace/result 不能混入上一 command 的尾部事件。 ## T3 @@ -61,7 +67,7 @@ | 规格项 | 状态 | 说明 | | --- | --- | --- | | health/readiness | 已实现 | `/health/live` 汇总 DB、runtime、Code Agent 和 blocker。 | -| Code Agent 短连接 submit/result/trace/cancel | 已实现 | repo-owned Codex stdio 和 provider profile 已接入。 | +| Code Agent 短连接 submit/steer/result/trace/cancel | 已实现 | repo-owned Codex stdio、运行中 steer 和 provider profile 已接入。 | | Postgres durable runtime | 已实现 | 通过 v02 独立 DB SecretRef 和 migration ledger 判定。 | | gateway outbound poll/result | 已实现 | 支持 gateway 主动轮询和 `hardware.invoke.shell` 分发。 | | device-pod 正式权限/profile/job | 部分实现 | profile/grant/list/status/job 持久化在 cloud-api;用户态 probe GET 已收敛为只读 job;已提供内部 gateway dispatch route 供 `hwlab-device-pod` executor 下发到 device-host-cli,无在线 gateway/device-host-cli 时返回 blocker。 | diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index c77298df..357adea1 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -12,6 +12,8 @@ - Cloud Web trace 展示与 `hwlab-cli client agent trace --render web` 必须共享同一套 trace row 纯转换路径。Web 发生 row 顺序错乱、final response 缺失、assistant 消息被吞、tool call 只显示泛化占位或噪声事件淹没时,先用 CLI 输出同一渲染 row 摘要和 noise event count 复现;CLI 可复现说明是 trace row 转换问题,CLI 不可复现再进入 DOM/CSS/滚动状态调查。默认展示应压制 AgentRun backend echo、token/rate-limit/status/terminal echo 等低价值事件,但原始 trace JSON 仍必须保留用于 `--full`/下载排障。 - Code Agent result `completed` 只有在同时包含真实 provider/model/trace/conversation 元数据、`providerTrace` 和可展示的 final assistant response 时,才能被 Web 标记为真实完成;`provider=agentrun-v01` 只是执行基础设施标识,不得替代上游 provider/model,也不得把 SOURCE、fixture、echo、mock 或 stub 当成 DEV-LIVE 完成。 - 同一 conversation/session 的后续用户消息必须在 AgentRun runner lease 有效时复用已存在的 AgentRun run/runner 继续新 command/turn;只有 runner 不可用、已过期或协议明确要求新 runner 时才重新 bundle 和启动 runner。每条消息都重新 bundle/runner 属于 v0.2 AgentRun 接入缺口,不能只靠 trace 显示原因当成已完成。 +- AgentRun 会话连续性只有一个标准路径:Cloud Web/CLI 提交的 `threadId` 必须经 Cloud API adapter 写入 AgentRun command `payload.threadId` 和 `SessionRef.threadId`。前端、CLI、API 和 AgentRun 的协议字段、trace、result 和 conversation facts 都以该字段为唯一 thread identity。 +- 同一 AgentRun run 复用多条 command 时,Web trace 展示只显示当前 command 归属事件和必要 run 级状态;旧 command 的 assistant/tool/terminal 尾部不能堆到新 command 末尾。取消轮次的可读进展必须作为脱敏 partial context 进入 conversation facts 和标准 thread,而不是靠旧 trace 尾部串线让后续轮次“碰巧看到”。 ## 内部架构 @@ -29,7 +31,7 @@ | `GET /help` | 返回可用 route 摘要。 | | `GET /v1`、`GET /v1/...` | 同源代理到 `hwlab-cloud-api`;公开的 Code Agent result/trace 轮询按 route policy 处理。 | | `GET/PATCH /v1/workbench/workspace...` | 同源代理到 cloud-api 的账号 workspace authority,用于 Web/CLI 共享工作区和 revision 冲突保护。 | -| `POST /v1/agent/chat`、`POST /v1/agent/chat/cancel` | 同源代理到 cloud-api 的 Code Agent 入口。 | +| `POST /v1/agent/chat`、`POST /v1/agent/chat/steer`、`POST /v1/agent/chat/cancel` | 同源代理到 cloud-api 的 Code Agent 入口;steer 必须走同一个 `19666` Web path,由 cloud-api/AgentRun 判断目标 turn 是否可接收。 | | `POST /v1/device-pods/...` | 受控同源代理到 cloud-api 的 Device Pod job/操作入口;只要 Cloud API 已提供对应能力,Cloud Web 不能只代理 list/status 而让 job POST 在 `19666` 返回 404。 | | `POST /v1/m3/io`、`POST /json-rpc` | 同源代理到受控 API;不能绕过 cloud-api 直连硬件服务。 | @@ -51,6 +53,8 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验, 阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:从同源 `19666` 提交 Code Agent 短连接请求并轮询 result,确认请求经 cloud-web proxy 到 `hwlab-cloud-api`,且 trace 可回放。 +阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:对运行中 Code Agent trace 调用 `hwlab-cli client agent steer --message ...`,确认请求走 Cloud Web 同源 `POST /v1/agent/chat/steer`;Web 层不能返回 `serviceId=hwlab-cloud-web` 的 404,目标不存在、非运行中或 runner 拒绝时必须透传 cloud-api/AgentRun 的结构化业务状态。 + ## T2.1 阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:对浏览器暴露的 Code Agent trace 运行 `hwlab-cli client agent trace --render web --limit 80`,确认 CLI 与 Web 使用同一 trace row 转换;若 final response 缺失、assistant row 顺序错乱或噪声事件过多,先用 CLI 固定复现再修实现。 @@ -59,6 +63,10 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验, 阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:在同一 conversation/session 连续发送两条 Code Agent 消息,确认第二条复用第一条的 AgentRun `runId` 和 runner `jobName`、生成新的 `commandId`,且不重新 materialize bundle/启动新 runner;result completed 必须包含真实 provider/model/`providerTrace`/trace/conversation 和 final assistant response。复用失败原因只能作为诊断,不作为本测试通过条件。 +## T2.3 + +阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:对一轮已取消但取消前存在 assistant/tool 进展的 trace 发送同一 conversation/session/thread 后续问题,确认请求和 result 只携带标准 `threadId`,不出现历史 thread 别名字段;`--render web` 输出不得把上一 command 的尾部 assistant/tool/terminal row 堆到新 command 末尾。 + ## T3 阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:打开 Workbench device-pod 面板,确认 status/freshness/blocker 显示来自 `/v1/device-pods`,未登录或未授权时必须显示认证/授权 blocker,不得把 fixture 或 blocked fallback 写成真实硬件 DEV-LIVE。 diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 59aa7e14..de5b3cdd 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -95,9 +95,9 @@ test("workbench workspace is account-scoped, persistent, and gates active agent projectId: "prj_device_pod_workbench", sessionId: "ses_issue655_shared", threadId: "thread-issue-655", - sessionStatus: "active", + sessionStatus: "running", lastTraceId: "trc_issue655_previous", - messages: [{ role: "user", text: "persist me", traceId: "trc_issue655_previous" }] + messages: [{ role: "agent", text: "persist me", status: "running", traceId: "trc_issue655_active" }] }, aliceLogin.cookie); assert.equal(conversation.status, 200); assert.equal(conversation.body.conversation.conversationId, "cnv_issue655_shared"); @@ -467,6 +467,86 @@ test("workbench workspace releases orphan cancel-blocked active trace on new tur } }); +test("workbench workspace permits continuation when stale active trace has idle active conversation", async () => { + const workspaceDir = await mkdtemp("/tmp/hwlab-stale-active-idle-"); + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspaceDir, + HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", + OPENAI_API_KEY: "test-openai-key-material" + }, + codexStdioManager: { + describe: () => ({ available: true, ready: true, workspace: workspaceDir, sandbox: "danger-full-access" }), + probe: async () => ({ available: true, ready: true, workspace: workspaceDir, sandbox: "danger-full-access" }), + chat: async (params = {}) => ({ + status: "completed", + traceId: params.traceId, + conversationId: params.conversationId, + sessionId: params.sessionId, + threadId: params.threadId, + reply: { role: "assistant", content: "ok" }, + runnerTrace: { traceId: params.traceId, status: "completed", events: [], eventCount: 0 }, + session: { sessionId: params.sessionId, conversationId: params.conversationId, threadId: params.threadId, status: "idle" } + }), + cancel() {}, + reapIdle() {} + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", adminLogin.cookie); + const conversation = await putJson(port, "/v1/agent/conversations/cnv_stale_active_idle", { + projectId: "prj_device_pod_workbench", + sessionId: "ses_stale_active_idle", + threadId: "thread-stale-active-idle", + status: "active", + sessionStatus: "active", + lastTraceId: "trc_stale_active_idle_missing", + messages: [{ role: "agent", text: "previous completed turn", status: "completed", traceId: "trc_stale_active_idle_missing" }] + }, adminLogin.cookie); + assert.equal(conversation.status, 200); + + const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { + expectedRevision: 1, + selectedConversationId: "cnv_stale_active_idle", + selectedAgentSessionId: "ses_stale_active_idle", + activeTraceId: "trc_stale_active_idle_missing", + sessionStatus: "running", + updatedByClient: "test-suite" + }, adminLogin.cookie); + assert.equal(update.status, 200); + + const next = await postJson(port, "/v1/agent/chat", { + message: "new turn after stale active idle trace", + traceId: "trc_stale_active_idle_next", + conversationId: "cnv_stale_active_idle", + sessionId: "ses_stale_active_idle", + threadId: "thread-stale-active-idle", + workspaceId: workspace.body.workspace.workspaceId, + expectedWorkspaceRevision: 2, + shortConnection: true + }, adminLogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_stale_active_idle_next" }); + + assert.equal(next.status, 202); + assert.equal(next.body.traceId, "trc_stale_active_idle_next"); + assert.equal(next.body.conversationId, "cnv_stale_active_idle"); + assert.equal(next.body.sessionId, "ses_stale_active_idle"); + + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", adminLogin.cookie); + assert.equal(restored.body.workspace.activeTraceId, "trc_stale_active_idle_next"); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("workbench workspace status clears completed AgentRun active trace on read", async () => { const agentRunCalls = []; const agentSessions = new Map(); diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index c5b911ac..8d3c824d 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -33,6 +33,9 @@ const AGENTRUN_BACKENDS = Object.freeze(["codex", "deepseek", "minimax-m3"]); const THREAD_CONTINUITY_POLICY = "hwlab-agentrun-v01-reuse-runner-thread"; const SESSION_POLICY_RUN_LOCAL = "hwlab-agentrun-v01-session-runner-reuse"; const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "blocked", "cancelled", "canceled"]); +const HWLAB_RESOURCE_TOOL_ALIASES = Object.freeze([ + Object.freeze({ name: "hwpod", path: "tools/device-pod-cli.mjs", kind: "node-script" }) +]); export function codeAgentAgentRunAdapterEnabled(env = process.env) { const value = String(env.HWLAB_CODE_AGENT_ADAPTER ?? env.HWLAB_CODE_AGENT_PROVIDER ?? "").trim().toLowerCase(); @@ -61,7 +64,6 @@ export function initialAgentRunChatResult({ params = {}, options = {}, traceId } capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, sessionMode: CODEX_STDIO_SESSION_MODE, implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, - requestedThreadId: safeOpaqueId(params.threadId) || null, threadContinuityPolicy: THREAD_CONTINUITY_POLICY, agentRun: { adapter: ADAPTER_ID, @@ -148,7 +150,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI } } - const sessionId = agentRunSessionIdForParams(params, traceId); + const sessionId = scopedAgentRunSessionIdForParams(params, traceId, backendProfile); const runInput = buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, sessionId }); const run = await agentRunJson(fetchImpl, managerUrl, "/api/v1/runs", { method: "POST", @@ -312,6 +314,87 @@ export async function cancelAgentRunChatTurn({ traceId, currentResult = null, op return payload; } +export async function steerAgentRunChatTurn({ traceId, currentResult = null, params = {}, options = {}, traceStore = defaultCodeAgentTraceStore }) { + const mapped = currentResult ?? await loadPersistedAgentRunResult(traceId, options); + if (!safeTraceId(traceId) || !mapped?.agentRun?.runId || !mapped?.agentRun?.commandId) return null; + const message = firstNonEmpty(params.message, params.prompt, params.text); + if (!message) { + throw Object.assign(new Error("steer command requires non-empty message text"), { code: "steer_message_missing", statusCode: 400 }); + } + const env = options.env ?? process.env; + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + const managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl); + const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000); + const steerTraceId = safeTraceId(params.steerTraceId ?? params.steerId) || `trc_steer_${randomUUID().replace(/-/gu, "")}`; + traceStore.append(traceId, { + type: "backend", + status: "running", + label: "agentrun:steer:accepted", + message: "HWLAB accepted an in-flight steer request and will create an AgentRun type=steer command on the existing run.", + runId: mapped.agentRun.runId, + commandId: mapped.agentRun.commandId, + steerTraceId, + waitingFor: "agentrun-steer-command-create", + valuesPrinted: false + }); + const commandInput = buildAgentRunSteerCommandInput({ params: { ...params, message }, traceId, steerTraceId, mapped }); + const command = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapped.agentRun.runId)}/commands`, { + method: "POST", + body: commandInput, + timeoutMs + }); + const steerCommandId = requiredString(command?.id, "command.id"); + const now = nowIso(options.now); + const agentRun = { + ...mapped.agentRun, + lastSteerCommandId: steerCommandId, + lastSteerTraceId: steerTraceId, + updatedAt: now, + valuesPrinted: false + }; + traceStore.append(traceId, { + type: "backend", + status: "running", + label: "agentrun:steer:command-created", + message: `AgentRun steer command ${steerCommandId} created on run ${mapped.agentRun.runId}; runner will apply it if the target Codex turn is still active.`, + runId: mapped.agentRun.runId, + commandId: mapped.agentRun.commandId, + steerCommandId, + targetCommandId: mapped.agentRun.commandId, + steerTraceId, + waitingFor: "agentrun-steer-apply", + valuesPrinted: false + }); + const payload = { + ok: true, + accepted: true, + status: "running", + shortConnection: true, + controlSemantics: "steer-active-turn-and-poll-target-trace", + route: "/v1/agent/chat/steer", + traceId, + targetTraceId: traceId, + steerTraceId, + conversationId: safeConversationId(mapped.conversationId ?? params.conversationId) || null, + sessionId: safeSessionId(mapped.sessionId ?? params.sessionId) || null, + threadId: safeOpaqueId(mapped.threadId ?? params.threadId) || null, + resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`, + traceUrl: `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, + agentRun: { + ...agentRun, + steerCommandId, + targetCommandId: mapped.agentRun.commandId, + steerCommandState: command.state ?? null, + valuesPrinted: false + }, + runnerTrace: traceStore.snapshot(traceId, agentRunTraceMeta({}, {})), + updatedAt: now, + valuesPrinted: false + }; + options.codeAgentChatResults?.set?.(traceId, { ...mapped, agentRun, runnerTrace: payload.runnerTrace, updatedAt: now }); + return payload; +} + export async function loadPersistedAgentRunResult(traceId, options = {}) { if (!safeTraceId(traceId) || typeof options.accessController?.getAgentSessionByTraceId !== "function") return null; const session = await options.accessController.getAgentSessionByTraceId(traceId); @@ -362,7 +445,7 @@ export function agentRunSessionEvidence(payload = {}) { lastSeq: payload.agentRun.lastSeq ?? 0, providerId: payload.agentRun.providerId ?? DEFAULT_PROVIDER_ID, reuseEligible: payload.agentRun.reuseEligible ?? false, - sessionId: payload.sessionId ?? payload.agentRun.sessionId ?? null, + sessionId: payload.agentRun.sessionId ?? payload.sessionId ?? null, conversationId: payload.conversationId ?? payload.agentRun.conversationId ?? null, threadId: payload.threadId ?? payload.agentRun.threadId ?? null, providerTrace: payload.providerTrace ?? payload.agentRun.providerTrace ?? null, @@ -373,13 +456,15 @@ export function agentRunSessionEvidence(payload = {}) { function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, sessionId }) { const commitId = fullSourceCommit(env); + const threadId = safeOpaqueId(params.threadId); const resourceBundleRef = commitId ? { kind: "git", repoUrl: resolveAgentRunRepoUrl(env), commitId, submodules: false, - lfs: false + lfs: false, + toolAliases: HWLAB_RESOURCE_TOOL_ALIASES } : null; return { @@ -394,16 +479,18 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses valuesPrinted: false }, sessionRef: { - sessionId: sessionId ?? agentRunSessionIdForParams(params, traceId), + sessionId: sessionId ?? scopedAgentRunSessionIdForParams(params, traceId, backendProfile), ...(safeConversationId(params.conversationId) ? { conversationId: safeConversationId(params.conversationId) } : {}), + ...(threadId ? { threadId } : {}), metadata: { adapter: ADAPTER_ID, hwlabTraceId: traceId, hwlabApi: "/v1/agent/chat", hwlabSessionId: safeSessionId(params.sessionId) || null, - requestedThreadId: safeOpaqueId(params.threadId) || null, threadContinuityPolicy: THREAD_CONTINUITY_POLICY, sessionPolicy: SESSION_POLICY_RUN_LOCAL, + agentRunSessionProfile: backendProfile, + agentRunSessionPolicy: "backend-profile-scoped", valuesPrinted: false } }, @@ -432,6 +519,7 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }) { const prompt = String(params.message ?? params.prompt ?? "").trim(); + const threadId = safeOpaqueId(params.threadId); return { type: "turn", payload: { @@ -439,10 +527,9 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId message: prompt, traceId, conversationId: safeConversationId(params.conversationId) || null, - sessionId: sessionId ?? agentRunSessionIdForParams(params, traceId), + sessionId: sessionId ?? scopedAgentRunSessionIdForParams(params, traceId, backendProfile), hwlabSessionId: safeSessionId(params.sessionId) || null, - threadId: null, - requestedThreadId: safeOpaqueId(params.threadId) || null, + threadId: threadId || null, threadContinuityPolicy: THREAD_CONTINUITY_POLICY, sessionPolicy: SESSION_POLICY_RUN_LOCAL, providerProfile: backendProfile, @@ -453,6 +540,28 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }; } +function buildAgentRunSteerCommandInput({ params, traceId, steerTraceId, mapped }) { + const prompt = String(params.message ?? params.prompt ?? params.text ?? "").trim(); + return { + type: "steer", + payload: { + prompt, + message: prompt, + text: prompt, + traceId: steerTraceId, + targetTraceId: traceId, + targetCommandId: mapped.agentRun.commandId, + conversationId: safeConversationId(mapped.conversationId ?? params.conversationId) || null, + sessionId: mapped.agentRun.sessionId ?? safeSessionId(mapped.sessionId ?? params.sessionId) ?? null, + hwlabSessionId: safeSessionId(mapped.sessionId ?? params.sessionId) || null, + threadId: safeOpaqueId(mapped.threadId ?? params.threadId) || null, + source: "hwlab-cloud-api", + valuesPrinted: false + }, + idempotencyKey: steerTraceId + }; +} + function buildAgentRunRunnerJobInput({ env, traceId, commandId }) { const transientEnv = buildAgentRunTransientEnv(env); return { @@ -487,18 +596,18 @@ function buildAgentRunTransientEnv(env = process.env) { } async function resolveReusableAgentRun({ params = {}, options = {}, env = process.env, managerUrl, fetchImpl, timeoutMs, backendProfile, traceId, traceStore }) { - const sessionId = agentRunSessionIdForParams(params, traceId); - if (!safeSessionId(sessionId) || sessionId === agentRunSessionId(traceId) || typeof options.accessController?.getAgentSession !== "function") { + const hwlabSessionId = hwlabSessionIdForParams(params, traceId); + if (!safeSessionId(hwlabSessionId) || hwlabSessionId === agentRunSessionId(traceId) || typeof options.accessController?.getAgentSession !== "function") { return null; } - const session = await options.accessController.getAgentSession(sessionId); + const session = await options.accessController.getAgentSession(hwlabSessionId); if (!canReuseAgentRunSessionForOwner(session, params, options)) { traceStore.append(traceId, { type: "backend", status: "running", label: "agentrun:run:reuse-owner-skipped", message: "Stored AgentRun session is not visible to the current actor; a new runner Job will be created for this request.", - sessionId, + sessionId: hwlabSessionId, waitingFor: "agentrun-run-create", valuesPrinted: false }); @@ -511,7 +620,7 @@ async function resolveReusableAgentRun({ params = {}, options = {}, env = proces status: "running", label: "agentrun:run:reuse-skipped", message: "No reusable AgentRun run was found for this HWLAB session; a new runner Job will be created.", - sessionId, + sessionId: hwlabSessionId, waitingFor: "agentrun-run-create", valuesPrinted: false }); @@ -531,7 +640,7 @@ async function resolveReusableAgentRun({ params = {}, options = {}, env = proces message: `Stored AgentRun run ${mapping.runId} could not be read; a new runner Job will be created.`, errorCode: error?.code ?? "agentrun_run_lookup_failed", runId: mapping.runId, - sessionId, + sessionId: hwlabSessionId, waitingFor: "agentrun-run-create", valuesPrinted: false }); @@ -545,7 +654,7 @@ async function resolveReusableAgentRun({ params = {}, options = {}, env = proces label: "agentrun:run:reuse-blocked", message: `Stored AgentRun run ${mapping.runId} cannot be reused: ${reason}; a new runner Job will be created.`, runId: mapping.runId, - sessionId, + sessionId: hwlabSessionId, waitingFor: "agentrun-run-create", valuesPrinted: false }); @@ -555,17 +664,17 @@ async function resolveReusableAgentRun({ params = {}, options = {}, env = proces type: "backend", status: "running", label: "agentrun:run:reused", - message: `AgentRun run ${mapping.runId} is reused for HWLAB session ${sessionId}; runner lease is active and no new bundle will be requested for this turn.`, + message: `AgentRun run ${mapping.runId} is reused for HWLAB session ${hwlabSessionId}; runner lease is active and no new bundle will be requested for this turn.`, runId: mapping.runId, commandId: mapping.commandId ?? null, runnerId: mapping.runnerId ?? null, jobName: mapping.jobName ?? null, namespace: mapping.namespace ?? null, - sessionId, + sessionId: hwlabSessionId, waitingFor: "agentrun-command-create", valuesPrinted: false }); - return { sessionId, session, mapping, run }; + return { sessionId: mapping.sessionId ?? scopedAgentRunSessionIdForParams(params, traceId, backendProfile), hwlabSessionId, session, mapping, run }; } function isReusableAgentRunMapping(mapping, backendProfile) { @@ -728,6 +837,7 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) }); const code = canceled ? "agentrun_canceled" : result?.failureKind ?? (terminalStatus === "blocked" ? "agentrun_blocked" : "agentrun_failed"); const message = result?.failureMessage ?? result?.blocker?.message ?? (canceled ? "AgentRun command was canceled" : "AgentRun command failed"); + const partialContext = partialAgentRunContext(runnerTrace); return { ...base, status: canceled ? "canceled" : "failed", @@ -736,7 +846,7 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) session: agentRunSessionSummary(base, canceled ? "canceled" : "failed"), sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true), runner: agentRunRunnerSummary(base.agentRun), - runnerTrace, + runnerTrace: partialContext ? { ...runnerTrace, partialContext } : runnerTrace, toolCalls: agentRunToolCalls(result, canceled ? "canceled" : "failed"), skills: { status: "delegated", provider: ADAPTER_ID, count: 0, items: [], valuesPrinted: false }, providerTrace, @@ -762,6 +872,28 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) toolName: "agentrun.manual-dispatch" }, agentRun: { ...base.agentRun, terminalStatus, completed: false, providerTrace, valuesPrinted: false }, + ...(partialContext ? { partialContext } : {}), + valuesPrinted: false + }; +} + +function partialAgentRunContext(runnerTrace = {}) { + const events = Array.isArray(runnerTrace.events) ? runnerTrace.events : []; + const assistantMessages = events + .filter((event) => event?.type === "assistant" && String(event.text ?? event.message ?? "").trim()) + .map((event) => String(event.text ?? event.message).trim()) + .slice(-4); + const toolEvidence = events + .filter((event) => event?.type === "tool_call" && String(event.status ?? "") === "completed") + .map((event) => [event.toolName ?? event.label ?? "tool", event.command ?? event.outputSummary ?? event.stdoutSummary ?? event.message ?? ""].filter(Boolean).join(": ")) + .filter(Boolean) + .slice(-6); + if (assistantMessages.length === 0 && toolEvidence.length === 0) return null; + return { + status: "partial-before-terminal", + summary: "AgentRun terminal 前已有可延续的 assistant/tool 上下文;后续同 conversation/session/thread 轮次必须能继续使用。", + assistantMessages, + toolEvidence, valuesPrinted: false }; } @@ -789,11 +921,20 @@ function decorateAgentRunRunningResult({ base, mapping, traceStore, traceId }) { function appendAgentRunEventsToTrace(traceStore, traceId, events, mapping = {}) { for (const event of events) { + if (isForeignAgentRunCommandEvent(event, mapping)) continue; const normalized = mapAgentRunEvent(event, mapping); if (normalized) traceStore.append(traceId, normalized, agentRunTraceMeta({}, {})); } } +function isForeignAgentRunCommandEvent(event, mapping = {}) { + const currentCommandId = typeof mapping.commandId === "string" ? mapping.commandId : ""; + if (!currentCommandId) return false; + const payload = event?.payload && typeof event.payload === "object" ? event.payload : {}; + const eventCommandId = typeof payload.commandId === "string" ? payload.commandId : ""; + return Boolean(eventCommandId && eventCommandId !== currentCommandId); +} + function mapAgentRunEvent(event, mapping = {}) { if (!event || typeof event !== "object") return null; const payload = event.payload && typeof event.payload === "object" ? event.payload : {}; @@ -1010,10 +1151,21 @@ function agentRunResultRefs(result = {}) { return refs; } -function agentRunSessionIdForParams(params = {}, traceId) { +function hwlabSessionIdForParams(params = {}, traceId) { return safeSessionId(params.sessionId) || agentRunSessionId(traceId); } +function scopedAgentRunSessionIdForParams(params = {}, traceId, backendProfile) { + const baseSessionId = hwlabSessionIdForParams(params, traceId); + const profile = agentRunSessionProfileToken(backendProfile); + const base = String(baseSessionId).replace(/^ses_/u, "").replace(/[^A-Za-z0-9_]+/gu, "_").replace(/^_+|_+$/gu, "") || "session"; + return `ses_agentrun_${profile}_${base}`; +} + +function agentRunSessionProfileToken(backendProfile) { + return String(backendProfile ?? "deepseek").trim().toLowerCase().replace(/[^a-z0-9]+/gu, "_").replace(/^_+|_+$/gu, "") || "default"; +} + function agentRunSessionId(traceId) { return `ses_agentrun_${String(safeTraceId(traceId) || `trc_${randomUUID()}`).slice(4)}`; } diff --git a/internal/cloud/code-agent-chat.ts b/internal/cloud/code-agent-chat.ts index 0a63f71d..ae185f3b 100644 --- a/internal/cloud/code-agent-chat.ts +++ b/internal/cloud/code-agent-chat.ts @@ -245,7 +245,10 @@ export async function handleCodeAgentChat(params = {}, options = {}) { if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations; if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility; if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate; - if (error.conversationFacts !== undefined) { + const factPayload = shouldRecordErrorConversationFact(payload) ? attachConversationFacts(decorateChatSessionLifecycle(payload), sessionRegistry, { now: options.now }) : null; + if (factPayload?.conversationFacts) { + payload.conversationFacts = factPayload.conversationFacts; + } else if (error.conversationFacts !== undefined) { payload.conversationFacts = error.conversationFacts; } else if (typeof sessionRegistry.getConversationFacts === "function") { payload.conversationFacts = sessionRegistry.getConversationFacts(conversationId); @@ -263,6 +266,14 @@ export async function handleCodeAgentChat(params = {}, options = {}) { } } +function shouldRecordErrorConversationFact(payload = {}) { + if (!payload || typeof payload !== "object") return false; + if (payload.status === "running" || payload.status === "completed") return false; + if (payload.partialContext) return true; + if (payload.runnerTrace?.partialContext) return true; + return Boolean(payload.runnerTrace?.traceId && payload.sessionId && payload.conversationId); +} + function completedRunnerPayload({ base, runnerResult, messageId, now, sessionRegistry }) { const completedAt = nowIso(now); const blocker = structuredCompletionBlocker(runnerResult, { diff --git a/internal/cloud/code-agent-session-registry.ts b/internal/cloud/code-agent-session-registry.ts index 347f202d..b27b32d0 100644 --- a/internal/cloud/code-agent-session-registry.ts +++ b/internal/cloud/code-agent-session-registry.ts @@ -477,11 +477,31 @@ function sanitizeConversationFact(fact, { conversationId, timestamp, maxFactTool toolCalls, skills, runnerTrace: sanitizeRunnerTrace(runnerTrace), + partialContext: sanitizePartialContext(fact.partialContext ?? fact.partialConversationContext ?? runnerTrace?.partialContext), valuesRedacted: true, secretMaterialStored: false }; } +function sanitizePartialContext(value) { + if (!value || typeof value !== "object") return null; + const assistantMessages = Array.isArray(value.assistantMessages) + ? value.assistantMessages.map((item) => safeFactText(item, 240)).filter(Boolean).slice(-4) + : []; + const toolEvidence = Array.isArray(value.toolEvidence) + ? value.toolEvidence.map((item) => safeFactText(item, 200)).filter(Boolean).slice(-6) + : []; + const summary = safeFactText(value.summary, 400); + if (!summary && assistantMessages.length === 0 && toolEvidence.length === 0) return null; + return { + status: safeFactText(value.status ?? "partial", 40) ?? "partial", + summary, + assistantMessages, + toolEvidence, + valuesRedacted: true + }; +} + function sanitizeToolCalls(toolCalls, limit) { const items = Array.isArray(toolCalls) ? toolCalls.slice(0, limit).map((toolCall) => ({ @@ -544,6 +564,7 @@ function sanitizeRunnerTrace(runnerTrace) { : [], route: safeFactText(runnerTrace.route, 120), status: safeFactText(runnerTrace.status, 80), + partialContext: sanitizePartialContext(runnerTrace.partialContext), outputTruncated: runnerTrace.outputTruncated === true, valuesPrinted: false }; diff --git a/internal/cloud/codex-stdio-session.ts b/internal/cloud/codex-stdio-session.ts index 2e544b49..f86e5813 100644 --- a/internal/cloud/codex-stdio-session.ts +++ b/internal/cloud/codex-stdio-session.ts @@ -204,7 +204,7 @@ export const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [ "Use the provided workspace and repo-owned Codex stdio session only.", "Do not read or print secrets, tokens, kubeconfig files, DB URLs, private keys, or raw environment values.", "For hardware, gateway, box-simu, patch-panel, DAP, PWM, Keil, serial, and Windows skill requests, execute the requested work through the repo-owned Codex stdio session with the available tran wrapper, skill CLI, or project tool that actually reaches the target.", - "For any request that mentions device-pod, device-pod-cli, device-host-cli, a device-pod profile, device-pod-71-freq, device-pod-71-00075-11, D601-F103-V2, or a registered device-pod Keil build/download/UART/debug-probe operation, first use the HWLAB internal skill named device-pod-cli. The canonical skill location is /app/skills/device-pod-cli/SKILL.md; read that manifest and run hwpod for the operation. Use node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs only as a compatibility fallback when hwpod is absent. After selecting the target pod or resuming context, run bootsharp --pod-id before edits, build, or download.", + "For any request that mentions device-pod, device-pod-cli, device-host-cli, a device-pod profile, device-pod-71-freq, device-pod-71-00075-11, D601-F103-V2, or a registered device-pod Keil build/download/UART/debug-probe operation, first use the HWLAB internal skill named device-pod-cli. The canonical skill location is /app/skills/device-pod-cli/SKILL.md; read that manifest and run hwpod for the operation. hwpod is the only standard runner entry; if it is absent, report a runner image/package error instead of invoking a long-path launcher. After selecting the target pod or resuming context, run bootsharp --pod-id before edits, build, or download.", "Do not pass --api-base-url, --api-url, --cloud-api-url, --base-url, or session tokens to hwpod in HWLAB runners. WEB and AgentRun assemble the current runtime endpoint and credential; hwpod must auto-locate from that assembled environment.", "When a matching device-pod profile exists, do not bypass device-pod-cli with direct hwlab-gateway-tran.mjs, Windows Keil skills, serial-monitor skills, keil-cli.py, or ad hoc path discovery. Only drop to tran or Windows-side skills when the device-pod-cli skill explicitly says to bootstrap, install, or repair the lower layer.", "For registered PC gateway Windows command or skill requests, use the preloaded tran wrapper: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work [options] -- . The locator before ':' is the gateway session and the path after ':' is the Windows workspace, with /f/work and f:/work both mapping to F:\\work.", @@ -1143,7 +1143,7 @@ export function createCodexStdioSessionManager(options = {}) { const timestampMs = Date.parse(timestamp); const conversationId = requiredId(params.conversationId, "cnv"); const requestedSessionId = optionalId(params.requestedSessionId); - const requestedThreadId = optionalId(params.threadId); + const threadId = optionalId(params.threadId); const mappedSessionId = conversations.get(conversationId) ?? null; if (mappedSessionId && requestedSessionId && mappedSessionId !== requestedSessionId) { @@ -1162,7 +1162,7 @@ export function createCodexStdioSessionManager(options = {}) { let reused = Boolean(session); if (session && sessionExpired(session, timestampMs)) { - const resumeThreadId = requestedThreadId ?? optionalId(session.threadId); + const resumeThreadId = threadId ?? optionalId(session.threadId); if (["idle", "expired"].includes(session.status) && resumeThreadId) { session.status = "idle"; session.threadId = resumeThreadId; @@ -1241,7 +1241,7 @@ export function createCodexStdioSessionManager(options = {}) { lastTraceId: optionalId(params.traceId), currentTraceId: null, turn: 0, - threadId: requestedThreadId, + threadId, durable: true, longLivedSession: true, codexStdio: true, @@ -1249,11 +1249,11 @@ export function createCodexStdioSessionManager(options = {}) { secretMaterialStored: false }; sessions.set(effectiveSessionId, session); - reused = Boolean(requestedThreadId); + reused = Boolean(threadId); } session.conversationIds.add(conversationId); - session.threadId = requestedThreadId ?? session.threadId; + session.threadId = threadId ?? session.threadId; session.workspace = params.workspace ?? session.workspace; session.sandbox = params.sandbox ?? session.sandbox; session.status = "busy"; diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 60d9f61b..6d5f803d 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -159,7 +159,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte leaseExpiresAt: "2999-01-01T00:00:00.000Z", backendProfile: "deepseek", sessionRef: { - sessionId: "ses_server-test-agentrun", + sessionId: "ses_agentrun_deepseek_server_test_agentrun", conversationId: "cnv_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", metadata: {} @@ -171,21 +171,35 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(body.backendProfile, "deepseek"); assert.equal(body.resourceBundleRef.repoUrl, "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"); assert.equal(body.resourceBundleRef.commitId, "0123456789abcdef0123456789abcdef01234567"); - assert.equal(Object.hasOwn(body.sessionRef, "threadId"), false); - assert.equal(body.sessionRef.sessionId, "ses_server-test-agentrun"); + assert.deepEqual(body.resourceBundleRef.toolAliases, [ + { name: "hwpod", path: "tools/device-pod-cli.mjs", kind: "node-script" } + ]); + assert.equal(body.sessionRef.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); + assert.equal(body.sessionRef.sessionId, "ses_agentrun_deepseek_server_test_agentrun"); assert.equal(body.sessionRef.metadata.hwlabSessionId, "ses_server-test-agentrun"); - assert.equal(body.sessionRef.metadata.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); + assert.equal(body.sessionRef.metadata.agentRunSessionProfile, "deepseek"); + assert.equal(body.sessionRef.metadata.agentRunSessionPolicy, "backend-profile-scoped"); assert.equal(body.sessionRef.metadata.threadContinuityPolicy, "hwlab-agentrun-v01-reuse-runner-thread"); assert.equal(body.sessionRef.metadata.sessionPolicy, "hwlab-agentrun-v01-session-runner-reuse"); return send({ id: "run_hwlab_adapter", status: "pending", backendProfile: "deepseek", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef }); } if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands") { + if (body.type === "steer") { + assert.equal(body.payload.targetTraceId, "trc_server-test-agentrun-adapter"); + assert.equal(body.payload.targetCommandId, "cmd_hwlab_adapter"); + assert.equal(body.payload.traceId, "trc_steer_server_test"); + assert.match(body.payload.prompt, /STEER_MARK/u); + assert.equal(body.payload.sessionId, "ses_agentrun_deepseek_server_test_agentrun"); + assert.equal(body.payload.hwlabSessionId, "ses_server-test-agentrun"); + assert.equal(body.payload.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); + assert.equal(body.idempotencyKey, "trc_steer_server_test"); + return send({ id: "cmd_hwlab_adapter_steer", runId: "run_hwlab_adapter", state: "pending", type: "steer", seq: 7 }); + } assert.equal(body.type, "turn"); assert.match(body.payload.prompt, /AgentRun adapter/u); - assert.equal(body.payload.sessionId, "ses_server-test-agentrun"); + assert.equal(body.payload.sessionId, "ses_agentrun_deepseek_server_test_agentrun"); assert.equal(body.payload.hwlabSessionId, "ses_server-test-agentrun"); - assert.equal(body.payload.threadId, null); - assert.equal(body.payload.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); + assert.equal(body.payload.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); assert.equal(body.payload.threadContinuityPolicy, "hwlab-agentrun-v01-reuse-runner-thread"); assert.equal(body.payload.sessionPolicy, "hwlab-agentrun-v01-session-runner-reuse"); const secondTurn = /第二轮/u.test(body.payload.prompt); @@ -211,6 +225,8 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(transientEnv.HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME, "1"); assert.equal(transientEnv.HWLAB_DEVICE_POD_SESSION_TOKEN, "test-device-pod-session-token"); assert.ok(body.transientEnv.length >= 7); + assert.equal(Object.hasOwn(transientEnv, "HWLAB_DEVICE_POD_API_URL"), false); + assert.equal(Object.hasOwn(transientEnv, "HWLAB_CODE_AGENT_DEVICE_POD_API_URL"), false); return send({ action: "create-kubernetes-job", runId: "run_hwlab_adapter", @@ -224,15 +240,20 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte }); } if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter/events") { - const second = url.searchParams.get("afterSeq") === "3" || calls.some((call) => call.path === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter_second/result"); + const afterSeq = Number.parseInt(url.searchParams.get("afterSeq") ?? "0", 10); + const second = afterSeq >= 5 || url.searchParams.get("afterSeq") === "3" || calls.some((call) => call.path === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter_second/result"); + if (second) return send({ items: [ + { id: "evt_old_tail", runId: "run_hwlab_adapter", seq: 6, type: "assistant_message", payload: { commandId: "cmd_hwlab_adapter", text: "旧 command 尾部不应进入第二轮。" }, createdAt: "2026-06-01T00:00:02.500Z" }, + { id: "evt_7", runId: "run_hwlab_adapter", seq: 7, type: "backend_status", payload: { phase: "turn-started", commandId: "cmd_hwlab_adapter_second", attemptId: "attempt_hwlab_adapter", jobName: "agentrun-v01-runner-hwlab-adapter", namespace: "agentrun-v01" }, createdAt: "2026-06-01T00:00:03.000Z" }, + { id: "evt_8", runId: "run_hwlab_adapter", seq: 8, type: "assistant_message", payload: { commandId: "cmd_hwlab_adapter_second", text: "AgentRun adapter 复用已有 runner 完成第二轮。" }, createdAt: "2026-06-01T00:00:04.000Z" }, + { id: "evt_9", runId: "run_hwlab_adapter", seq: 9, type: "terminal_status", payload: { commandId: "cmd_hwlab_adapter_second", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:05.000Z" } + ] }); return send({ items: [ - { id: second ? "evt_4" : "evt_1", runId: "run_hwlab_adapter", seq: second ? 4 : 1, type: "backend_status", payload: { phase: second ? "turn-started" : "runner-job-created", commandId: second ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", attemptId: "attempt_hwlab_adapter", jobName: "agentrun-v01-runner-hwlab-adapter", namespace: "agentrun-v01" }, createdAt: "2026-06-01T00:00:00.000Z" }, - ...(second ? [] : [ + { id: "evt_1", runId: "run_hwlab_adapter", seq: 1, type: "backend_status", payload: { phase: "runner-job-created", commandId: "cmd_hwlab_adapter", attemptId: "attempt_hwlab_adapter", jobName: "agentrun-v01-runner-hwlab-adapter", namespace: "agentrun-v01" }, createdAt: "2026-06-01T00:00:00.000Z" }, { id: "evt_tool", runId: "run_hwlab_adapter", seq: 2, type: "tool_call", payload: { method: "item/completed", item: { type: "commandExecution", id: "call_agentrun_tool", command: "/bin/sh -lc 'hwpod profile list'", status: "completed", exitCode: 0, durationMs: 708, aggregatedOutput: '{"ok":true,"action":"profile.list"}' }, summary: { outputBytes: 42, outputTruncated: false }, commandId: "cmd_hwlab_adapter", runnerId: "runner_hwlab_adapter", attemptId: "attempt_hwlab_adapter" }, createdAt: "2026-06-01T00:00:00.500Z" }, - { id: "evt_noise", runId: "run_hwlab_adapter", seq: 3, type: "backend_status", payload: { phase: "thread/status/changed", commandId: "cmd_hwlab_adapter" }, createdAt: "2026-06-01T00:00:00.750Z" } - ]), - { id: second ? "evt_5" : "evt_2", runId: "run_hwlab_adapter", seq: second ? 5 : 4, type: "assistant_message", payload: { commandId: second ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", text: second ? "AgentRun adapter 复用已有 runner 完成第二轮。" : "AgentRun adapter 已接管 HWLAB Code Agent。" }, createdAt: "2026-06-01T00:00:01.000Z" }, - { id: second ? "evt_6" : "evt_3", runId: "run_hwlab_adapter", seq: second ? 6 : 5, type: "terminal_status", payload: { commandId: second ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:02.000Z" } + { id: "evt_noise", runId: "run_hwlab_adapter", seq: 3, type: "backend_status", payload: { phase: "thread/status/changed", commandId: "cmd_hwlab_adapter" }, createdAt: "2026-06-01T00:00:00.750Z" }, + { id: "evt_2", runId: "run_hwlab_adapter", seq: 4, type: "assistant_message", payload: { commandId: "cmd_hwlab_adapter", text: "AgentRun adapter 已接管 HWLAB Code Agent。" }, createdAt: "2026-06-01T00:00:01.000Z" }, + { id: "evt_3", runId: "run_hwlab_adapter", seq: 5, type: "terminal_status", payload: { commandId: "cmd_hwlab_adapter", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:02.000Z" } ] }); } if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter/result") { @@ -251,7 +272,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte reply: "AgentRun adapter 已接管 HWLAB Code Agent。", lastSeq: 5, eventCount: 5, - sessionRef: { sessionId: "ses_server-test-agentrun", conversationId: "cnv_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" } + sessionRef: { sessionId: "ses_agentrun_deepseek_server_test_agentrun", conversationId: "cnv_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" } }); } if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands/cmd_hwlab_adapter_second/result") { @@ -268,9 +289,9 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte terminalStatus: "completed", completed: true, reply: "AgentRun adapter 复用已有 runner 完成第二轮。", - lastSeq: 6, - eventCount: 6, - sessionRef: { sessionId: "ses_server-test-agentrun", conversationId: "cnv_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" } + lastSeq: 9, + eventCount: 9, + sessionRef: { sessionId: "ses_agentrun_deepseek_server_test_agentrun", conversationId: "cnv_server-test-agentrun", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" } }); } response.writeHead(404, { "content-type": "application/json" }); @@ -372,6 +393,29 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.match(commandTraceEvent.command, /hwpod profile list/u); assert.match(commandTraceEvent.stdoutSummary, /profile\.list/u); + const steer = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/steer`, { + method: "POST", + headers: { "content-type": "application/json", "x-trace-id": traceId }, + body: JSON.stringify({ + traceId, + steerTraceId: "trc_steer_server_test", + conversationId: "cnv_server-test-agentrun", + sessionId: "ses_server-test-agentrun", + threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", + message: "请按 STEER_MARK 调整最终回复" + }) + }); + assert.equal(steer.status, 202); + const steerBody = await steer.json(); + assert.equal(steerBody.accepted, true); + assert.equal(steerBody.route, "/v1/agent/chat/steer"); + assert.equal(steerBody.traceId, traceId); + assert.equal(steerBody.steerTraceId, "trc_steer_server_test"); + assert.equal(steerBody.agentRun.runId, "run_hwlab_adapter"); + assert.equal(steerBody.agentRun.targetCommandId, "cmd_hwlab_adapter"); + assert.equal(steerBody.agentRun.steerCommandId, "cmd_hwlab_adapter_steer"); + assert.ok(steerBody.runnerTrace.events.some((event) => event.label === "agentrun:steer:command-created")); + const secondTraceId = "trc_server-test-agentrun-adapter-second"; const second = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { method: "POST", @@ -401,9 +445,10 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.match(secondPayload.reply.content, /复用已有 runner/u); assert.ok(secondPayload.runnerTrace.events.some((event) => event.label === "agentrun:run:reused")); assert.ok(secondPayload.runnerTrace.events.some((event) => event.label === "agentrun:runner-job:reused")); + assert.equal(secondPayload.runnerTrace.events.some((event) => String(event.text ?? event.message ?? "").includes("旧 command 尾部")), false); assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs").length, 1); assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs/run_hwlab_adapter/runner-jobs").length, 1); - assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs/run_hwlab_adapter/commands").length, 2); + assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs/run_hwlab_adapter/commands").length, 3); } finally { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve()))); @@ -424,6 +469,10 @@ test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun ba }; if (request.method === "POST" && url.pathname === "/api/v1/runs") { assert.equal(body.backendProfile, "minimax-m3"); + assert.equal(body.sessionRef.sessionId, "ses_agentrun_minimax_m3_server_test_minimax_m3"); + assert.equal(body.sessionRef.metadata.hwlabSessionId, "ses_server-test-minimax-m3"); + assert.equal(body.sessionRef.metadata.agentRunSessionProfile, "minimax-m3"); + assert.equal(body.sessionRef.metadata.agentRunSessionPolicy, "backend-profile-scoped"); assert.equal(body.executionPolicy.secretScope.providerCredentials[0].profile, "minimax-m3"); assert.equal(body.executionPolicy.secretScope.providerCredentials[0].secretRef.name, "agentrun-v01-provider-minimax-m3"); assert.equal(body.executionPolicy.secretScope.providerCredentials[0].secretRef.namespace, "agentrun-v01"); @@ -431,6 +480,8 @@ test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun ba } if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_minimax_m3/commands") { assert.equal(body.payload.providerProfile, "minimax-m3"); + assert.equal(body.payload.sessionId, "ses_agentrun_minimax_m3_server_test_minimax_m3"); + assert.equal(body.payload.hwlabSessionId, "ses_server-test-minimax-m3"); assert.match(body.payload.prompt, /MiniMax-M3/u); return send({ id: "cmd_hwlab_minimax_m3", runId: "run_hwlab_minimax_m3", state: "pending", type: "turn", seq: 1 }); } @@ -471,7 +522,7 @@ test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun ba reply: "AGENTRUN_MINIMAX_M3_OK", lastSeq: 3, eventCount: 3, - sessionRef: { sessionId: "ses_server-test-minimax-m3", conversationId: "cnv_server-test-minimax-m3", threadId: null } + sessionRef: { sessionId: "ses_agentrun_minimax_m3_server_test_minimax_m3", conversationId: "cnv_server-test-minimax-m3", threadId: null } }); } response.writeHead(404, { "content-type": "application/json" }); @@ -521,6 +572,8 @@ test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun ba assert.equal(payload.status, "completed"); assert.equal(payload.infrastructureBackend, "agentrun-v01/minimax-m3"); assert.equal(payload.agentRun.backendProfile, "minimax-m3"); + assert.equal(payload.sessionId, "ses_server-test-minimax-m3"); + assert.equal(payload.agentRun.sessionId, "ses_agentrun_minimax_m3_server_test_minimax_m3"); assert.equal(payload.providerTrace.backendProfile, "minimax-m3"); assert.equal(payload.agentRun.jobName, "agentrun-v01-runner-hwlab-minimax-m3"); assert.equal(payload.reply.content, "AGENTRUN_MINIMAX_M3_OK"); @@ -531,6 +584,172 @@ test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun ba } }); +test("cloud api AgentRun adapter scopes AgentRun sessions by backend profile", async () => { + const calls = []; + const hwlabSessionId = "ses_server-test-profile-switch"; + const minimaxSessionId = "ses_agentrun_minimax_m3_server_test_profile_switch"; + const ownerSessions = new Map([[hwlabSessionId, { + ownerUserId: "usr_agent_owner", + ownerRole: "user", + sessionId: hwlabSessionId, + conversationId: "cnv_server-test-profile-switch", + threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", + traceId: "trc_existing_deepseek", + status: "active", + session: { + agentRun: { + adapter: "agentrun-v01", + backendProfile: "deepseek", + runId: "run_existing_deepseek", + commandId: "cmd_existing_deepseek", + jobName: "agentrun-v01-runner-existing-deepseek", + namespace: "agentrun-v01", + sessionId: "ses_agentrun_deepseek_server_test_profile_switch", + reuseEligible: true, + managerUrl: "http://127.0.0.1:1", + status: "runner-job-created" + } + } + }]]); + + const agentRunServer = createHttpServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + const chunks = []; + for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null; + calls.push({ method: request.method, path: url.pathname, body }); + const send = (data) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_fake_profile_switch" })}\n`); + }; + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_existing_deepseek") { + response.writeHead(500, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: false, failureKind: "unexpected-reuse", message: "deepseek run must not be reused for minimax-m3" })}\n`); + return; + } + if (request.method === "POST" && url.pathname === "/api/v1/runs") { + assert.equal(body.backendProfile, "minimax-m3"); + assert.equal(body.sessionRef.sessionId, minimaxSessionId); + assert.equal(body.sessionRef.metadata.hwlabSessionId, hwlabSessionId); + assert.equal(body.sessionRef.metadata.agentRunSessionProfile, "minimax-m3"); + return send({ id: "run_hwlab_profile_switch_m3", status: "pending", backendProfile: "minimax-m3", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef }); + } + if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_profile_switch_m3/commands") { + assert.equal(body.payload.providerProfile, "minimax-m3"); + assert.equal(body.payload.sessionId, minimaxSessionId); + assert.equal(body.payload.hwlabSessionId, hwlabSessionId); + return send({ id: "cmd_hwlab_profile_switch_m3", runId: "run_hwlab_profile_switch_m3", state: "pending", type: "turn", seq: 1 }); + } + if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_profile_switch_m3/runner-jobs") { + assert.equal(body.commandId, "cmd_hwlab_profile_switch_m3"); + return send({ + action: "create-kubernetes-job", + runId: "run_hwlab_profile_switch_m3", + commandId: "cmd_hwlab_profile_switch_m3", + attemptId: "attempt_hwlab_profile_switch_m3", + runnerId: "runner_hwlab_profile_switch_m3", + namespace: "agentrun-v01", + jobName: "agentrun-v01-runner-profile-switch-m3", + jobIdentity: { namespace: "agentrun-v01", name: "agentrun-v01-runner-profile-switch-m3" }, + runner: { attemptId: "attempt_hwlab_profile_switch_m3", runnerId: "runner_hwlab_profile_switch_m3" } + }); + } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_profile_switch_m3/events") { + return send({ items: [ + { id: "evt_profile_switch_1", runId: "run_hwlab_profile_switch_m3", seq: 1, type: "backend_status", payload: { phase: "runner-job-created", commandId: "cmd_hwlab_profile_switch_m3", attemptId: "attempt_hwlab_profile_switch_m3", jobName: "agentrun-v01-runner-profile-switch-m3", namespace: "agentrun-v01" }, createdAt: "2026-06-02T00:00:00.000Z" }, + { id: "evt_profile_switch_2", runId: "run_hwlab_profile_switch_m3", seq: 2, type: "assistant_message", payload: { commandId: "cmd_hwlab_profile_switch_m3", text: "AGENTRUN_HWLAB_MINIMAX_M3_OK" }, createdAt: "2026-06-02T00:00:01.000Z" }, + { id: "evt_profile_switch_3", runId: "run_hwlab_profile_switch_m3", seq: 3, type: "terminal_status", payload: { commandId: "cmd_hwlab_profile_switch_m3", terminalStatus: "completed" }, createdAt: "2026-06-02T00:00:02.000Z" } + ] }); + } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_profile_switch_m3/commands/cmd_hwlab_profile_switch_m3/result") { + return send({ + runId: "run_hwlab_profile_switch_m3", + commandId: "cmd_hwlab_profile_switch_m3", + attemptId: "attempt_hwlab_profile_switch_m3", + runnerId: "runner_hwlab_profile_switch_m3", + jobName: "agentrun-v01-runner-profile-switch-m3", + namespace: "agentrun-v01", + status: "completed", + runStatus: "completed", + commandState: "completed", + terminalStatus: "completed", + completed: true, + reply: "AGENTRUN_HWLAB_MINIMAX_M3_OK", + lastSeq: 3, + eventCount: 3, + sessionRef: { sessionId: minimaxSessionId, conversationId: "cnv_server-test-profile-switch", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" } + }); + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: false, failureKind: "schema-invalid", message: `unexpected ${request.method} ${url.pathname}`, traceId: "trc_fake_profile_switch" })}\n`); + }); + await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); + + const agentRunPort = agentRunServer.address().port; + const server = createCloudApiServer({ + env: { + HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", + AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", + HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", + HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek", + HWLAB_ENVIRONMENT: "v02", + HWLAB_GITOPS_PROFILE: "v02" + }, + accessController: { + required: false, + async authenticate() { + return { ok: true, actor: null, session: null }; + }, + async recordAgentSessionOwner(input) { + ownerSessions.set(input.sessionId, input); + return input; + }, + async getAgentSession(sessionId) { + return ownerSessions.get(sessionId) ?? null; + } + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const traceId = "trc_server-test-agentrun-profile-switch"; + const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { "content-type": "application/json", "x-trace-id": traceId }, + body: JSON.stringify({ + conversationId: "cnv_server-test-profile-switch", + sessionId: hwlabSessionId, + ownerUserId: "usr_agent_owner", + ownerRole: "user", + threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", + providerProfile: "minimax-m3", + message: "Switch this HWLAB session to MiniMax-M3 through AgentRun" + }) + }); + assert.equal(submit.status, 202); + + const payload = await pollAgentResult(port, traceId); + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.sessionId, hwlabSessionId); + assert.equal(payload.infrastructureBackend, "agentrun-v01/minimax-m3"); + assert.equal(payload.agentRun.backendProfile, "minimax-m3"); + assert.equal(payload.agentRun.runId, "run_hwlab_profile_switch_m3"); + assert.equal(payload.agentRun.sessionId, minimaxSessionId); + assert.equal(payload.reply.content, "AGENTRUN_HWLAB_MINIMAX_M3_OK"); + assert.equal(calls.some((call) => call.method === "GET" && call.path === "/api/v1/runs/run_existing_deepseek"), false); + assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs").length, 1); + const stored = ownerSessions.get(hwlabSessionId); + assert.equal(stored.session.agentRun.backendProfile, "minimax-m3"); + assert.equal(stored.session.agentRun.sessionId, minimaxSessionId); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("cloud api AgentRun adapter rejects non-internal manager URLs by default", async () => { const server = createCloudApiServer({ env: { diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 7da87b5a..aaf69822 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -9,6 +9,7 @@ import { initialAgentRunChatResult, loadPersistedAgentRunResult, refreshAgentRunTrace, + steerAgentRunChatTurn, submitAgentRunChatTurn, syncAgentRunChatResult } from "./code-agent-agentrun-adapter.ts"; @@ -344,6 +345,7 @@ function submitCodeAgentChatTurn({ params, options, traceId }) { runnerTrace: traceStore.snapshot(traceId), updatedAt: new Date().toISOString() }, params); + recordCodeAgentConversationFact(payload, executionOptions); results.set(traceId, payload); traceStore.append(traceId, { type: "result", @@ -478,6 +480,7 @@ export async function handleCodeAgentChatResultHttp(request, response, url, opti return; } if (synced.result && synced.result.status !== "running") { + recordCodeAgentConversationFact(synced.result, options); await recordCodeAgentSessionOwner({ payload: synced.result, params: synced.result, options, status: synced.result.status === "completed" ? "active" : synced.result.status }); sendJson(response, 200, compactCodeAgentChatResultPayload(synced.result, options)); return; @@ -837,10 +840,165 @@ export async function handleCodeAgentCancelHttp(request, response, options) { updatedAt: new Date().toISOString() }; await recordCodeAgentSessionOwner({ payload, params: { ...params, traceId, ownerUserId: options.actor?.id, ownerRole: options.actor?.role }, options, status: "canceled" }); + recordCodeAgentConversationFact(payload, options); options.codeAgentChatResults?.set(traceId, annotateOwner(payload, { ownerUserId: options.actor?.id, ownerRole: options.actor?.role })); sendJson(response, 200, payload); } +function recordCodeAgentConversationFact(payload = {}, options = {}) { + const registry = options.sessionRegistry; + const conversationId = safeConversationId(payload.conversationId); + if (!conversationId || typeof registry?.recordFact !== "function") return null; + try { + return registry.recordFact(conversationId, { + kind: payload.status === "completed" ? "runner_turn" : "runner_turn_terminal", + conversationId, + sessionId: payload.sessionId, + traceId: payload.traceId, + provider: payload.provider, + backend: payload.backend, + workspace: payload.workspace, + sandbox: payload.sandbox, + session: payload.session, + sessionMode: payload.sessionMode, + sessionReuse: payload.sessionReuse, + implementationType: payload.implementationType, + runner: payload.runner, + runnerTrace: payload.runnerTrace, + partialContext: payload.partialContext ?? payload.runnerTrace?.partialContext, + capabilityLevel: payload.capabilityLevel, + toolCalls: payload.toolCalls, + skills: payload.skills + }, { now: options.now }); + } catch { + return null; + } +} + +export async function handleCodeAgentSteerHttp(request, response, options) { + const body = await readBody(request, options.bodyLimitBytes); + let params = {}; + + try { + params = body ? JSON.parse(body) : {}; + } catch (error) { + sendJson(response, 400, { + ...createCodeAgentErrorPayload({ + code: "parse_error", + message: "Invalid JSON body", + reason: error.message, + traceId: getHeader(request, "x-trace-id") || "trc_unassigned", + layer: "api", + retryable: true, + route: "/v1/agent/chat/steer" + }) + }); + return; + } + + if (!params || typeof params !== "object" || Array.isArray(params)) { + sendJson(response, 400, { + ...createCodeAgentErrorPayload({ + code: "invalid_params", + message: "Code Agent steer body must be a JSON object", + traceId: getHeader(request, "x-trace-id") || "trc_unassigned", + layer: "api", + retryable: true, + route: "/v1/agent/chat/steer" + }) + }); + return; + } + + const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId || params.targetTraceId); + const message = firstNonEmptyValue(params.message, params.prompt, params.text); + if (!traceId || !message) { + sendJson(response, 400, { + ...createCodeAgentErrorPayload({ + code: !traceId ? "steer_trace_missing" : "steer_message_missing", + message: !traceId ? "traceId is required to steer the current Code Agent turn." : "message is required to steer the current Code Agent turn.", + traceId: traceId || "trc_unassigned", + layer: "api", + retryable: true, + route: "/v1/agent/chat/steer" + }) + }); + return; + } + + const traceStore = options.traceStore ?? defaultCodeAgentTraceStore; + const currentResult = options.codeAgentChatResults?.get?.(traceId) ?? await loadPersistedAgentRunResult(traceId, options); + if (!canAccessOwnedResult(currentResult, options.actor)) { + sendJson(response, 403, { + ok: false, + status: "forbidden", + traceId, + error: { code: "code_agent_trace_forbidden", message: "Code Agent trace is not visible to the current actor" } + }); + return; + } + if (!currentResult?.agentRun?.runId || !currentResult?.agentRun?.commandId) { + sendJson(response, 404, { + ok: false, + status: "not_found", + traceId, + error: { code: "steer_trace_not_found", message: "No AgentRun-backed Code Agent turn was found for the requested traceId." }, + route: "/v1/agent/chat/steer", + runnerTrace: traceStore.snapshot(traceId) + }); + return; + } + + try { + const payload = await steerAgentRunChatTurn({ + traceId, + currentResult, + params: { + ...params, + message, + ownerUserId: options.actor?.id ?? params.ownerUserId, + ownerRole: options.actor?.role ?? params.ownerRole + }, + options, + traceStore + }); + await recordCodeAgentSessionOwner({ payload: currentResult, params: { ...params, traceId, ownerUserId: options.actor?.id, ownerRole: options.actor?.role }, options, status: "running" }); + sendJson(response, 202, payload); + } catch (error) { + traceStore.append(traceId, { + type: "backend", + status: "failed", + label: "agentrun:steer:failed", + errorCode: error?.code ?? "agentrun_steer_failed", + message: error?.message ?? "AgentRun steer command failed.", + terminal: false, + valuesPrinted: false + }); + sendJson(response, error?.statusCode === 404 ? 404 : 409, { + ok: false, + accepted: false, + status: "failed", + traceId, + route: "/v1/agent/chat/steer", + conversationId: safeConversationId(currentResult.conversationId ?? params.conversationId) || null, + sessionId: safeSessionId(currentResult.sessionId ?? params.sessionId) || null, + threadId: safeOpaqueId(currentResult.threadId ?? params.threadId) || null, + agentRun: currentResult.agentRun, + runnerTrace: traceStore.snapshot(traceId), + error: { + code: error?.code ?? "agentrun_steer_failed", + layer: "agentrun", + category: "steer_failed", + retryable: true, + message: error?.message ?? "AgentRun steer command failed.", + traceId, + route: "/v1/agent/chat/steer", + toolName: "agentrun.command.steer" + } + }); + } +} + async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options = {}, status = "active" } = {}) { const ownerUserId = options.actor?.id ?? params.ownerUserId; if (!ownerUserId || !options.accessController?.recordAgentSessionOwner) return null; @@ -945,6 +1103,9 @@ async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId, }); } if (activeTraceBlocksTurn) { + const conversationId = safeConversationId(params.conversationId) || selectedConversationId || activeResult?.conversationId || null; + const sessionId = safeSessionId(params.sessionId) || safeSessionId(workspace.selectedAgentSessionId) || activeResult?.sessionId || null; + const threadId = safeOpaqueId(params.threadId) || activeResult?.threadId || null; sendJson(response, 409, { ok: false, status: "workspace_agent_busy", @@ -952,6 +1113,10 @@ async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId, workspaceId, workspaceRevision: workspace.revision, activeTraceId, + conversationId, + sessionId, + threadId, + providerTrace: activeResult?.providerTrace ?? activeResult?.agentRun?.providerTrace ?? null, resultUrl: `/v1/agent/chat/result/${encodeURIComponent(activeTraceId)}`, traceUrl: `/v1/agent/chat/trace/${encodeURIComponent(activeTraceId)}` }); @@ -989,7 +1154,7 @@ function isActiveWorkbenchConversation(conversation = null) { for (const message of Array.isArray(conversation.messages) ? conversation.messages.slice(-5) : []) { values.push(message?.status, message?.runnerTrace?.sessionStatus, message?.sessionStatus); } - return values.some((value) => ["running", "busy", "pending", "active", "queued"].includes(textValue(value).toLowerCase())); + return values.some((value) => ["running", "busy", "pending", "queued"].includes(textValue(value).toLowerCase())); } function isWorkbenchOrphanActiveTrace(snapshot) { diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index b3788ce5..ab282683 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -51,6 +51,7 @@ import { handleCodeAgentChatHttp, handleCodeAgentChatResultHttp, handleCodeAgentInspectHttp, + handleCodeAgentSteerHttp, handleCodeAgentTraceHttp } from "./server-code-agent-http.ts"; import { handleM3IoControlHttp } from "./server-m3-http.ts"; @@ -452,6 +453,13 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (request.method === "POST" && url.pathname === "/v1/agent/chat/steer") { + const nextOptions = await codeAgentOptions(request, response, options); + if (!nextOptions) return; + await handleCodeAgentSteerHttp(request, response, nextOptions); + return; + } + if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/trace/")) { const nextOptions = await codeAgentOptions(request, response, options); if (!nextOptions) return; diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index 575eeb17..988f051a 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -6,6 +6,7 @@ const POST_PROXY_ROUTES = new Set([ "/json-rpc", "/v1/agent/chat", "/v1/agent/chat/cancel", + "/v1/agent/chat/steer", "/v1/m3/io", "/v1/skills/uploads" ]); @@ -14,7 +15,8 @@ const PUBLIC_PROXY_ROUTES = new Set([ "POST /auth/login", "POST /auth/logout", "POST /v1/agent/chat", - "POST /v1/agent/chat/cancel" + "POST /v1/agent/chat/cancel", + "POST /v1/agent/chat/steer" ]); export function cloudWebProxyRoutePolicy(method, pathname) { diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index 5c995778..5d7cc298 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -89,3 +89,12 @@ test("cloud web proxies authenticated account workbench write routes", () => { routeKey: "POST /v1/workbench/workspace/ws_default/events" }); }); + +test("cloud web proxies Code Agent steer through the Web-equivalent public API path", () => { + assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/agent/chat/steer"), { + proxy: true, + authRequired: false, + publicRoute: true, + routeKey: "POST /v1/agent/chat/steer" + }); +}); diff --git a/internal/dev-entrypoint/http.mjs b/internal/dev-entrypoint/http.mjs index 8a5df80f..9c3d9987 100644 --- a/internal/dev-entrypoint/http.mjs +++ b/internal/dev-entrypoint/http.mjs @@ -109,31 +109,16 @@ export function proxyHttpRequest({ request, response, upstream, timeoutMs = DEFA const target = new URL(request.url || "/", upstream); const traceId = request.headers["x-trace-id"] ?? null; let timedOut = false; - const timeout = setTimeout(() => { - timedOut = true; - proxy.destroy(new Error(`upstream timed out after ${timeoutMs}ms`)); - }, timeoutMs); - const proxy = httpRequest( - target, - { - method: request.method, - headers: { - ...request.headers, - host: target.host - } - }, - (upstreamResponse) => { - response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); - upstreamResponse.on("end", () => clearTimeout(timeout)); - upstreamResponse.on("close", () => clearTimeout(timeout)); - upstreamResponse.pipe(response); - } - ); let settled = false; - - proxy.on("error", (error) => { - clearTimeout(timeout); - if (settled || response.headersSent) { + let timeout = null; + const clearProxyTimeout = () => { + if (timeout) clearTimeout(timeout); + timeout = null; + }; + const sendProxyFailure = (error) => { + clearProxyTimeout(); + if (settled) return; + if (response.headersSent) { response.destroy(error); return; } @@ -170,7 +155,37 @@ export function proxyHttpRequest({ request, response, upstream, timeoutMs = DEFA traceId, message: userMessage }); - }); + }; + const proxy = httpRequest( + target, + { + method: request.method, + headers: { + ...request.headers, + host: target.host + } + }, + (upstreamResponse) => { + if (settled) { + upstreamResponse.resume(); + return; + } + settled = true; + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.on("end", clearProxyTimeout); + upstreamResponse.on("close", clearProxyTimeout); + upstreamResponse.pipe(response); + } + ); + + timeout = setTimeout(() => { + timedOut = true; + const error = new Error(`upstream timed out after ${timeoutMs}ms`); + sendProxyFailure(error); + proxy.destroy(error); + }, timeoutMs); + + proxy.on("error", sendProxyFailure); request.pipe(proxy); } diff --git a/internal/dev-entrypoint/http.test.mjs b/internal/dev-entrypoint/http.test.mjs index 3caf3034..a35c9387 100644 --- a/internal/dev-entrypoint/http.test.mjs +++ b/internal/dev-entrypoint/http.test.mjs @@ -19,6 +19,12 @@ test("cloud web route policy proxies public Code Agent chat without gating other publicRoute: true, routeKey: "POST /v1/agent/chat/cancel" }); + assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/agent/chat/steer"), { + proxy: true, + authRequired: false, + publicRoute: true, + routeKey: "POST /v1/agent/chat/steer" + }); assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/m3/io"), { proxy: true, authRequired: true, diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index fbd86a3f..944c65ab 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -499,6 +499,66 @@ test("hwlab-cli client agent send can replay continuation from an existing trace assert.equal(JSON.stringify(result.payload).includes("hwlab_session=session-a"), false); }); +test("hwlab-cli client agent steer posts to Web-equivalent steer route", async () => { + const calls: any[] = []; + const result = await runHwlabCli([ + "client", + "agent", + "steer", + "trc_target_steer", + "--base-url", + "http://web.test", + "--cookie", + "hwlab_session=session-a", + "--message", + "please include STEER_MARK", + "--steer-trace-id", + "trc_steer_cli", + "--conversation-id", + "cnv_steer", + "--session-id", + "ses_steer", + "--thread-id", + "thread-steer" + ], { + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null }); + return new Response(JSON.stringify({ + ok: true, + accepted: true, + status: "running", + route: "/v1/agent/chat/steer", + traceId: "trc_target_steer", + steerTraceId: "trc_steer_cli", + conversationId: "cnv_steer", + sessionId: "ses_steer", + agentRun: { runId: "run_steer", targetCommandId: "cmd_turn", steerCommandId: "cmd_steer" }, + traceUrl: "/v1/agent/chat/trace/trc_target_steer", + resultUrl: "/v1/agent/chat/result/trc_target_steer" + }), { status: 202 }); + }, + sleep: async () => {} + }); + + assert.equal(result.exitCode, 0); + assert.equal(calls[0].url, "http://web.test/v1/agent/chat/steer"); + assert.equal(calls[0].init.headers["x-trace-id"], "trc_target_steer"); + assert.equal(calls[0].init.headers.prefer, "respond-async"); + assert.equal(calls[0].body.traceId, "trc_target_steer"); + assert.equal(calls[0].body.targetTraceId, "trc_target_steer"); + assert.equal(calls[0].body.steerTraceId, "trc_steer_cli"); + assert.equal(calls[0].body.message, "please include STEER_MARK"); + assert.equal(calls[0].body.conversationId, "cnv_steer"); + assert.equal(calls[0].body.sessionId, "ses_steer"); + assert.equal(calls[0].body.threadId, "thread-steer"); + assert.equal(result.payload.action, "client.agent.steer"); + assert.equal(result.payload.route.path, "/v1/agent/chat/steer"); + assert.equal(result.payload.traceId, "trc_target_steer"); + assert.equal(result.payload.steerTraceId, "trc_steer_cli"); + assert.equal(result.payload.body.agentRun.steerCommandId, "cmd_steer"); + assert.equal(result.payload.waitPolicy.webEquivalent, true); +}); + test("hwlab-cli client agent trace shows assistant stream text in compact output", async () => { const result = await runHwlabCli(["client", "agent", "trace", "trc_test", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], { fetchImpl: async () => new Response(JSON.stringify({ diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 00f37f8b..3a14d975 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -129,6 +129,7 @@ function help() { "hwlab-cli client agent trace TRACE_ID [--render web]", "hwlab-cli client agent inspect --trace-id TRACE_ID", "hwlab-cli client agent cancel TRACE_ID", + "hwlab-cli client agent steer TRACE_ID --message TEXT|--message-file PATH", "hwlab-cli client harness submit --message TEXT --provider-profile deepseek|codex-api|minimax-m3", "hwlab-cli client harness wait TRACE_ID --timeout-ms 50000", "hwlab-cli client harness audit TRACE_ID --require-bootsharp" @@ -603,6 +604,7 @@ async function agentCommand(context: any) { const response = await requestJson({ ...context, method: "GET", path: pathName }); return responsePayload("client.agent.inspect", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) }); } + if (subcommand === "steer") return agentSteer(context); if (subcommand === "cancel") { const traceId = requiredText(context.rest[1] ?? context.parsed.traceId, "traceId"); const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/chat/cancel", body: clean({ traceId, conversationId: text(context.parsed.conversationId), sessionId: text(context.parsed.sessionId), threadId: text(context.parsed.threadId) }), extraHeaders: { "x-trace-id": traceId } }); @@ -621,11 +623,55 @@ function agentHelp() { "result TRACE_ID [--full]", "trace TRACE_ID [--render web] [--limit N] [--full]", "inspect --trace-id TRACE_ID|--conversation-id ID|--session-id ID|--thread-id ID", + "steer TRACE_ID --message TEXT|--message-file PATH [--conversation-id ID] [--session-id ID] [--thread-id ID]", "cancel TRACE_ID" ] }); } +async function agentSteer(context: any) { + const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId ?? context.parsed.targetTraceId); + const message = await messageValue(context); + if (!message) throw cliError("message_required", "client agent steer requires --message or stdin text"); + const steerTraceId = text(context.parsed.steerTraceId) || makeId("trc_steer"); + const body = clean({ + traceId, + targetTraceId: traceId, + steerTraceId, + message, + conversationId: text(context.parsed.conversationId), + sessionId: text(context.parsed.sessionId), + threadId: text(context.parsed.threadId) + }); + const response = await requestJson({ + ...context, + method: "POST", + path: "/v1/agent/chat/steer", + body, + timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS, + extraHeaders: { + "x-trace-id": traceId, + "prefer": "respond-async", + "x-hwlab-short-connection": "1" + } + }); + return responsePayload("client.agent.steer", response, context, { + route: route("POST", "/v1/agent/chat/steer"), + traceId, + steerTraceId, + body: responseBodyForCli(response.body, context.parsed), + waitPolicy: { + defaultWait: false, + webEquivalent: true, + reason: "Steer is applied to the active Code Agent turn; observe the original target trace/result to confirm whether the runner accepted and applied it.", + nextCommands: [ + `hwlab-cli client agent result ${traceId}`, + `hwlab-cli client agent trace ${traceId} --render web` + ] + } + }); +} + function agentInspectPath(context: any) { const params = new URLSearchParams(); const traceId = text(context.rest[1] ?? context.parsed.traceId); @@ -1786,6 +1832,7 @@ function compactApiBody(body: any): any { adapter: body.adapter, sourceKind: body.sourceKind, liveBackend: body.liveBackend, + agentRun: compactAgentRun(body.agentRun), rowCount: body.rowCount, columns: Array.isArray(body.columns) ? body.columns : undefined, roles: Array.isArray(body.roles) ? body.roles : undefined, @@ -1809,6 +1856,29 @@ function compactApiBody(body: any): any { }); } +function compactAgentRun(value: any) { + if (!value || typeof value !== "object") return undefined; + return pruneUndefined({ + adapter: value.adapter, + runId: value.runId, + commandId: value.commandId, + targetCommandId: value.targetCommandId, + steerCommandId: value.steerCommandId, + lastSteerCommandId: value.lastSteerCommandId, + attemptId: value.attemptId, + runnerId: value.runnerId, + jobName: value.jobName, + namespace: value.namespace, + backendProfile: value.backendProfile, + status: value.status, + runStatus: value.runStatus, + commandState: value.commandState, + terminalStatus: value.terminalStatus, + reuseEligible: value.reuseEligible, + valuesPrinted: false + }); +} + function assistantText(body: any) { const direct = text(body?.reply?.content ?? body?.message?.content ?? body?.assistantText); if (direct) return direct; @@ -1873,7 +1943,7 @@ function auditTrace(traceObject: any, { requireBootsharp = false } = {}) { "long_device_pod_cli_path", /node\s+\/app\/(?:tools\/device-pod-cli\.mjs|skills\/device-pod-cli\/scripts\/device-pod-cli\.mjs)/u, textValue, - "Prefer hwpod in HWLAB code-agent runners; the long wrapper path is only a compatibility fallback when hwpod is absent." + "Use hwpod in HWLAB code-agent runners; if hwpod is absent, fix the runner image/package instead of invoking the long wrapper path." ), signal( "temporary_script_workaround", diff --git a/web/hwlab-cloud-web/app-device-pod.ts b/web/hwlab-cloud-web/app-device-pod.ts index c691d705..fcd3db5a 100644 --- a/web/hwlab-cloud-web/app-device-pod.ts +++ b/web/hwlab-cloud-web/app-device-pod.ts @@ -102,11 +102,11 @@ async function submitAgentMessage(value, options = {}) { const traceId = options.traceId ?? nextProtocolId("trc"); const activeConversationId = options.conversationId ?? state.conversationId ?? nextProtocolId("cnv"); const requestedSessionId = options.sessionId === undefined ? sessionIdForNextRequest() : options.sessionId; - const requestedThreadId = options.threadId === undefined ? threadIdForNextRequest() : options.threadId; + const threadId = options.threadId === undefined ? threadIdForNextRequest() : options.threadId; const pendingContinuity = pendingSessionContinuity({ conversationId: activeConversationId, sessionId: requestedSessionId, - threadId: requestedThreadId, + threadId, retryOf: options.retryOf }); state.conversationId = activeConversationId; @@ -119,7 +119,7 @@ async function submitAgentMessage(value, options = {}) { traceId, conversationId: activeConversationId, sessionId: requestedSessionId, - threadId: requestedThreadId, + threadId, retryOf: options.retryOf ?? null, createdAt: new Date().toISOString() }; @@ -132,7 +132,7 @@ async function submitAgentMessage(value, options = {}) { traceId, conversationId: activeConversationId, sessionId: requestedSessionId, - threadId: requestedThreadId, + threadId, sessionContinuity: pendingContinuity, retryInput: value, retryOf: options.retryOf ?? null, @@ -145,7 +145,7 @@ async function submitAgentMessage(value, options = {}) { traceId, conversationId: activeConversationId, sessionId: requestedSessionId, - threadId: requestedThreadId, + threadId, messageId: pendingMessage.id, input: value, lastActivityAt: Date.now(), @@ -165,14 +165,13 @@ async function submitAgentMessage(value, options = {}) { const stopTraceStream = subscribeRunnerTrace(traceId, pendingMessage.id); try { - const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId, requestedThreadId); + const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId, threadId); stopTraceStream(); if (state.canceledTraces.has(traceId)) return; const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); const updatedMessage = applyCodeAgentResultToMessage(pendingMessage.id, result, { baseMessage: pendingMessage, pendingContinuity, - requestedThreadId, retryInput: value, retryOf: options.retryOf ?? null }); @@ -200,7 +199,7 @@ async function submitAgentMessage(value, options = {}) { text: presentation.text, status: failedStatus, traceId: error.traceId || traceId, - threadId: requestedThreadId, + threadId, sessionContinuity: failedSessionContinuity(pendingContinuity, error), updatedAt: new Date().toISOString(), retryInput: value, @@ -292,7 +291,7 @@ function applyCodeAgentResultToMessage(messageId, result, options = {}) { traceId: result.traceId || current.traceId, conversationId: result.conversationId || result.sessionId || state.conversationId, sessionId: result.sessionId || result.session?.sessionId || result.sessionReuse?.sessionId || state.sessionId, - threadId: resultThreadId || options.requestedThreadId || current.threadId || state.threadId, + threadId: resultThreadId || current.threadId || state.threadId, sessionContinuity, messageId: result.messageId, provider: result.provider, @@ -782,9 +781,9 @@ function renderProbePending() { renderCodeAgentSummary(); } -async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc"), requestedSessionId = sessionIdForNextRequest(), requestedThreadId = threadIdForNextRequest()) { +async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc"), requestedSessionId = sessionIdForNextRequest(), threadIdForRequest = threadIdForNextRequest()) { const sessionId = requestedSessionId || undefined; - const threadId = requestedThreadId || undefined; + const threadId = threadIdForRequest || undefined; const response = await fetchJson("/v1/agent/chat", { method: "POST", headers: { diff --git a/web/hwlab-cloud-web/auth.test.ts b/web/hwlab-cloud-web/auth.test.ts index 087a8d21..79cbebf8 100644 --- a/web/hwlab-cloud-web/auth.test.ts +++ b/web/hwlab-cloud-web/auth.test.ts @@ -20,7 +20,7 @@ test("server auth is authoritative when restoring the workbench", async () => { assert.equal(session.authenticated, true); assert.equal(session.mode, "server"); - assert.deepEqual(session.user, { username: "server-admin" }); + assert.equal(session.user.username, "server-admin"); assert.deepEqual(harness.fetchCalls.map((call) => call.path), ["/auth/session"]); assert.deepEqual(harness.fetchCalls.map((call) => call.method), ["GET"]); assert.equal(harness.loginShell.hidden, true);