feat(managed-agent): execute pinned AgentDefinition revisions (#13530)

* docs(managed-agent): design AgentDefinition execution (D8b and D8c)

Proposes how a Session pins a stored AgentDefinition revision (D8b) and how
the definition's fields take effect (D8c): permission_policy and tools through
the existing per-Session approval mode and tool profile pins (Java only), then
model and instructions through the Hosted Harness. Content that cannot take
effect refuses admission instead of being dropped. The D8a design's open
questions now link here.

Decisions for review are listed in section 7.

* docs(managed-agent): address D8b/D8c design review

- Default the definition tool-profile allowlist to files/1; files/2 becomes
  an operator opt-in after the #13166 rollout, since Java has no switch.
- Add W1 recovery's source validator as the fourth qwen-code check and keep
  the pinned revision and digest in the recovery source.
- Refuse approval_timeout_ms in v1 instead of accepting a value the
  connector would not deliver.

* feat(managed-agent): pin and compile stored AgentDefinition revisions (D8b+D8c-1) (#12867)

Session admission resolves a stored definition: an explicit agent_revision
pins that revision, an omitted one resolves the latest revision locked
under FOR UPDATE in the creation transaction, and a missing agent or
revision answers 404 agent_not_found. The pinned (agent_id, revision,
digest) lands on the new agent_definition_digest column (V53), and replay
returns the original Session without resolving again.

D8c-1 compiles permission_policy and tools into the approval mode and
tool profile the Session already pins: shell profiles never pair with
yolo, unbound Sessions stay model-only, and selectable profiles sit
behind the qwen.managed-agent.definition-tool-profiles allowlist
(default hosted-workspace-files/1). model, instructions, skills,
mcp_servers and environment_template_id refuse with 409
agent_definition_unsupported naming the field in error.field instead of
being dropped.

The four hard-coded qwen-code gates relax to qwen-code-or-pinned:
later-Turn admission, Workspace execution authorization and W1 recovery
sources, which now carry the pinned revision and digest. PublicSession
gains the optional agent_digest and the contract becomes 1.34. Two
design amendments ride along: FOR UPDATE implements the stated locked
read (H2 has no FOR SHARE) and the refusal field is named error.field,
matching the envelope shape.

* chore(web-shell): regenerate managed agent API types for error.field

* test(managed-agent): include V55 in migration upgrade assertions

The MariaDB integration run applies the new definition-digest migration correctly. Keep the strict ordered upgrade expectation in sync while preserving the applied-history checks.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(managed-agent): correct three review findings on stored-definition execution

- insertChildSessionCommand now copies agent_definition_digest from the
  parent, so a child of a stored-definition Session is no longer born
  with a NULL digest (non-executable, and poisoning W1 recovery).
- hosted-harness-session: a deadline expiry keeps naming the deadline;
  only a generic error outcome is reclassified as model_unavailable.
- README and the definition-service javadoc no longer claim Sessions do
  not use stored definitions.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(managed-agent): accept published agent instructions in W1 recovery

The W1 recovery reader's PUBLISHED allowlist covered only the three
managed-tool-result kinds, so reading a definition Session's digest-
addressed instructions resource threw resource_layout_unsupported and
failed the whole storage's capture/verify (R1-3). The reader now also
accepts managed-agent-instructions up to 64 KiB, mirroring the
allowance ManagedSessionStore.verifyStoredResource already carries,
and the mixed-storage capture test publishes non-empty instructions
through the production publisher and reads them back through the
recovery RPC.

Also fixes two test/doc findings from the same review round: the two
compiler-test @CsvSource text blocks were passed as value, so JUnit
executed one row per annotation (14 of 16 rows dead) — now textBlock =
(R1-50); and the OpenAPI route descriptions for createAgent and
createSession (plus their WebShell twins) still claimed Sessions do
not use definitions and public callers cannot select the profile,
which this PR's own admission changes falsify (R1-7, R1-6). WebShell
generated types regenerated.

* fix(managed-agent): round-2 review — migration version, stop-load pin, store guard, doc truths

R2-3 (fails-closed): the new migration took V58, below versions main
already shipped (V60/V62/V64), so with Flyway's default outOfOrder=false
it resolves-but-never-applies on any upgraded database and the
agent_definition_digest column every Session INSERT and read touches
silently does not exist. Renamed to V66, the next free version.

R2-1: messageStopAttachment is the third LoadHarnessSession site and the
only one that never carried the pinned agentDefinition, so a
definition-pinned child Session's message stop was refused with 409
hosted_agent_definition_conflict. The load now carries the pin, and
aMessageStopOfAPinnedSessionCarriesTheAgentDefinition pins it red/green.

R2-2: Session admission pinned a stored definition even when the Managed
Session Store the Harness create needs was disabled — a supported-looking
config (harness on, store off, files off) answered 202 for a Session that
could never run. validateWorkspaceFiles now refuses that shape at startup,
and ManagedAgentPropertiesTest witnesses it.

R1-28: two of the four falsified statements were still standing — the
README's revision-source sentence (a pinned Session records the revision
resolved at admission, not QWEN_MANAGED_AGENT_REVISION) and the
AgentDefinitionRequest javadoc's 'no field changes Session execution yet'.

Also from the same round: the 64 KiB agent-instructions bound is now one
named constant (MAX_AGENT_INSTRUCTIONS_BYTES) instead of four bare
literals across publisher, both verifiers and admission (R2-5); the
createAgent description's stale (v1.34) label is now v1.41 and names the
operator allowlists that gate model and tool profile (R2-13), the
webShellCreateSession description no longer promises a fixed server-owned
profile for definition-pinned Sessions (R2-6), and the workspaceTurns
predicate wording now matches the implementation (active and undeleted,
both branches on the frozen execution profile) (R2-11); the generated
managed-agent-api.ts was regenerated.

* fix(managed-agent): pin V66 in the workspace migration IT list

842d8a62e8 renumbered the agent definition digest migration from V58 to
V66, but WorkspaceMigrationMySqlIT still asserted the old pinned version
list. upgradesCurrentMainWithoutChangingAppliedMigrations therefore
failed with the applied set [48..57, 60, 62, 64, 66] against the expected
[48..57, 58, 60, 62, 64].

Align the list with the migrations actually present in the tree, keeping
installed_rank (ascending version) order.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmv32n54k1x

* docs(managed-agent): name V66 as the definition-digest migration in the design

Review round 2 renamed the migration V58→V66 (main had since shipped
V60/V62/V64 and Flyway never applies a version below the applied
maximum), but §10 of both design docs still said 'the database
migration is V58'. Both now name V66 with the reason, matching the
shipped V66__managed_session_agent_definition_digest.sql. The PR
description's V55 references are updated the same way, keeping the
two historical repair notes as records with the superseded numbering
marked inline.

---------

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
易良andQwen-Coder authored and GitHub committed 2026-10-11 04:00:50 +00:00
1 parent 543f03e5b1
commit fa18c9fc83
46 files changed
+2610 -67

No files matched your search

@@ -115,6 +115,9 @@ v1.28 first.
3. How do `tools` and `permission_policy` map onto the frozen Hosted tool
profiles and the approval mode a Session pins today?
[AgentDefinition execution (D8b and D8c)](2026-10-07-managed-agent-definition-execution.md)
proposes answers to these questions.
## 6. Verification
- `ManagedAgentDefinitionTest`: creation and replay, unchanged and changed
@@ -97,6 +97,9 @@ v1.29.0。该版本号假定 #13112 先以 v1.28 合入。
3. `tools` 与 `permission_policy` 如何映射到冻结的 Hosted 工具 profile,以及
会话当前固定的审批模式?
[AgentDefinition 生效(D8b 与 D8c)](2026-10-07-managed-agent-definition-execution.zh-CN.md)
给出了这些问题的建议答案。
## 6. 验证
- `ManagedAgentDefinitionTest`:创建与回放、内容不变与变化的更新、revision 读取、
@@ -0,0 +1,345 @@
# Managed AgentDefinition Execution (Stages D8b and D8c)
[English](2026-10-07-managed-agent-definition-execution.md) | [简体中文](2026-10-07-managed-agent-definition-execution.zh-CN.md)
Status: D8b, D8c-1 and D8c-2 implemented in #13530; reviewer decisions in section 7 remain pending
Date: 2026-10-07
Issue: [#12867](https://github.com/QwenLM/qwen-code/issues/12867), part of [#12380](https://github.com/QwenLM/qwen-code/issues/12380)
Builds on: [AgentDefinition revisions (D8a)](2026-10-01-managed-agent-definitions.md)
Baseline: `main` at `ac497aeed9`, OpenAPI 1.33.0
## 1. Problem
D8a stores immutable definition revisions under `/v1/agents`, but no Session
uses them:
- **Admission ignores definitions.** Session creation stores the deployment
setting `QWEN_MANAGED_AGENT_REVISION` (default `1`) as `agent_revision`, and
any other requested revision answers `400 unsupported_feature`
(`ManagedAgentStore.java:370-375`).
- **`qwen-code` is hard-coded in four places:** Session admission
(`ManagedAgentStore.java:266`), the Workspace execution authority
(`WorkspaceExecutionStore.java:67,98`), admission of later Turns on a
bound Session (`ManagedAgentService.java:871`), and the W1 recovery source
validator (`WorkspaceRecoveryStore.currentSource`). The last one walks every
Session on a storage, including closed and deleted ones, and fails the whole
capture with `source_drift` on any other `agent_id`.
- **The Harness has no notion of a revision.** Java's `POST /session` to the
Hosted Harness carries only `approvalMode`, `approvalTimeoutMs`,
`toolProfile` and the Session Store descriptor
(`QwenHostedHarnessConnector.java:322-345`). Model, system prompt, cwd,
sampling and the round budget come from the Harness process: every Turn
loads them with `loadSettings(cwd, {skipWorkspaceSettings,
workspaceTrusted: false})` and `loadCliConfig`
(`hosted-harness-model.ts:82-104`).
- **Two per-Session pins already exist.**
- **Approval mode:** `managed_agent_session.approval_mode` (V24). It is
taken from the deployment setting `QWEN_MANAGED_AGENT_APPROVAL_MODE`
(default `yolo`) and written only for bound Sessions.
- **Tool profile:** `managed_agent_session.tool_profile` (V35). Bound
Sessions get the literal `hosted-workspace-files/1`
(`ManagedAgentStore.java:409`).
- The Harness records both in the immutable `managed-definition` resource
(`hosted-harness-session.ts:1704-1727`), compares them on every load, and
answers `409 hosted_tool_profile_conflict` on a mismatch.
## 2. Constraints from the upstream design
The [code_agent design][upstream] (v1.13, pinned at `6891216`) fixes these
rules, and this design keeps them:
1. **A Session pins its revision and never drifts.** Creation fixes
`agentRevision`; a newer revision never changes an existing Session.
Upgrading a definition means a new Session; there is no hot switch.
2. **"Latest" is resolved once, before the Session is created.**
3. **Configuration is never dropped silently.** If a definition asks for a
capability that cannot run, admission refuses it. It must not run without
that configuration and claim compatibility.
4. **Credentials stay out of definitions.** Model credentials resolve only
in the Harness, through its existing credential references.
5. **Digests are verified.** The Harness checks the revision and digest it
loads and refuses to run on a mismatch.
Upstream describes a trusted publish step that writes an `AgentBundle` to a
shared directory. This design adds no separate publish service: a D8a
revision is already immutable and digest-addressed, so storing a revision is
publishing it (decision 1 in section 7).
## 3. Goals
- **D8b:** a new Session can select a stored definition and pins
`(agent_id, revision, digest)`. An omitted revision resolves to the latest
at admission, and a replay never resolves it again.
- **D8c-1:** `permission_policy` and `tools` take effect through the two
existing per-Session pins. Java only.
- **D8c-2:** `model` and `instructions` take effect. The Harness gains a
per-Session model override and instruction section, and verifies the
digest on load.
- **No silent drops:** admission refuses a definition whose content cannot
take effect and names the field.
## 4. Non-goals
- A separate publish step, a default-revision pointer, and listing or
deleting definitions (unchanged from D8a).
- Applying `skills` and `mcp_servers`, which belong to Stage H. Until then a
non-empty value refuses admission.
- Applying `environment_template_id`, which belongs to Environment and
Runtime templates. A non-null value refuses admission.
- Switching the definition of an existing Session.
- A per-definition approval timeout (`approval_timeout_ms`). v1 refuses it;
see section 9.
- The reader, operator and owner role matrix from section 10 of the
contract.
- Any change to the behavior of the built-in `qwen-code` agent.
## 5. Design
### 5.1 Two kinds of agent
| | `qwen-code` (built-in) | Stored definition (`agent_<32 hex>`) |
| ------------------------------ | ----------------------------------------- | ---------------------------------------------------------- |
| Source | Deployment settings; no stored row | D8a's `managed_agent_definition` |
| Revision | `QWEN_MANAGED_AGENT_REVISION`, as today | Resolved at admission: explicit, or the latest |
| Digest | None (`NULL`) | The pinned revision's `digest` |
| Approval mode and tool profile | Unchanged (deployment default, `files/1`) | Compiled from the definition (5.3) |
| Model and instructions | Harness process settings | Deployment defaults until D8c-2 (5.3), then the definition |
`qwen-code` does not become a seeded definition. Seeding would need a row per
tenant, and its behavior is defined by deployment settings, so a stored copy
could disagree with what actually runs. The four `"qwen-code".equals(...)`
checks (section 1) become "the Session is `qwen-code` or pins an executable
definition".
### 5.2 Session admission (D8b)
The existing creation transaction gains these steps:
1. **Resolve the revision.** `qwen-code` keeps its current path. Otherwise:
- an explicit `agent_revision` reads that row;
- an omitted one reads the latest row for the agent with
`SELECT … FOR UPDATE`, in the same transaction as the Session insert, so
a concurrent update cannot interleave;
- a missing agent or revision answers `404 agent_not_found`. The create
route already declares `404`; its description is filled in.
2. **Compile the execution settings** by the rules in 5.3. Content that
cannot take effect answers `409 agent_definition_unsupported`, with the
field named in the error envelope as `error.field`.
3. **Store** `agent_id`, `agent_revision` (decimal string) and a new column
`agent_definition_digest CHAR(64) NULL`, and take `approval_mode` and
`tool_profile` from the compiled settings.
4. **Request digest and replay.**
- The request digest keeps its current rule: `agentRevision` counts only
when the caller sent it.
- A replay goes through the creation receipt and returns the original
Session; it never resolves the latest revision again. The receipt
already guarantees this; a test pins it by publishing revision 2
between two retries and expecting revision 1 on the replay.
5. **Public fields.** `PublicSession.agent_revision` exists already; an
optional `agent_digest` is added so a caller can check the pin. The
contract takes a minor version.
Revisions are immutable and have no delete route, so a pinned revision is
always readable for recovery and load. A future delete must first handle
revisions that Sessions still pin.
### 5.3 Field rules (v1)
D8a stores `model`, `permission_policy` and the items of `tools` as open
objects without checking them. This design leaves D8a's storage alone and
compiles at admission, so stored definitions stay valid, and unsupported
content fails with a precise error when a Session is created from it.
| Field | Accepted v1 shape | Effect | Stage |
| ------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `permission_policy` | `{}` (deployment default), or `{"approval_mode": "default" \| "auto-edit" \| "yolo"}` | Written to `approval_mode`. The timeout stays the deployment's `QWEN_MANAGED_AGENT_APPROVAL_TIMEOUT`. `plan`, `approval_timeout_ms` and any other key are refused | D8c-1 |
| `tools` | `[]`, or exactly one `{"type": "hosted_profile", "profile": "<id>"}` | Written to `tool_profile`. A bound Session needs one entry; an unbound Session needs `[]` (model only) | D8c-1 |
| `model` | D8c-1: only `{}` (deployment default). D8c-2: `{}` or `{"id": "<model id>"}` | D8c-2 overrides it per Session in the Harness | D8c-2 |
| `instructions` | D8c-1: only `""`. D8c-2: UTF-8 up to 64 KiB | D8c-2 appends it as an agent instruction section (5.5) | D8c-2 |
| `skills`, `mcp_servers` | Omitted, `null` or `[]` | None; non-empty is refused | Stage H |
| `environment_template_id` | Omitted or `null` | None; non-null is refused | Later |
| `metadata` | Anything | Does not affect execution | — |
**Selectable profiles come from a deployment allowlist**,
`QWEN_MANAGED_AGENT_DEFINITION_TOOL_PROFILES`, which defaults to
`hosted-workspace-files/1` only.
- **`files/2`** (glob) is an explicit operator opt-in. The worker identity
cannot prove glob support, so #13166 makes its rollout operator-enforced:
drain old workers and upgrade every provisioner before any `/2` Session.
Java has no switch to check, so the allowlist is the gate: the operator
adds `hosted-workspace-files/2` only after completing that rollout. Until
then a definition selecting it is refused.
- **`shell/1` and `shell/2`** wait for public foreground Shell admission
(#13271) and must pair with the `default` approval mode; see below.
- **`mcp/1`** waits for Stage H.
**Combination rules**, each answering `409 agent_definition_unsupported`:
- A Shell profile may not pair with `yolo`. This is #13271's mandatory-asking
gate, written into the rules before the allowlist opens Shell.
- An unbound Session may not select a profile.
### 5.4 D8c-1: Java only
The approval mode and tool profile already travel to the Harness on create
and load, where they are pinned and verified. D8c-1 therefore only:
- writes the compiled values at admission, replacing the literal `files/1`
and the deployment-default approval mode;
- changes the `qwen-code` checks in later-Turn admission, the Workspace
execution authority and the W1 recovery source validator to "`qwen-code` or
a pinned, compiled definition". The recovery source also records the pinned
`agent_revision` and `agent_definition_digest`, so a restored Session keeps
its pin;
- makes `maySubmitShape` and similar checks read the Session's columns
instead of assuming a profile.
The Harness, the Runtime Broker and WebShell are unchanged. WebShell already
shows approval cards (#13107), so a Session created from a definition with
`default` approval asks through it.
### 5.5 D8c-2: model and instructions (Java and Harness)
- **Delivery.** Java adds an optional `agentDefinition` object to
`POST /session` on create: `{agentId, revision, digest, model?: {id},
instructionsRef?}`.
- Instructions do not travel in the request body. Java publishes them to a
digest-addressed Session Store resource (`managed-agent-instructions`)
and sends the reference.
- Load sends only `{agentId, revision, digest}`.
- **Harness pin.** On create the Harness records `agentDefinition` in the
`managed-definition` resource next to `toolProfile` and `approvalMode`. On
load it compares the digest and answers `409
hosted_agent_definition_conflict` on a mismatch, as it does for
`hosted_tool_profile_conflict`.
- **Application.**
- **Model:** after each Turn's `loadCliConfig`, the pinned `model.id`
overrides model selection. It must name a model the deployment has
configured (present in `modelProviders`, with resolvable credentials);
otherwise the Turn fails with `model_unavailable` and nothing runs. So
that admission can refuse early, Java keeps an allowlist,
`QWEN_MANAGED_AGENT_DEFINITION_MODELS`, which the deployment keeps
consistent with the Harness.
- **Instructions:** a separate section after the core system instruction
and before project context (QWEN.md and AGENTS.md from #13168). It never
replaces the core instruction, which carries tool-use and safety rules.
- **Not delivered:** credentials, sampling parameters and the round budget.
They stay deployment settings until a field is added for them.
### 5.6 Contract changes
- Create: describe `404 agent_not_found` and `409
agent_definition_unsupported`, and describe `agent_revision` as "omitted
resolves the latest revision at admission and pins it".
- `PublicSession` gains the optional `agent_digest`.
- A pinned model with a missing, ambiguous or unauthenticated configured route
fails the Turn before inference with `turn.failed` code `model_unavailable`.
- `AgentDefinitionRequest` is not tightened, so revisions stored under D8a
stay valid. The v1 shapes are documented in route descriptions and here.
- WebShell creation uses the same admission path (it already has `agentId`)
and needs no separate change.
## 6. Delivery
The original plan splits delivery into two slices. Both slices are now implemented in #13530 because they share the immutable definition pin and resource boundary; delivering them together makes the complete execution contract reviewable end to end:
1. **PR A, D8b and D8c-1 (Java only):** pinning, compilation of the approval
mode and tool profile, the refusal rules, the new column (the next free
Flyway version on `main` at merge time) and contract 1.41. Definitions then
have their first real effect: selecting the approval mode, and `files/2`
on deployments that have opted in.
2. **PR B, D8c-2 (Java and Hosted Harness):** delivery, pinning,
verification and application of the model and instructions.
D8b does not ship alone. Pinning a definition whose fields do nothing would
drop configuration silently (rule 3 in section 2). The alternatives are to
keep refusing agents other than `qwen-code`, as today, or to apply at least
some fields.
## 7. Decisions for review
Each item is a recommendation, followed by who should confirm it.
1. **No publish step; an omitted revision means the latest.** A stored
revision is usable at once. A default-revision pointer can be added later
for staged rollout without affecting pinned Sessions. → wenshao
2. **D8b ships with D8c-1 in one PR**, not alone (section 6). → wenshao
3. **`qwen-code` stays built in**, not a seeded definition. → wenshao
4. **Field order:** `permission_policy` and `tools` first (Java only), then
`model` and `instructions`; `skills` and `mcp_servers` with Stage H.
→ wenshao
5. **`tools` selects one frozen profile** rather than listing individual
tools; profiles are limited by a deployment allowlist that defaults to
`files/1`, `files/2` is an operator opt-in after #13166's rollout, and
Shell requires an asking mode. → doudouOUC (tool profiles), aligned with
DragonnZhang's #13271
6. **Instruction placement:** after the core system instruction and before
project context, never replacing it, up to 64 KiB. → wenshao
7. **Ownership of D8c-2's Harness part:** yiliang114 implements it;
doudouOUC reviews the profile and definition-resource changes.
→ doudouOUC
## 8. Verification
- **Java unit and contract tests:**
- an omitted and an explicit revision;
- a revision published between two retries, with the replay returning the
original;
- no partially updated read under a concurrent update;
- the refusal matrix: every unsupported shape of every field, and the
combination rules;
- under the default allowlist, a definition selecting `files/2` is refused
with `409 agent_definition_unsupported`, and accepted once the
allowlist includes it;
- `approval_timeout_ms` is refused, and a Session from a definition gets
the deployment timeout;
- W1 recovery capture and verify on a storage holding both a `qwen-code`
Session and a stored-definition Session (active and deleted), with the
pin preserved in the bundle;
- `404` across tenants;
- the `qwen-code` path unchanged.
- **Harness unit tests (PR B):**
- create pins `agentDefinition`;
- a digest mismatch on load answers `409`;
- the model override applies, and an unconfigured model fails closed;
- instructions land in the right place in the system instruction.
- **Real-stack acceptance**, following the method used on #13101 and #13107:
- run MySQL, Java, the Hosted Harness and Chromium, with every worker on
the glob build and `files/2` added to the allowlist;
- store a definition with `files/2` and `default`, and create a bound
Session from it;
- confirm glob is available and a file write shows an approval card;
- store revision 2 with `yolo`: the old Session still asks, and a new one
does not;
- for PR B, add a model switch and instructions taking effect.
## 9. Risks
- **The Java model allowlist and the Harness settings disagree:** admission
succeeds and the Turn fails. Failing closed with an explicit code covers
this, and the deployment documentation states that both lists must match.
- **The default approval mode is `yolo`:** a definition with `{}` inherits
it. That is what "deployment default" means, but the documentation must say
so, and production deployments should change the default to `default`.
- **An operator adds `files/2` before the rollout completes:** a glob call
can reach an old worker and hold the Workspace lease. The allowlist cannot
detect this; the deployment documentation repeats #13166's rollout steps
next to the setting.
- **A per-definition approval timeout is not supported yet:** adding it later
needs a per-Session pin, connector delivery (the connector sends the
deployment value today) and bounds matching the Harness (1 s to 24 h,
capped by the deployment). Until then the field is refused, not ignored.
- **Revisions stored under D8a may use shapes v1 does not support:**
creating a Session from them is refused. This is intended (no silent
drops), and the error names the field.
[upstream]: https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-java-hosted-runtime.md
## 10. D8c-2 implementation details
The model allowlist defaults to empty. Set `QWEN_MANAGED_AGENT_DEFINITION_MODELS` to comma-separated model IDs also present in the Harness deployment's `modelProviders`. The Harness requires exactly one configured route for an ID, excludes runtime-only models and resolves credentials from deployment configuration; a missing, ambiguous or unauthenticated route ends the Turn with `model_unavailable` before inference. No credentials or provider overrides are accepted in definitions.
Java publishes non-empty instructions as raw UTF-8 `managed-agent-instructions` resources in the Session Store, addressed by their SHA-256 digest. The reference joins the immutable `managed-definition` resource's transaction closure and W1 recovery closure. Create, cold load and each Turn verify its bytes; load accepts only the pinned agent ID, revision and digest and refuses missing or changed identity with `409 hosted_agent_definition_conflict`, including resident recovery and lifecycle loads. Agent instructions precede project context and survive context refreshes.
The database migration is V66 because main has since taken V58 for the child-result relay, V60 for child Workspaces, V62 for task-cancel operations and V64 for the session-message relay, and Flyway never applies a version below the applied maximum (R2-3). Conflict resolution keeps main's creator/owner actor identity and child lineage together with the pinned definition identity. `QWEN_MANAGED_AGENT_DEFINITION_TOOL_PROFILES` remains `hosted-workspace-files/1` by default; add `files/2` only after draining old workers and upgrading all provisioners as required by #13166.
@@ -0,0 +1,288 @@
# Managed AgentDefinition 生效(Stage D8b 与 D8c)
[English](2026-10-07-managed-agent-definition-execution.md) | [简体中文](2026-10-07-managed-agent-definition-execution.zh-CN.md)
状态:D8b、D8c-1 与 D8c-2 已在 #13530 实现;第 7 节决策仍待 reviewer 确认
日期:2026-10-07
Issue:[#12867](https://github.com/QwenLM/qwen-code/issues/12867),属于 [#12380](https://github.com/QwenLM/qwen-code/issues/12380)
基于:[AgentDefinition revision(D8a)](2026-10-01-managed-agent-definitions.zh-CN.md)
基线:`main` @ `ac497aeed9`,OpenAPI 1.33.0
## 1. 问题
D8a 在 `/v1/agents` 下存储不可变的定义 revision,但没有任何会话使用它们:
- **准入不使用定义。** 创建会话时,`agent_revision` 写入的是部署配置
`QWEN_MANAGED_AGENT_REVISION`(默认 `1`),请求其他 revision 一律返回
`400 unsupported_feature`(`ManagedAgentStore.java:370-375`)。
- **`qwen-code` 写死在四处:** 会话准入(`ManagedAgentStore.java:266`)、
Workspace 执行授权(`WorkspaceExecutionStore.java:67,98`)、绑定会话后续
Turn 的准入(`ManagedAgentService.java:871`),以及 W1 恢复的来源校验
(`WorkspaceRecoveryStore.currentSource`)。最后一处会遍历存储上的所有会话,
包括已关闭和已删除的,遇到其他 `agent_id` 就以 `source_drift` 让整次捕获
失败。
- **Harness 没有 revision 的概念。** Java 发给 Hosted Harness 的
`POST /session` 只带 `approvalMode`、`approvalTimeoutMs`、`toolProfile` 和
Session Store 描述(`QwenHostedHarnessConnector.java:322-345`)。模型、
system prompt、cwd、sampling 和轮次预算都来自 Harness 进程:每个 Turn 用
`loadSettings(cwd, {skipWorkspaceSettings, workspaceTrusted: false})` 与
`loadCliConfig` 加载(`hosted-harness-model.ts:82-104`)。
- **已有两项按会话固定的配置。**
- **审批模式:** `managed_agent_session.approval_mode`(V24)。取自部署
配置 `QWEN_MANAGED_AGENT_APPROVAL_MODE`(默认 `yolo`),只为绑定会话
写入。
- **工具 profile:** `managed_agent_session.tool_profile`(V35)。绑定会话
固定写入 `hosted-workspace-files/1`(`ManagedAgentStore.java:409`)。
- Harness 把这两项记录在不可变的 `managed-definition` 资源中
(`hosted-harness-session.ts:1704-1727`),每次 load 时比对,不一致则返回
`409 hosted_tool_profile_conflict`。
## 2. 上游设计的约束
[code_agent 设计][upstream](v1.13,固定在 `6891216`)确定了以下规则,本设计
沿用:
1. **会话固定 revision,不漂移。** 创建时固定 `agentRevision`;新的
revision 不会改变已有会话。升级定义需要新建会话,不支持热切换。
2. **"最新版本"只在会话创建前解析一次。**
3. **不静默丢弃配置。** 定义要求的能力无法运行时,准入直接拒绝;不能去掉这部分
配置照常运行,再宣称兼容。
4. **凭据不进定义。** 模型凭据只在 Harness 内通过既有的凭据引用解析。
5. **核验摘要。** Harness 核对装载的 revision 和 digest,不一致就拒绝运行。
上游描述了一个可信发布步骤,把 `AgentBundle` 写入共享目录。本设计不增加独立的
发布服务:D8a 的 revision 本身已经不可变、按摘要寻址,存储一个 revision 就等于
发布它(第 7 节决策 1)。
## 3. 目标
- **D8b:** 新会话可以选择已存储的定义,并固定 `(agent_id, revision, digest)`。
省略 revision 时在准入时解析为最新版本,重放不会再次解析。
- **D8c-1:** `permission_policy` 和 `tools` 通过现有的两项按会话固定配置生效。
只改 Java。
- **D8c-2:** `model` 和 `instructions` 生效。Harness 增加按会话覆盖模型和追加
指令段的能力,并在 load 时核验摘要。
- **不静默丢弃:** 定义中有无法生效的内容时,准入拒绝并指出字段。
## 4. 非目标
- 独立的发布步骤、默认 revision 指针,以及定义的列表和删除(与 D8a 相同)。
- 让 `skills` 和 `mcp_servers` 生效,这属于 Stage H。在此之前,非空值会被拒绝
准入。
- 让 `environment_template_id` 生效,这属于 Environment 与 Runtime 模板。非 null
值会被拒绝准入。
- 切换已有会话的定义。
- 按定义设置审批超时(`approval_timeout_ms`)。v1 拒绝该字段,见第 9 节。
- 契约第 10 节的 reader、operator 与 owner 角色矩阵。
- 改变内置 `qwen-code` agent 的任何行为。
## 5. 设计
### 5.1 两类 agent
| | `qwen-code`(内置) | 已存储的定义(`agent_<32 位十六进制>`) |
| ---------------------- | ---------------------------------- | --------------------------------------------- |
| 来源 | 部署配置,没有存储行 | D8a 的 `managed_agent_definition` |
| revision | 仍为 `QWEN_MANAGED_AGENT_REVISION` | 准入时解析:显式指定,或最新版本 |
| digest | 无(`NULL`) | 所固定 revision 的 `digest` |
| 审批模式与工具 profile | 不变(部署默认值、`files/1`) | 由定义编译得到(5.3) |
| 模型与 instructions | Harness 进程配置 | D8c-2 之前为部署默认值(5.3),之后由定义决定 |
`qwen-code` 不做成种子定义。种子定义需要为每个租户插入一行,而它的行为由部署
配置决定,存储的副本可能与实际运行的行为不一致。四处
`"qwen-code".equals(...)`(第 1 节)改为"会话是 `qwen-code`,或固定了一个
可执行的定义"。
### 5.2 会话准入(D8b)
现有的创建事务增加以下步骤:
1. **解析 revision。** `qwen-code` 保持现有路径。其他情况:
- 显式给出 `agent_revision` 时读取该行;
- 省略时在会话插入的同一事务中,用 `SELECT … FOR UPDATE` 锁定读取该 agent 的
最新一行,避免与并发更新交错;
- agent 或 revision 不存在时返回 `404 agent_not_found`。创建路由已声明
`404`,补上其描述。
2. **按 5.3 的规则编译执行配置。** 无法生效的内容返回
`409 agent_definition_unsupported`,字段写在错误信封的 `error.field` 中。
3. **写入** `agent_id`、`agent_revision`(十进制字符串)和新增列
`agent_definition_digest CHAR(64) NULL`,`approval_mode` 与 `tool_profile`
取编译结果。
4. **请求摘要与重放。**
- 请求摘要保持现有规则:只有调用方显式传入时,`agentRevision` 才计入。
- 重放经由创建回执返回原会话,不会再次解析最新版本。现有回执已保证这一点;
增加一个测试锁定:在两次重试之间发布 revision 2,重放仍返回 revision 1。
5. **公开字段。** `PublicSession.agent_revision` 已存在;新增可选字段
`agent_digest`,便于调用方核对固定的版本。契约升一个小版本。
revision 不可变,也没有删除路由,所以被固定的 revision 在恢复和 load 时始终
可读。将来如果增加删除,必须先处理仍被会话固定的 revision。
### 5.3 字段规则(v1)
D8a 把 `model`、`permission_policy` 和 `tools` 的元素作为开放对象存储,不做
校验。本设计不改动 D8a 的存储,只在准入时编译:已存储的定义仍然有效,不支持的
内容在用它创建会话时给出明确错误。
| 字段 | v1 接受的形状 | 效果 | 阶段 |
| ------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ------- |
| `permission_policy` | `{}`(部署默认值),或 `{"approval_mode": "default" \| "auto-edit" \| "yolo"}` | 写入 `approval_mode`。超时仍取部署的 `QWEN_MANAGED_AGENT_APPROVAL_TIMEOUT`。`plan`、`approval_timeout_ms` 与其他键一律拒绝 | D8c-1 |
| `tools` | `[]`,或恰好一项 `{"type": "hosted_profile", "profile": "<id>"}` | 写入 `tool_profile`。绑定会话需要一项;未绑定会话必须为 `[]`(仅模型) | D8c-1 |
| `model` | D8c-1:只接受 `{}`(部署默认值)。D8c-2:`{}` 或 `{"id": "<模型 id>"}` | D8c-2 在 Harness 中按会话覆盖 | D8c-2 |
| `instructions` | D8c-1:只接受 `""`。D8c-2:不超过 64 KiB 的 UTF-8 | D8c-2 作为 agent 指令段追加(5.5) | D8c-2 |
| `skills`、`mcp_servers` | 省略、`null` 或 `[]` | 无;非空则拒绝 | Stage H |
| `environment_template_id` | 省略或 `null` | 无;非 null 则拒绝 | 以后 |
| `metadata` | 任意 | 不影响执行 | — |
**可选择的 profile 由部署白名单决定**,即
`QWEN_MANAGED_AGENT_DEFINITION_TOOL_PROFILES`,默认只有
`hosted-workspace-files/1`。
- **`files/2`**(glob)需要运维显式开启。worker 身份无法证明支持 glob,所以
#13166 规定由运维保证发布顺序:任何 `/2` 会话之前,先排空旧 worker 并升级所有
provisioner。Java 没有可检查的开关,白名单就是门槛:运维完成上述发布后才把
`hosted-workspace-files/2` 加入白名单。在此之前,选择它的定义会被拒绝。
- **`shell/1` 与 `shell/2`** 等待公开前台 Shell 准入(#13271),且必须搭配
`default` 审批模式,见下文。
- **`mcp/1`** 等待 Stage H。
**组合规则**,不满足时均返回 `409 agent_definition_unsupported`:
- Shell profile 不能搭配 `yolo`。这是 #13271 的强制询问门槛,在白名单开放
Shell 之前先写进规则。
- 未绑定会话不能选择 profile。
### 5.4 D8c-1:只改 Java
审批模式和工具 profile 已经在 create 和 load 时下发给 Harness,并由它固定和
核验。因此 D8c-1 只需要:
- 准入时写入编译结果,替换写死的 `files/1` 和部署默认审批模式;
- 把后续 Turn 准入、Workspace 执行授权和 W1 恢复来源校验中的 `qwen-code`
判断,改为"`qwen-code`,或固定了已编译的定义"。恢复来源同时记录固定的
`agent_revision` 与 `agent_definition_digest`,恢复后的会话保持原有固定;
- 让 `maySubmitShape` 等判断读取会话的列,而不是假定某个 profile。
Harness、Runtime Broker 和 WebShell 都不变。WebShell 已能显示审批卡片
(#13107),由 `default` 审批的定义创建的会话会通过它询问。
### 5.5 D8c-2:模型与 instructions(Java 与 Harness)
- **下发。** Java 在 create 时的 `POST /session` 中增加可选对象
`agentDefinition`:`{agentId, revision, digest, model?: {id},
instructionsRef?}`。
- instructions 不放在请求体中。Java 把它发布为按摘要寻址的 Session Store
资源(`managed-agent-instructions`),只发送引用。
- load 只发送 `{agentId, revision, digest}`。
- **Harness 固定。** create 时 Harness 把 `agentDefinition` 记录在
`managed-definition` 资源中,与 `toolProfile`、`approvalMode` 放在一起。
load 时比对摘要,不一致则返回 `409 hosted_agent_definition_conflict`,处理
方式与 `hosted_tool_profile_conflict` 相同。
- **应用。**
- **模型:** 每个 Turn 的 `loadCliConfig` 之后,用固定的 `model.id` 覆盖
模型选择。它必须是部署已配置的模型(存在于 `modelProviders` 且凭据可解析);
否则该 Turn 以 `model_unavailable` 失败,不执行任何内容。为了让准入能提前
拒绝,Java 维护白名单 `QWEN_MANAGED_AGENT_DEFINITION_MODELS`,由部署保证与
Harness 一致。
- **instructions:** 作为单独一段,放在核心系统指令之后、项目上下文
(#13168 的 QWEN.md 与 AGENTS.md)之前。它不替换核心指令,因为工具使用与
安全规则都在核心指令里。
- **不下发:** 凭据、sampling 参数和轮次预算。在增加对应字段之前,它们仍由部署
决定。
### 5.6 契约变更
- 创建:补充 `404 agent_not_found` 和 `409 agent_definition_unsupported` 的
描述,并把 `agent_revision` 描述为"省略时在准入时解析为最新 revision 并固定"。
- `PublicSession` 增加可选字段 `agent_digest`。
- 固定模型缺少已配置路由、路由歧义或凭据不可用时,Turn 在推理前失败,
`turn.failed` 的错误码为 `model_unavailable`。
- 不收紧 `AgentDefinitionRequest`,D8a 已存储的 revision 保持有效。v1 的形状
写在路由描述和本文中。
- WebShell 创建走同一条准入路径(已有 `agentId`),无需单独修改。
## 6. 交付
原计划分为两个交付切片。目前两个切片已在 #13530 实现,因为它们共用不可变的定义固定与资源边界;一起交付可以端到端评审完整的执行契约:
1. **PR A,D8b 与 D8c-1(只改 Java):** 固定版本、编译审批模式与工具 profile、
拒绝规则、新增列(取合入时 `main` 上下一个空闲的 Flyway 版本号)以及契约
1.41。之后定义就有了第一批实际效果:选择审批模式,以及在已开启的部署上
选择 `files/2`。
2. **PR B,D8c-2(Java 与 Hosted Harness):** 模型与 instructions 的下发、
固定、核验和应用。
D8b 不单独发布。固定一个字段都不生效的定义,就是静默丢弃配置(第 2 节规则 3)。
替代做法只有两种:像现在一样继续拒绝 `qwen-code` 以外的 agent,或者至少让部分
字段生效。
## 7. 待评审的决策
每项先给建议,再写由谁确认。
1. **不设发布步骤;省略 revision 即最新版本。** 已存储的 revision 立即可用。
以后可以为灰度发布增加默认 revision 指针,不影响已固定的会话。→ wenshao
2. **D8b 与 D8c-1 放在同一个 PR**,不单独发布(第 6 节)。→ wenshao
3. **`qwen-code` 保持内置**,不做成种子定义。→ wenshao
4. **字段顺序:** 先 `permission_policy` 和 `tools`(只改 Java),再 `model` 和
`instructions`;`skills` 和 `mcp_servers` 随 Stage H。→ wenshao
5. **`tools` 选择一个冻结的 profile**,而不是列出单个工具;profile 受部署白名单
限制,白名单默认只有 `files/1`,`files/2` 在 #13166 的发布完成后由运维开启,
Shell 必须使用询问模式。→ doudouOUC(工具 profile),并与
DragonnZhang 的 #13271 对齐
6. **instructions 的位置:** 在核心系统指令之后、项目上下文之前,不替换核心指令,
上限 64 KiB。→ wenshao
7. **D8c-2 Harness 部分的归属:** 由 yiliang114 实现,doudouOUC review profile
与定义资源的改动。→ doudouOUC
## 8. 验证
- **Java 单元与契约测试:**
- 省略 revision 与显式指定 revision;
- 两次重试之间发布新 revision,重放返回原 revision;
- 并发更新下不会读到部分更新的状态;
- 拒绝矩阵:每个字段的每种不支持形状,以及组合规则;
- 默认白名单下,选择 `files/2` 的定义返回 `409 agent_definition_unsupported`,
白名单加入后则接受;
- `approval_timeout_ms` 被拒绝,由定义创建的会话使用部署的超时;
- 同一存储上同时有 `qwen-code` 会话和已存储定义的会话(含活跃与已删除)时,
W1 恢复的 capture 与 verify 均通过,bundle 中保留固定的版本;
- 跨租户返回 `404`;
- `qwen-code` 路径不变。
- **Harness 单元测试(PR B):**
- create 固定 `agentDefinition`;
- load 时摘要不一致返回 `409`;
- 模型覆盖生效,未配置的模型 fail closed;
- instructions 出现在系统指令的正确位置。
- **真实环境验收**,沿用 #13101 和 #13107 的验证方法:
- 运行 MySQL、Java、Hosted Harness 与 Chromium,所有 worker 使用支持 glob
的版本,并把 `files/2` 加入白名单;
- 存储一个 `files/2` + `default` 的定义,并用它创建绑定会话;
- 确认 glob 可用,写文件时出现审批卡片;
- 存储 revision 2 并改为 `yolo`:旧会话仍然询问,新会话不再询问;
- PR B 再增加模型切换与 instructions 生效的验证。
## 9. 风险
- **Java 的模型白名单与 Harness 配置不一致:** 准入成功而 Turn 失败。fail closed
加明确错误码兜底,部署文档写明两份列表必须一致。
- **默认审批模式是 `yolo`:** 定义写 `{}` 会继承它。这符合"部署默认值"的含义,
但文档必须写明,生产部署应把默认值改为 `default`。
- **运维在发布完成前加入 `files/2`:** glob 调用可能到达旧 worker 并占住
Workspace lease。白名单无法发现这一点;部署文档在该配置旁重复 #13166 的发布
步骤。
- **暂不支持按定义设置审批超时:** 以后增加需要按会话固定、由 connector 下发
(目前 connector 发送的是部署值),并与 Harness 的范围一致(1 秒到 24 小时,
不超过部署上限)。在此之前该字段会被拒绝,而不是被忽略。
- **D8a 下存储的 revision 可能使用 v1 不支持的形状:** 用它们创建会话会被拒绝。
这是有意为之(不静默丢弃),错误会指出字段。
[upstream]: https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-java-hosted-runtime.md
## 10. D8c-2 实现细节
模型白名单默认空。将 `QWEN_MANAGED_AGENT_DEFINITION_MODELS` 设为逗号分隔的模型 ID,同时确保这些 ID 存在于 Harness 部署的 `modelProviders`。Harness 要求每个 ID 仅匹配一条已配置路由,排除只在运行期出现的模型,并从部署配置解析凭据;缺失、歧义或凭据不可用的路由在推理前以 `model_unavailable` 结束 Turn。定义不接受凭据或 provider 覆盖。
Java 将非空 instructions 作为原始 UTF-8 的 `managed-agent-instructions` 资源发布到 Session Store,以 SHA-256 摘要寻址。引用进入不可变 `managed-definition` 资源的事务闭包与 W1 恢复闭包。创建、冷加载及每个 Turn 都核验资源字节;load 仅接受固定的 agent ID、revision 和 digest,身份缺失或变化返回 `409 hosted_agent_definition_conflict`,驻留会话的恢复和生命周期加载同样检查。Agent instructions 位于项目上下文之前,刷新上下文时仍然保留。
数据库迁移采用 V66,因为此后 main 已用 V58 增加 child 结果中继、V60 增加 child Workspace、V62 增加任务取消操作、V64 增加会话消息中继,而 Flyway 永远不会应用低于已应用最大版本号的迁移(R2-3)。冲突处理同时保留主干的 creator/owner actor 身份、child 血缘与固定的定义身份。`QWEN_MANAGED_AGENT_DEFINITION_TOOL_PROFILES` 默认仍为 `hosted-workspace-files/1`;仅在按 #13166 排空旧 worker 并升级所有 provisioner 后,才加入 `files/2`。
@@ -0,0 +1,80 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'node:crypto';
import { expect, it } from 'vitest';
import {
parseHostedAgentDefinition,
readHostedAgentInstructions,
sameHostedAgentDefinition,
} from './hosted-agent-definition.js';
const identity = {
agentId: `agent_${'a'.repeat(32)}`,
revision: '1',
digest: 'b'.repeat(64),
};
it('keeps execution settings on create and accepts only the fixed identity on load', () => {
const saved = parseHostedAgentDefinition({
...identity,
model: { id: 'configured' },
});
expect(saved?.model?.id).toBe('configured');
expect(
sameHostedAgentDefinition(
saved,
parseHostedAgentDefinition(identity, false),
),
).toBe(true);
expect(sameHostedAgentDefinition(saved, undefined)).toBe(false);
expect(sameHostedAgentDefinition(saved, { ...identity, revision: '2' })).toBe(
false,
);
expect(() =>
parseHostedAgentDefinition({ ...identity, model: { id: 'other' } }, false),
).toThrow();
expect(() =>
parseHostedAgentDefinition({ ...identity, instructions: 'inline' }),
).toThrow();
});
it('checks instruction bytes and UTF-8 before returning the pinned section', async () => {
const bytes = Buffer.from('Agent instruction 界');
const ref = {
resourceId: 'instructions',
kind: 'managed-agent-instructions',
schemaVersion: 1,
byteLength: bytes.length,
digest: createHash('sha256').update(bytes).digest('hex'),
};
const pin = parseHostedAgentDefinition({ ...identity, instructionsRef: ref });
expect(
await readHostedAgentInstructions(pin, { read: async () => bytes }),
).toBe(bytes.toString());
await expect(
readHostedAgentInstructions(pin, {
read: async () => Buffer.alloc(bytes.length),
}),
).rejects.toThrow();
const invalid = Buffer.from([0xff]);
const invalidPin = parseHostedAgentDefinition({
...identity,
instructionsRef: {
...ref,
byteLength: 1,
digest: createHash('sha256').update(invalid).digest('hex'),
},
});
await expect(
readHostedAgentInstructions(invalidPin, { read: async () => invalid }),
).rejects.toThrow();
expect(() =>
parseHostedAgentDefinition({
...identity,
instructionsRef: { ...ref, byteLength: 65537 },
}),
).toThrow();
});
@@ -0,0 +1,112 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'node:crypto';
import {
assertManagedSessionDurableRef,
type ManagedSessionDurableRef,
type ManagedSessionJsonValue,
} from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js';
import type { ManagedSessionResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-storage.js';
export interface HostedAgentDefinition {
agentId: string;
revision: string;
digest: string;
model?: { id: string };
instructionsRef?: ManagedSessionDurableRef;
}
export function parseHostedAgentDefinition(
value: unknown,
create = true,
): HostedAgentDefinition | undefined {
if (value === undefined) return undefined;
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error('Invalid agent definition.');
const pin = value as Record<string, unknown>;
const fields = create
? ['agentId', 'revision', 'digest', 'model', 'instructionsRef']
: ['agentId', 'revision', 'digest'];
if (
Object.keys(pin).some((key) => !fields.includes(key)) ||
typeof pin['agentId'] !== 'string' ||
!/^agent_[0-9a-f]{32}$/u.test(pin['agentId']) ||
typeof pin['revision'] !== 'string' ||
!/^[1-9][0-9]{0,17}$/u.test(pin['revision']) ||
typeof pin['digest'] !== 'string' ||
!/^[0-9a-f]{64}$/u.test(pin['digest'])
)
throw new Error('Invalid agent definition identity.');
const result: HostedAgentDefinition = {
agentId: pin['agentId'],
revision: pin['revision'],
digest: pin['digest'],
};
if (pin['model'] !== undefined) {
const model = pin['model'] as Record<string, unknown> | null;
if (
!model ||
typeof model !== 'object' ||
Array.isArray(model) ||
Object.keys(model).length !== 1 ||
typeof model['id'] !== 'string' ||
!model['id'].trim()
)
throw new Error('Invalid agent model.');
result.model = { id: model['id'] };
}
if (pin['instructionsRef'] !== undefined) {
const ref = assertManagedSessionDurableRef(
pin['instructionsRef'] as ManagedSessionJsonValue,
'agent instructions',
);
if (
ref.kind !== 'managed-agent-instructions' ||
ref.schemaVersion !== 1 ||
ref.byteLength > 64 * 1024
)
throw new Error('Invalid agent instructions reference.');
result.instructionsRef = ref;
}
return result;
}
export function sameHostedAgentDefinition(
saved: HostedAgentDefinition | undefined,
requested: HostedAgentDefinition | undefined,
): boolean {
return (
saved?.agentId === requested?.agentId &&
saved?.revision === requested?.revision &&
saved?.digest === requested?.digest
);
}
export async function readHostedAgentInstructions(
pin: HostedAgentDefinition | undefined,
resources: Pick<ManagedSessionResourceStore, 'read'>,
): Promise<string | undefined> {
const ref = pin?.instructionsRef;
if (!ref) return undefined;
const bytes = await resources.read(ref);
if (
bytes.byteLength !== ref.byteLength ||
bytes.byteLength > 64 * 1024 ||
createHash('sha256').update(bytes).digest('hex') !== ref.digest
)
throw new Error('Agent instructions do not match their reference.');
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
}
export class HostedModelUnavailableError extends Error {
constructor(cause?: unknown) {
super('model_unavailable: The pinned agent model is unavailable.', {
cause,
});
this.name = 'HostedModelUnavailableError';
}
}
@@ -16,6 +16,7 @@ import { runHostedHarnessTextTurn } from './hosted-harness-model.js';
let root: string;
let server: Server;
let requests: Array<{
model?: string;
messages: Array<{
role: string;
content: unknown;
@@ -68,6 +69,11 @@ beforeEach(async () => {
telemetry: { enabled: false },
modelProviders: {
openai: [
{
id: 'definition-model',
envKey: 'OPENAI_API_KEY',
baseUrl: `http://127.0.0.1:${address.port}/v1`,
},
{
id: 'pr12713-fixture',
envKey: 'OPENAI_API_KEY',
@@ -203,3 +209,30 @@ it.each([
}
},
);
it('sends the selected model and preserves core, agent and project instruction order on the wire', async () => {
await runHostedHarnessTextTurn({
sessionId: randomUUID(),
cwd: root,
history: [],
prompt: 'hello',
promptId: randomUUID(),
signal: new AbortController().signal,
agentDefinition: {
agentId: `agent_${'a'.repeat(32)}`,
revision: '1',
digest: 'b'.repeat(64),
model: { id: 'definition-model' },
},
agentInstructions: 'D8_AGENT_INSTRUCTION',
workspaceContext: { read: () => 'D8_PROJECT_CONTEXT' },
});
expect(requests).toHaveLength(1);
expect(requests[0]?.model).toBe('definition-model');
const system = JSON.stringify(
requests[0]?.messages.find((m) => m.role === 'system')?.content,
);
const agent = system.indexOf('D8_AGENT_INSTRUCTION');
expect(agent).toBeGreaterThan(100);
expect(system.indexOf('D8_PROJECT_CONTEXT')).toBeGreaterThan(agent);
});
@@ -19,6 +19,7 @@ import { SendMessageType } from '@qwen-code/qwen-code-core/core/client.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { loadCliConfig } from '../config/config.js';
import { runHostedHarnessTextTurn } from './hosted-harness-model.js';
import { HostedModelUnavailableError } from './hosted-agent-definition.js';
import type { ChatRecord } from '@qwen-code/qwen-code-core/services/chatRecordingService.js';
const state = vi.hoisted(() => ({
@@ -1370,3 +1371,95 @@ describe('Hosted Harness resume and retraction', () => {
]);
});
});
it('uses the unique configured route for a definition model', async () => {
const mocks = config([{ type: LlmEventType.Finished }]);
const switchModel = vi.fn(async () => undefined);
state.config['getAllConfiguredModels'] = () => [
{
id: 'pinned',
authType: 'openai',
registryBaseUrl: 'https://model.example/v1',
},
];
state.config['switchModel'] = switchModel;
await runHostedHarnessTextTurn({
...input,
agentDefinition: {
agentId: `agent_${'a'.repeat(32)}`,
revision: '1',
digest: 'b'.repeat(64),
model: { id: 'pinned' },
},
});
expect(switchModel).toHaveBeenCalledWith('openai', 'pinned', {
baseUrl: 'https://model.example/v1',
requireCachedCredentials: true,
});
expect(mocks.refreshAuth).toHaveBeenCalled();
});
it.each([
{ models: [] },
{
models: [
{ id: 'pinned', authType: 'openai' },
{ id: 'pinned', authType: 'anthropic' },
],
},
])(
'refuses an absent or ambiguous model before inference',
async ({ models }) => {
const mocks = config([]);
state.config['getAllConfiguredModels'] = () => models;
state.config['switchModel'] = vi.fn();
await expect(
runHostedHarnessTextTurn({
...input,
agentDefinition: {
agentId: `agent_${'a'.repeat(32)}`,
revision: '1',
digest: 'b'.repeat(64),
model: { id: 'pinned' },
},
}),
).rejects.toThrow('model_unavailable');
expect(mocks.initialize).not.toHaveBeenCalled();
expect(mocks.requests).toHaveLength(0);
},
);
it('preserves model_unavailable when early config cleanup fails', async () => {
const mocks = config([]);
state.config['getAllConfiguredModels'] = () => [];
mocks.shutdown.mockRejectedValueOnce(new Error('cleanup failed'));
await expect(
runHostedHarnessTextTurn({
...input,
agentDefinition: {
agentId: `agent_${'a'.repeat(32)}`,
revision: '1',
digest: 'b'.repeat(64),
model: { id: 'pinned' },
},
}),
).rejects.toBeInstanceOf(HostedModelUnavailableError);
expect(mocks.shutdown).toHaveBeenCalledExactlyOnceWith({
shutdownTelemetry: false,
strictResourceCleanup: true,
});
expect(mocks.initialize).not.toHaveBeenCalled();
expect(mocks.requests).toHaveLength(0);
});
it('keeps agent instructions ahead of refreshed project context', async () => {
const mocks = config([{ type: LlmEventType.Finished }]);
await runHostedHarnessTextTurn({
...input,
agentInstructions: 'AGENT_SECTION',
workspaceContext: { read: () => 'PROJECT_SECTION' },
});
expect(mocks.setUserMemory).toHaveBeenLastCalledWith(
'# Agent instructions\n\nAGENT_SECTION\n\nPROJECT_SECTION',
);
});
+64 -2
View File
@@ -14,6 +14,11 @@ import { loadCliConfig, type CliArgs } from '../config/config.js';
import { loadSettings } from '../config/settings.js';
import { writeStderrLineSafe } from '../utils/stdioHelpers.js';
import {
HostedModelUnavailableError,
type HostedAgentDefinition,
} from './hosted-agent-definition.js';
import type { HostedWorkspaceToolTurn } from './hosted-workspace-tool-turn.js';
import {
HostedHookRecoveryRequiredError,
@@ -78,6 +83,8 @@ export async function runHostedHarnessTextTurn(input: {
Pick<HostedWorkspaceToolTurn, 'resumeHookResults' | 'hookStopReason'>
>;
workspaceContext?: { read(): string | undefined };
agentDefinition?: HostedAgentDefinition;
agentInstructions?: string;
textDeltas?: HostedHarnessTextDeltas;
}): Promise<HostedHarnessModelResult> {
const settings = loadSettings(input.cwd, {
@@ -103,6 +110,43 @@ export async function runHostedHarnessTextTurn(input: {
true,
{ toolInvocationGuard: () => ({ allowed: false }) },
);
const contextWithInstructions = (context: string | undefined) =>
[
input.agentInstructions
? `# Agent instructions\n\n${input.agentInstructions}`
: undefined,
context,
]
.filter(Boolean)
.join('\n\n');
const model = input.agentDefinition?.model;
if (model) {
try {
const matches = config
.getAllConfiguredModels()
.filter((entry) => !entry.isRuntimeModel && entry.id === model.id);
if (matches.length !== 1) throw new HostedModelUnavailableError();
const selected = matches[0]!;
await config.switchModel(selected.authType, selected.id, {
requireCachedCredentials: true,
...(selected.registryBaseUrl
? { baseUrl: selected.registryBaseUrl }
: {}),
});
} catch (cause) {
try {
await config.shutdown({
shutdownTelemetry: false,
strictResourceCleanup: true,
});
} catch (cleanupCause) {
writeStderrLineSafe(
`qwen serve: Hosted Harness model cleanup failed: ${String(cleanupCause)}`,
);
}
throw new HostedModelUnavailableError(cause);
}
}
let runPromptHook: HostedPromptHookRunner | undefined;
let modelReady = false;
let historyReady = false;
@@ -206,6 +250,12 @@ export async function runHostedHarnessTextTurn(input: {
};
try {
await input.hooks?.ensureReady(input.signal);
// Seed instructions when memory reload is skipped; restore them after
// initialize() when its hierarchical memory refresh replaces user memory.
if (input.agentInstructions)
config.setUserMemory(
contextWithInstructions(input.workspaceContext?.read()),
);
await config.initialize({
signal: input.signal,
skipHooks: true,
@@ -218,7 +268,19 @@ export async function runHostedHarnessTextTurn(input: {
const authType = config.getModelsConfig().getCurrentAuthType();
if (!authType)
throw new Error('Hosted Harness model authentication is unavailable.');
await config.refreshAuth(authType, true);
if (input.agentInstructions) {
config.setUserMemory(
contextWithInstructions(input.workspaceContext?.read()),
);
}
try {
await config.refreshAuth(authType, true);
} catch (cause) {
if (model) throw new HostedModelUnavailableError(cause);
throw cause;
}
if (input.agentInstructions)
await config.getLlmClient().refreshSystemInstruction();
await input.modelScope?.bindBudget(config.getTurnBudget(), input.prompt);
runPromptHook = input.modelScope
? createHostedPromptHookRunner(config, input.modelScope)
@@ -350,7 +412,7 @@ export async function runHostedHarnessTextTurn(input: {
if (contextAvailable && contextAvailable !== injectedContext) {
// setUserMemory alone never reaches the wire: the system instruction
// was assembled during initialize() and is cached on the chat.
config.setUserMemory(contextAvailable);
config.setUserMemory(contextWithInstructions(contextAvailable));
await client.refreshSystemInstruction();
injectedContext = contextAvailable;
}
@@ -19840,3 +19840,70 @@ describe('Hosted Harness Runtime turn takeover', () => {
30_000,
);
});
it('pins stored definitions across resident and cold loads and delivers instructions to the Turn', async () => {
const server = await app();
const resources = LocalManagedSessionResourceStore.create({
runtimeBaseDir: state.root,
sessionKey: {
tenantId: 'tenant',
workspaceId: 'workspace',
sessionId: SESSION_ID,
},
});
const instructionsRef = await resources.publish(
'managed-agent-instructions',
Buffer.from('D8_PINNED_INSTRUCTIONS'),
);
const identity = {
agentId: `agent_${'a'.repeat(32)}`,
revision: '1',
digest: 'b'.repeat(64),
};
const definition = {
...identity,
model: { id: 'configured' },
instructionsRef,
};
const created = await headers(supertest(server).post('/session')).send({
sessionId: SESSION_ID,
sessionScope: 'thread',
managedSessionStore: store(),
agentDefinition: definition,
});
expect(created.status).toBe(200);
const load = (agentDefinition?: typeof identity) =>
headers(supertest(server).post(`/session/${SESSION_ID}/load`)).send({
managedSessionStore: store(),
agentDefinition,
driveRuntimeRecovery: true,
});
expect((await load()).body.error).toBe('hosted_agent_definition_conflict');
expect((await load({ ...identity, digest: 'c'.repeat(64) })).body.error).toBe(
'hosted_agent_definition_conflict',
);
expect((await load(identity)).status).toBe(200);
await headers(supertest(server).delete(`/session/${SESSION_ID}`)).expect(204);
const loaded = await load(identity);
expect(loaded.status).toBe(200);
const prompt = [{ type: 'text', text: 'hello' }];
await headers(supertest(server).post(`/session/${SESSION_ID}/prompt`))
.set('X-Qwen-Client-Id', loaded.body.clientId)
.send({
prompt,
promptId: PROMPT_ID,
payloadDigest: `sha256:${createHash('sha256').update(JSON.stringify(prompt)).digest('hex')}`,
})
.expect(202);
await vi.waitFor(() => expect(state.model).toHaveBeenCalled());
expect(state.model.mock.calls.at(-1)?.[0]).toMatchObject({
agentDefinition: definition,
agentInstructions: 'D8_PINNED_INSTRUCTIONS',
});
await vi.waitFor(async () => {
const response = await headers(
supertest(server).delete(`/session/${SESSION_ID}`),
);
expect(response.status).toBe(204);
});
});
@@ -77,6 +77,13 @@ import {
import type { ChatRecord } from '@qwen-code/qwen-code-core/services/chatRecordingService.js';
import { stripAnsiAndControl } from '@qwen-code/qwen-code-core/utils/textUtils.js';
import { writeStderrLineSafe } from '../utils/stdioHelpers.js';
import {
HostedModelUnavailableError,
parseHostedAgentDefinition,
sameHostedAgentDefinition,
readHostedAgentInstructions,
type HostedAgentDefinition,
} from './hosted-agent-definition.js';
import { runHostedHarnessTextTurn } from './hosted-harness-model.js';
import {
HostedHookSession,
@@ -213,6 +220,7 @@ const RESTORE_READ_BATCH = 32;
* what they reference is verified too; the rest are not read again.
*/
const RESTORE_CONTAINER_KINDS = new Set([
'managed-definition',
'managed-session_metadata',
'managed-file_history',
'managed-tool-outcome',
@@ -250,6 +258,7 @@ interface HostedSession {
managed: ManagedSession;
storeBaseUrl: string;
definition: Record<string, unknown> | null;
agentDefinition?: HostedAgentDefinition;
clientId: string;
cwd: string;
streams: Set<() => void>;
@@ -1644,6 +1653,14 @@ async function verifyWorkspaceRestore(
metadata['previousRecordRef'] as unknown as ManagedSessionDurableRef,
);
}
if (ref.kind === 'managed-definition') {
const definition = object(JSON.parse(bytes.toString('utf8')));
const pin = parseHostedAgentDefinition(definition?.['agentDefinition']);
if (pin?.instructionsRef) {
await readRef(pin.instructionsRef);
await readHostedAgentInstructions(pin, resources);
}
}
if (ref.kind === 'managed-tool-outcome') {
const outcome = object(JSON.parse(bytes.toString('utf8')));
if (outcome?.['manifestRef'])
@@ -2194,6 +2211,8 @@ async function eventEnvelope(
// projection stays distinguishable from both a cancellation and an
// unattributed failure.
const expired = event.payload['stopReason'] === 'deadline_exceeded';
const modelUnavailable =
event.payload['stopReason'] === 'model_unavailable';
return {
v: 1,
id: event.sequence,
@@ -2202,7 +2221,11 @@ async function eventEnvelope(
data: {
sessionId,
promptId,
code: expired ? 'hosted_turn_deadline_exceeded' : 'hosted_turn_failed',
code: expired
? 'hosted_turn_deadline_exceeded'
: modelUnavailable
? 'model_unavailable'
: 'hosted_turn_failed',
message: expired
? 'The Hosted Harness Turn exceeded its deadline.'
: 'Hosted Harness turn failed.',
@@ -2367,6 +2390,11 @@ async function executeHostedTurn(
signal: abort.signal,
modelScope,
workspaceContext,
agentDefinition: session.agentDefinition,
agentInstructions: await readHostedAgentInstructions(
session.agentDefinition,
session.managed.resources,
),
...(session.hooks ? { hooks: session.hooks } : {}),
...(toolTurn ? { toolTurn } : {}),
...(resumeFromToolResults ? { resumeFromToolResults } : {}),
@@ -2387,6 +2415,13 @@ async function executeHostedTurn(
const outcome = settledTurnOutcome(abort);
state = outcome.state;
stopReason = outcome.stopReason;
// A deadline expiry must keep naming the deadline: only a generic
// error outcome may be reclassified as a model-route failure.
if (
stopReason === 'error' &&
cause instanceof HostedModelUnavailableError
)
stopReason = 'model_unavailable';
if (state === 'error') {
// The model layer surfaces any abort as a cancellation, so a
// deadline expiry names the deadline, not the thrown cause.
@@ -2543,6 +2578,16 @@ export function registerHostedHarnessSessionRoutes(
): Promise<void> => {
const body = object(req.body);
const sessionId = create ? body?.['sessionId'] : req.params['id'];
let agentDefinition: HostedAgentDefinition | undefined;
try {
agentDefinition = parseHostedAgentDefinition(
body?.['agentDefinition'],
create,
);
} catch {
error(res, 400, 'invalid_hosted_agent_definition');
return;
}
let toolProfile = body?.['toolProfile'];
let captureBytes = body?.['captureBytes'];
if (
@@ -2644,6 +2689,14 @@ export function registerHostedHarnessSessionRoutes(
// pass this load kicks before the stop itself arrives.
const stopMessages = body?.['stopMessages'] === true;
const childWorkspaces = body?.['childWorkspaces'] === true;
if (
resident &&
!create &&
!sameHostedAgentDefinition(resident.agentDefinition, agentDefinition)
) {
error(res, 409, 'hosted_agent_definition_conflict');
return;
}
const passiveRecovery = body?.['passiveManagedRuntimeRecovery'] === true;
const driveRecovery = body?.['driveRuntimeRecovery'] === true;
const takeoverFlags = passiveRecovery || driveRecovery;
@@ -3072,6 +3125,7 @@ export function registerHostedHarnessSessionRoutes(
JSON.stringify({
engine: 'managed',
sessionId,
...(agentDefinition ? { agentDefinition } : {}),
...(toolProfile ? { toolProfile } : {}),
...(mcpServers ? { mcpServers } : {}),
...(hookCatalog ? { hookCatalog } : {}),
@@ -3120,6 +3174,22 @@ export function registerHostedHarnessSessionRoutes(
).toString('utf8'),
),
);
let savedAgentDefinition: HostedAgentDefinition | undefined;
try {
savedAgentDefinition = parseHostedAgentDefinition(
definition?.['agentDefinition'],
);
if (!sameHostedAgentDefinition(savedAgentDefinition, agentDefinition))
throw new Error('Agent definition identity changed.');
await readHostedAgentInstructions(
savedAgentDefinition,
managed.resources,
);
} catch {
await managed.close();
error(res, 409, 'hosted_agent_definition_conflict');
return;
}
const savedProfile = definition?.['toolProfile'];
if (
!create &&
@@ -3169,6 +3239,7 @@ export function registerHostedHarnessSessionRoutes(
managed,
storeBaseUrl: store.baseUrl,
definition,
agentDefinition: savedAgentDefinition,
clientId: randomUUID(),
cwd,
streams: new Set(),
@@ -5897,13 +5968,19 @@ export function registerHostedHarnessSessionRoutes(
const sessionId = req.params['id'];
void (async () => {
let toolTurn: HostedWorkspaceToolTurn | undefined;
let failureReason = 'error';
const turnResultRecord = (state: 'cancelled' | 'error' | 'completed') =>
record(session, sessionId, 'system', null, {
subtype: 'turn_result',
systemPayload: {
promptId,
state,
stopReason: state === 'completed' ? 'end_turn' : state,
stopReason:
state === 'completed'
? 'end_turn'
: state === 'error'
? failureReason
: state,
endedAt: Date.now(),
},
});
@@ -6154,6 +6231,11 @@ export function registerHostedHarnessSessionRoutes(
promptId,
signal: abort.signal,
workspaceContext,
agentDefinition: session.agentDefinition,
agentInstructions: await readHostedAgentInstructions(
session.agentDefinition,
session.managed.resources,
),
toolTurn,
resumeFromToolResults: resumeParts,
textDeltas: deltas,
@@ -6166,6 +6248,8 @@ export function registerHostedHarnessSessionRoutes(
} catch (cause) {
if (cause instanceof HostedToolRecoveryRequiredError) throw cause;
state = abort.signal.aborted ? 'cancelled' : 'error';
if (state === 'error' && cause instanceof HostedModelUnavailableError)
failureReason = 'model_unavailable';
if (state === 'error') {
writeStderrLineSafe(
`qwen serve: Hosted Harness turn ${promptId} failed: ${String(cause)}`,
@@ -940,6 +940,40 @@ describe('migration profile evidence', () => {
});
describe('verifyRecoverySession', () => {
it('captures raw pinned agent instructions and refuses changed bytes', async () => {
const bytes = Buffer.from('Keep the agent instructions. 保留指令。');
const instructionsRef: ManagedSessionDurableRef = {
resourceId: 'agent-instructions',
kind: 'managed-agent-instructions',
schemaVersion: 1,
byteLength: bytes.length,
digest: hash(bytes),
};
const definition = {
toolProfile: 'hosted-workspace-files/1',
agentDefinition: {
agentId: `agent_${'a'.repeat(32)}`,
revision: '1',
digest: 'b'.repeat(64),
instructionsRef,
},
};
const f = fixture(definition);
f.resources.set(instructionsRef.resourceId, bytes);
await expect(verifyRecoverySession(f.source, f.io)).resolves.toEqual({
fileHistory: 'not_captured',
});
expect(f.complete.has(instructionsRef.resourceId)).toBe(true);
const corrupt = fixture(definition);
corrupt.resources.set(
instructionsRef.resourceId,
Buffer.alloc(bytes.length),
);
await expect(
verifyRecoverySession(corrupt.source, corrupt.io),
).rejects.toThrow('resource bytes conflict');
});
it.each([
{},
{ undoReceipts: undefined },
@@ -169,6 +169,7 @@ const DOMAINS = [
];
const RESOURCE_KINDS = new Set([
'managed-definition',
'managed-agent-instructions',
'managed-root',
'managed-input',
'managed-admission',
@@ -995,6 +996,12 @@ export async function verifyRecoverySession(
'checkpoint closure owner conflicts',
);
await enqueueRefs(state, io);
} else if (ref.kind === 'managed-agent-instructions') {
requireValue(
bytes.byteLength <= 64 * 1024,
'agent instructions exceed 64 KiB',
);
new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} else if (ref.kind === 'managed-api-history') {
const history = json(bytes);
requireValue(Array.isArray(history), 'invalid API history');
@@ -128,6 +128,71 @@ describe('HTTP Managed Session store', () => {
};
}
it('includes digest-addressed instructions in the definition transaction closure', async () => {
const server = new FakeManagedSessionStore();
const runtimeBaseDir = await mkdtemp(
path.join(tmpdir(), 'definition-closure-'),
);
temporaryDirectories.push(runtimeBaseDir);
const stores = createHttpManagedSessionStores({
baseUrl: 'http://127.0.0.1:8080',
sessionKey: SESSION_KEY,
writerId: 'harness-a',
writerToken: TOKEN_A,
fetchFn: server.fetch,
});
const instructionsRef = await stores.resourceStore.publish(
'managed-agent-instructions',
Buffer.from('Definition instructions'),
);
const definitionRef = await stores.resourceStore.publish(
'managed-definition',
Buffer.from(
JSON.stringify({
engine: 'managed',
agentDefinition: {
agentId: `agent_${'a'.repeat(32)}`,
revision: '1',
digest: 'b'.repeat(64),
instructionsRef,
},
}),
),
);
const rootSnapshotRef = await stores.resourceStore.publish(
'managed-root',
Buffer.from('{}'),
);
const session = await openManagedSession({
runtimeBaseDir,
sessionId: SESSION_KEY.sessionId,
transcriptPath: path.join(runtimeBaseDir, 'session.jsonl'),
sessionKey: SESSION_KEY,
cwd: '/workspace',
version: 'test',
workerId: 'harness-a',
activationLeaseDurationMs: 60000,
journalStore: stores.journalStore,
resourceStore: stores.resourceStore,
create: { definitionRef, rootSnapshotRef, createdBy: 'test' },
});
expect(await stores.resourceStore.read(instructionsRef)).toEqual(
Buffer.from('Definition instructions'),
);
await session.close();
const restored = createHttpManagedSessionStores({
baseUrl: 'http://127.0.0.1:8080',
sessionKey: SESSION_KEY,
writerId: 'harness-b',
writerToken: TOKEN_B,
fetchFn: server.fetch,
});
await restored.journalStore.open({ sessionKey: SESSION_KEY });
expect(await restored.resourceStore.read(instructionsRef)).toEqual(
Buffer.from('Definition instructions'),
);
});
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(
@@ -1748,6 +1748,7 @@ export function collectNestedResourceRefs(
}
if (
EXTENSION_RECORD_KINDS.has(ref.kind) ||
ref.kind === 'managed-definition' ||
ref.kind === 'managed-action-options' ||
ref.kind === 'managed-hook-plan' ||
ref.kind === 'managed-hook-message-chunks'
@@ -37,10 +37,13 @@ The test-tree `SurfaceRegistry` names every mounted route, internal ones
included, with its admission rule class; `SurfaceRegistryGateTest` fails any
mounted route the registry lacks, so a new public or WebShell route needs both
a spec operation and a registry entry ([actor-roles design](../../../docs/design/2026-10-07-managed-agent-actor-roles.md), D5).
Sessions record the agent revision from `QWEN_MANAGED_AGENT_REVISION` (default
`1`) when they are created. `POST /v1/agents`, `GET /v1/agents/{id}` and
Sessions created for the built-in `qwen-code` agent record the agent revision
from `QWEN_MANAGED_AGENT_REVISION` (default `1`); a Session pinned to a stored
definition records the revision it resolved at admission. `POST /v1/agents`,
`GET /v1/agents/{id}` and
`POST /v1/agents/{id}` store tenant-scoped, immutable AgentDefinition
revisions; Sessions do not use them yet. Every response carries `X-Request-Id`, which error
revisions; a Session pinned to one executes that stored revision (see "Stored
agent execution" below). Every response carries `X-Request-Id`, which error
envelopes repeat as `request_id` and the logs print. Events keep the schema and
projection versions they were accepted with. They keep their Item and Part
identity too, except after Harness recovery retracts output: the retracted
@@ -1073,3 +1076,11 @@ Do not recreate or move the directory, change `QWEN_HOME`, or modify retained ba
Promotion increments mount revision once. Successful `prepare` and `promote` commits clear the migration's previous error code. Update the deployment's Workspace root and restart Broker/Harness with the original QWEN_HOME before opening admission. An old deployment mapping fails closed. New file Turns and undo use fresh Runtime identities. Source rows, messages, journal, keys and backup names remain unchanged. Failure preserves the fence; missing stop proof, unsupported profiles or drift require diagnosis. Reverse migration is a new verified operation at a higher revision.
The target marker is the sole manifest exception and must match the copied source marker or the exact operation-pinned target marker. Do not hand-edit it. No online drain, directory copying, public migration route, Shell/MCP/Hook migration or source-lost recovery is provided. Uninitialized retained members without a verifiable frozen private definition are refused. Production Linux/MySQL acceptance evidence must be recorded separately from injected-identity tests.
### Stored agent execution
Sessions pin a stored AgentDefinition revision and digest at admission. `permission_policy` and `tools` select the Session's immutable approval mode and hosted tool profile. `model` accepts `{}` or `{ "id": "configured-model" }`; `instructions` accepts UTF-8 text up to 64 KiB. Skills, MCP servers, environment templates and per-definition approval timeouts remain unsupported and are refused explicitly.
Set `QWEN_MANAGED_AGENT_DEFINITION_MODELS` to comma-separated IDs configured in the Harness's deployment `modelProviders`; it defaults to empty. Each ID must resolve to one configured provider route with deployment credentials. An absent or ambiguous route or unresolved credentials fails the Turn with `model_unavailable` before inference. Credentials never belong in definitions. Enable and configure the Managed Session Store for stored-definition execution; instructions are delivered as digest-addressed resource references and verified on load.
`QWEN_MANAGED_AGENT_DEFINITION_TOOL_PROFILES` defaults to `hosted-workspace-files/1`. Before opting into `hosted-workspace-files/2`, drain old workers and upgrade every provisioner according to #13166. Empty permission policies inherit the deployment approval mode (default `yolo`); use `default` for deployments that require approval.
@@ -92,6 +92,7 @@ public final class ApiModels {
public record PublicSession(String id, String object,
@JsonProperty("agent_id") String agentId,
@JsonProperty("agent_revision") String agentRevision,
@JsonProperty("agent_digest") String agentDefinitionDigest,
String status,
@JsonProperty("created_at") long createdAt,
@JsonProperty("updated_at") long updatedAt,
@@ -602,7 +603,8 @@ public final class ApiModels {
/**
* An AgentDefinition revision's content (D8a). The server stores and
* digests it; no field changes Session execution yet.
* digests it; a Session pinned to a revision executes its
* permission_policy, tools, model and instructions (D8b/D8c).
*/
public record AgentDefinitionRequest(
@NotNull Map<String, Object> model,
@@ -4,6 +4,8 @@ import com.alibaba.qwen.code.managedagent.harness.HarnessConnector;
import com.alibaba.qwen.code.managedagent.harness.QwenHostedHarnessConnector;
import com.alibaba.qwen.code.managedagent.harness.UnavailableHarnessConnector;
import com.alibaba.qwen.code.managedagent.store.AgentStateStore;
import com.alibaba.qwen.code.managedagent.store.ManagedAgentDefinitionStore;
import com.alibaba.qwen.code.managedagent.store.ManagedSessionStore;
import com.alibaba.qwen.code.managedagent.store.WorkspaceExecutionStore;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -22,8 +24,9 @@ public class HarnessConfiguration {
ManagedAgentProperties properties,
AgentStateStore sessions,
WorkspaceExecutionStore workspaceExecution,
ManagedActionStore actions) {
return new QwenHostedHarnessConnector(properties, sessions, workspaceExecution, actions);
ManagedActionStore actions, ManagedAgentDefinitionStore definitions,
ManagedSessionStore resources) {
return new QwenHostedHarnessConnector(properties, sessions, workspaceExecution, actions, definitions, resources);
}
@Bean
@@ -23,6 +23,11 @@ public class ManagedAgentProperties {
private final Automation automation = new Automation();
private final Channels channels = new Channels();
private String agentRevision = "1";
// The tool profiles a stored AgentDefinition may select (D8c-1).
// hosted-workspace-files/2 joins only after the #13166 glob rollout.
private List<String> definitionToolProfiles =
List.of("hosted-workspace-files/1");
private List<String> definitionModels = List.of();
private String trustedActorHeader = "";
public Harness getHarness() {
@@ -76,6 +81,21 @@ public class ManagedAgentProperties {
public void setAgentRevision(String agentRevision) {
this.agentRevision = agentRevision;
}
public List<String> getDefinitionToolProfiles() {
return definitionToolProfiles;
}
public void setDefinitionToolProfiles(
List<String> definitionToolProfiles) {
this.definitionToolProfiles = definitionToolProfiles;
}
public List<String> getDefinitionModels() {
return definitionModels;
}
public void setDefinitionModels(List<String> definitionModels) {
this.definitionModels = definitionModels;
}
public String getTrustedActorHeader() {
return trustedActorHeader;
@@ -107,6 +127,13 @@ public class ManagedAgentProperties {
+ " a supported Harness, Session Store and Session-isolated"
+ " local-process Broker with Workspace mounts");
}
// A definition Session publishes its instructions through the
// Session Store at Harness create, so the Harness implies the
// Store even when Workspace files are off (D8c-2).
if (harness.isEnabled() && !sessionStore.isEnabled()) {
throw new IllegalStateException(
"Hosted Harness requires the Managed Session Store");
}
if (automation.isEnabled()) {
// A non-positive value fails at tick time, nearly silently:
// 0 slots per tick stops every scheduled fire behind one
@@ -21,6 +21,9 @@ import com.alibaba.qwen.code.managedagent.store.StoreModels;
import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord;
import com.alibaba.qwen.code.managedagent.store.WorkspaceExecutionStore;
import com.alibaba.qwen.code.managedagent.store.WriterCredentialPolicy;
import com.alibaba.qwen.code.managedagent.store.ManagedAgentDefinitionStore;
import com.alibaba.qwen.code.managedagent.store.ManagedSessionStore;
import java.util.LinkedHashMap;
import java.net.URI;
import java.time.Duration;
import java.util.List;
@@ -40,6 +43,8 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
private final WorkspaceExecutionStore workspaceExecution;
private final DaemonApprovalMode approvalMode;
private final ManagedActionStore actions;
private final ManagedAgentDefinitionStore definitions;
private final ManagedSessionStore resources;
private final WriterCredentialPolicy credentials;
/** #13753 I2: this control plane serves child Workspaces (startup checks the shape). */
private final boolean childWorkspaces;
@@ -69,6 +74,15 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
AgentStateStore sessions,
WorkspaceExecutionStore workspaceExecution,
ManagedActionStore actions) {
this(properties, sessions, workspaceExecution, actions, null, null);
}
public QwenHostedHarnessConnector(ManagedAgentProperties properties,
AgentStateStore sessions, WorkspaceExecutionStore workspaceExecution,
ManagedActionStore actions, ManagedAgentDefinitionStore definitions,
ManagedSessionStore resources) {
this.definitions = definitions;
this.resources = resources;
this.properties = properties.getHarness();
this.sessionStore = properties.getSessionStore();
this.workspaceId = sessionStore.getWorkspaceId();
@@ -158,7 +172,8 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
HarnessSessionRef ref = attachments.get(key);
if (ref == null) {
ref = client().loadSession(new LoadHarnessSession(session.sessionId(), managedSessionStore(session),
false, toolProfile(session), false).withChildWorkspaces(childWorkspaces)
false, toolProfile(session), false)
.withAgentDefinition(agentDefinition(session, false)).withChildWorkspaces(childWorkspaces)
.forLifecycle(operation.operationId(), operation.claimGeneration()));
attachments.put(key, ref);
}
@@ -583,7 +598,9 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
new LoadHarnessSession(session.sessionId(),
managedSessionStore(session),
session.harnessBootId() != null, toolProfile(session),
false, false).withChildWorkspaces(childWorkspaces)
false, false)
.withAgentDefinition(agentDefinition(session, false))
.withChildWorkspaces(childWorkspaces)
.withStoppedMessages());
if (session.workspace() != null && !actions.approvalMode(tenantId,
sessionId).equals(attached.getApprovalMode())) {
@@ -728,6 +745,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
.harnessSessionId(session.sessionId())
.approvalMode(
session.workspace() == null
&& session.agentDefinitionDigest() == null
? approvalMode
: parseApprovalMode(
actions.approvalMode(
@@ -741,6 +759,10 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
if (store != null) {
builder.managedSessionStore(store);
}
Map<String, Object> definition = agentDefinition(session, true);
if (definition != null) {
builder.agentDefinition(definition);
}
StoreModels.SessionLineage lineage = sessions.findChildLineage(
session.tenantId(), session.sessionId());
if (lineage != null) {
@@ -779,7 +801,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
return client().loadSession(new LoadHarnessSession(session.sessionId(), store,
passiveManagedRuntimeRecovery, profile,
driveRuntimeRecovery, cancellationTakeover)
.withChildWorkspaces(childWorkspaces));
.withAgentDefinition(agentDefinition(session, false)).withChildWorkspaces(childWorkspaces));
}
@Override
@@ -915,6 +937,39 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
return session.toolProfile();
}
private Map<String, Object> agentDefinition(SessionRecord session,
boolean create) {
if (session.agentDefinitionDigest() == null) {
return null;
}
Map<String, Object> pin = new LinkedHashMap<>();
pin.put("agentId", session.agentId());
pin.put("revision", session.agentRevision());
pin.put("digest", session.agentDefinitionDigest());
if (!create) {
return pin;
}
if (definitions == null || resources == null || !sessionStore.isEnabled()) {
throw new IllegalStateException("Agent definitions require the Managed Session Store");
}
Map<String, Object> content = definitions.pinnedContent(session);
Object model = content.get("model");
if (model instanceof Map<?, ?> map && !map.isEmpty()) {
pin.put("model", model);
}
Object instructions = content.get("instructions");
if (instructions instanceof String text && !text.isEmpty()) {
String workspace = session.workspace() == null ? workspaceId
: session.workspace().getWorkspaceId();
var ref = resources.publishAgentInstructions(session.tenantId(),
workspace, session.sessionId(), text);
pin.put("instructionsRef", Map.of("resourceId", ref.resourceId(),
"kind", ref.kind(), "schemaVersion", ref.schemaVersion(),
"byteLength", ref.byteLength(), "digest", ref.digest()));
}
return pin;
}
private ManagedSessionStoreConnection managedSessionStore(
SessionRecord session) {
if (!sessionStore.isEnabled()) {
@@ -20,8 +20,9 @@ import org.springframework.stereotype.Service;
/**
* Stores AgentDefinition revisions (D8a). Definitions are tenant-scoped and
* immutable once stored; Sessions do not use them yet (D8b), and no field
* changes Harness execution (D8c).
* immutable once stored; a Session pinned to a stored revision executes it
* (D8b), and its permission_policy/tools fields fix that Session's approval
* mode and tool profile.
*/
@Service
public class ManagedAgentDefinitionService {
@@ -764,6 +764,7 @@ public class ManagedAgentService {
"agent.session",
session.agentId(),
session.agentRevision(),
session.agentDefinitionDigest(),
session.status().toLowerCase(),
session.createdAt() / 1000,
session.updatedAt() / 1000,
@@ -1135,7 +1136,8 @@ public class ManagedAgentService {
// that fails them can never execute, so admission must not certify
// it. Empty bound creation skips that validation by design.
return "ACTIVE".equals(session.status()) && session.deletedAt() == null
&& "qwen-code".equals(session.agentId())
&& ("qwen-code".equals(session.agentId())
|| session.agentDefinitionDigest() != null)
&& WorkspaceExecutionProfile.CONTEXT_CONFIG_REF.equals(
session.workspace().getContextConfigRef());
}
@@ -0,0 +1,130 @@
package com.alibaba.qwen.code.managedagent.store;
import com.alibaba.qwen.code.managedagent.api.ApiException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpStatus;
/**
* Compiles a stored AgentDefinition revision into the fields that take
* effect at Session admission (D8c-1): the approval mode and the tool
* profile a Session pins. Content that cannot take effect refuses
* admission with {@code 409 agent_definition_unsupported} naming the field,
* instead of being dropped.
*/
final class AgentDefinitionCompiler {
private static final ObjectMapper JSON = new ObjectMapper();
private static final TypeReference<Map<String, Object>> CONTENT =
new TypeReference<>() {
};
private static final List<String> APPROVAL_MODES = List.of("default",
"auto-edit", "yolo");
record Compiled(String approvalMode, String toolProfile) {
}
private AgentDefinitionCompiler() {
}
static Compiled compile(String definitionJson, boolean workspaceBound,
String deploymentApprovalMode, List<String> toolProfiles) {
return compile(definitionJson, workspaceBound,
deploymentApprovalMode, toolProfiles, List.of());
}
static Compiled compile(String definitionJson, boolean workspaceBound,
String deploymentApprovalMode, List<String> toolProfiles,
List<String> models) {
Map<String, Object> definition;
try {
definition = JSON.readValue(definitionJson, CONTENT);
} catch (Exception error) {
throw new IllegalStateException(
"A stored agent definition is not valid JSON", error);
}
Object model = definition.get("model");
if (model instanceof Map<?, ?> map) {
if (!map.isEmpty() && !(map.size() == 1
&& map.get("id") instanceof String id
&& !id.isBlank() && models.contains(id))) {
throw unsupported("model");
}
} else if (model != null) {
throw unsupported("model");
}
Object instructions = definition.get("instructions");
if (instructions != null && (!(instructions instanceof String text)
|| text.getBytes(StandardCharsets.UTF_8).length
> ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES)) {
throw unsupported("instructions");
}
String approvalMode = deploymentApprovalMode;
Object policy = definition.get("permission_policy");
if (policy instanceof Map<?, ?> map) {
if (!map.isEmpty()) {
if (map.size() == 1
&& map.get("approval_mode") instanceof String mode
&& APPROVAL_MODES.contains(mode)) {
approvalMode = mode;
} else {
throw unsupported("permission_policy");
}
}
} else if (policy != null) {
throw unsupported("permission_policy");
}
Object raw = definition.get("tools");
List<?> tools = raw == null ? List.of()
: raw instanceof List<?> list ? list : null;
if (tools == null) {
throw unsupported("tools");
}
String toolProfile = null;
if (!workspaceBound) {
if (!tools.isEmpty()) {
throw unsupported("tools");
}
} else {
if (tools.size() != 1 || !(tools.getFirst() instanceof Map<?, ?> entry)
|| entry.size() != 2
|| !"hosted_profile".equals(entry.get("type"))
|| !(entry.get("profile") instanceof String profile)) {
throw unsupported("tools");
} else {
if (!toolProfiles.contains(profile)) {
throw unsupported("tools");
}
// A Shell profile always asks (#13271); it may never pair
// with yolo once the deployment allowlist opens it.
if (profile.startsWith("hosted-workspace-shell/")
&& "yolo".equals(approvalMode)) {
throw unsupported("tools");
}
toolProfile = profile;
}
}
refuseNonEmptyList(definition.get("skills"), "skills");
refuseNonEmptyList(definition.get("mcp_servers"), "mcp_servers");
if (definition.get("environment_template_id") != null) {
throw unsupported("environment_template_id");
}
return new Compiled(approvalMode, toolProfile);
}
private static void refuseNonEmptyList(Object value, String field) {
if (value != null && (!(value instanceof List<?> list)
|| !list.isEmpty())) {
throw unsupported(field);
}
}
private static ApiException unsupported(String field) {
return new ApiException(HttpStatus.CONFLICT,
"agent_definition_unsupported",
"The agent definition field cannot take effect yet.",
Map.of("field", field));
}
}
@@ -1,6 +1,11 @@
package com.alibaba.qwen.code.managedagent.store;
import com.alibaba.qwen.code.managedagent.api.ApiException;
import com.alibaba.qwen.code.managedagent.service.RequestDigests;
import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
import java.util.List;
import java.util.Optional;
import org.springframework.dao.DuplicateKeyException;
@@ -98,6 +103,27 @@ public class ManagedAgentDefinitionStore {
return record(tenantId, idempotencyKey, requestDigest, result, now);
}
public Map<String, Object> pinnedContent(SessionRecord session) {
DefinitionRevision revision = find(session.tenantId(),
session.agentId(), Long.parseLong(session.agentRevision()))
.orElseThrow(ManagedAgentDefinitionStore::notFound);
Map<String, Object> content;
try {
content = new ObjectMapper().readValue(revision.definitionJson(),
new TypeReference<Map<String, Object>>() { });
} catch (Exception error) {
throw new IllegalStateException("Invalid pinned definition", error);
}
if (!revision.digest().equals(session.agentDefinitionDigest())
|| !new RequestDigests().digest(content)
.equals("sha256:" + session.agentDefinitionDigest())) {
throw new ApiException(HttpStatus.CONFLICT,
"hosted_agent_definition_conflict",
"The pinned agent definition digest does not match.");
}
return content;
}
public Optional<DefinitionRevision> latest(String tenantId,
String agentId) {
return first(jdbc.query("SELECT agent_id, revision, digest,"
@@ -107,6 +133,21 @@ public class ManagedAgentDefinitionStore {
ManagedAgentDefinitionStore::row, tenantId, agentId));
}
/**
* The latest revision read inside the caller's transaction with the
* agent's rows write-locked (D8b): a Session pinned to "latest" cannot
* interleave with a concurrent update writing that same next revision.
* FOR UPDATE stands for both engines: H2 has no FOR SHARE.
*/
public Optional<DefinitionRevision> latestForUpdate(String tenantId,
String agentId) {
return first(jdbc.query("SELECT agent_id, revision, digest,"
+ " definition_json, created_at FROM managed_agent_definition"
+ " WHERE tenant_id = ? AND agent_id = ?"
+ " ORDER BY revision DESC LIMIT 1 FOR UPDATE",
ManagedAgentDefinitionStore::row, tenantId, agentId));
}
public Optional<DefinitionRevision> find(String tenantId, String agentId,
long revision) {
return first(jdbc.query("SELECT agent_id, revision, digest,"
@@ -51,6 +51,7 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -100,12 +101,23 @@ public class ManagedAgentStore implements AgentStateStore {
this.lifecycle = lifecycle;
}
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setManagedAgentDefinitionStore(
ManagedAgentDefinitionStore definitions) {
this.definitions = definitions;
}
private final ObjectMapper objectMapper;
private final Clock clock;
private final String approvalMode;
private final CommittedEventPublisher eventPublisher;
private final ManagedWorkspaceRegistry workspaces;
private final String agentRevision;
private final List<String> definitionToolProfiles;
private final List<String> definitionModels;
private ManagedAgentDefinitionStore definitions;
private static final Pattern DEFINITION_REVISION = Pattern.compile(
"^[1-9][0-9]{0,17}$");
private final boolean workspaceFilesEnabled;
private final List<ManagedAgentProperties.RuntimeBroker.WorkspaceMount> workspaceMounts;
private final RowMapper<SessionRecord> sessionMapper = (result, row) ->
@@ -125,7 +137,8 @@ public class ManagedAgentStore implements AgentStateStore {
nullableLong(result, "deleted_at"),
result.getLong("version"), readBinding(result),
result.getString("approval_mode"),
result.getString("tool_profile"));
result.getString("tool_profile"),
result.getString("agent_definition_digest"));
private final RowMapper<TurnSummary> turnSummaryMapper =
(result, row) -> new TurnSummary(result.getString("session_id"),
result.getString("turn_id"), result.getString("status"),
@@ -236,6 +249,8 @@ public class ManagedAgentStore implements AgentStateStore {
this.eventPublisher = eventPublisher;
this.workspaces = workspaces;
this.agentRevision = properties.getAgentRevision();
this.definitionToolProfiles = properties.getDefinitionToolProfiles();
this.definitionModels = properties.getDefinitionModels();
this.workspaceFilesEnabled = properties.getHarness().isWorkspaceFilesEnabled();
this.workspaceMounts = properties.getRuntimeBroker().getWorkspaceMounts();
if (agentRevision == null || agentRevision.isBlank()
@@ -252,11 +267,12 @@ public class ManagedAgentStore implements AgentStateStore {
String operation, String idempotencyKey, String requestDigest,
String agentId, String requestedRevision, String title,
List<Map<String, Object>> input, String payloadDigest) {
requireAgentRevision(requestedRevision);
AgentPin pin = resolveAgentPin(tenantId, agentId, requestedRevision,
false);
requireCreationScope(tenantId, idempotencyKey, false);
return insertSession(tenantId, operation, idempotencyKey,
requestDigest, agentId, title, input, payloadDigest,
null, actorId);
null, actorId, pin);
}
@Override
@@ -277,14 +293,16 @@ public class ManagedAgentStore implements AgentStateStore {
return replayWorkspaceCommand(tenantId, actorId,
requestDigest, existing.getFirst());
}
requireAgentRevision(requestedRevision);
AgentPin pin = resolveAgentPin(tenantId, agentId, requestedRevision,
true);
requireCreationScope(tenantId, idempotencyKey, true);
ResolvedBinding workspace = workspaces.resolveForCreation(
tenantId, actorId, selection);
WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, workspace.binding().getStorageId());
// Admission already proved the agent is qwen-code or a compiled
// definition, so only the deployment mounts gate execution here.
if (!input.isEmpty()
&& (!"qwen-code".equals(agentId)
|| !WorkspaceExecutionProfile.CONFIG_REF.equals(workspace.configRef())
&& (!WorkspaceExecutionProfile.CONFIG_REF.equals(workspace.configRef())
|| !WorkspaceExecutionProfile.POLICY_REF.equals(workspace.policyRef())
|| workspaceMounts.stream().noneMatch(mount ->
tenantId.equals(mount.tenantId())
@@ -293,7 +311,7 @@ public class ManagedAgentStore implements AgentStateStore {
}
return insertSession(tenantId, "CREATE_SESSION", idempotencyKey,
requestDigest, agentId, title, input, payloadDigest,
workspace, actorId);
workspace, actorId, pin);
}
@Override
@@ -413,7 +431,8 @@ public class ManagedAgentStore implements AgentStateStore {
String turnId = input.isEmpty() ? null : publicId("turn");
String promptId = input.isEmpty() ? null : UUID.randomUUID().toString();
jdbc.update("INSERT INTO managed_agent_session (tenant_id,"
+ " session_id, agent_id, agent_revision, title,"
+ " session_id, agent_id, agent_revision,"
+ " agent_definition_digest, title,"
+ " status, created_at, updated_at, workspace_id,"
+ " workspace_generation, workspace_storage_id,"
+ " cwd_relative, context_config_ref,"
@@ -423,7 +442,8 @@ public class ManagedAgentStore implements AgentStateStore {
+ " parent_session_id, root_session_id,"
+ " parent_child_run_id, child_depth)"
+ " SELECT tenant_id, ?, agent_id,"
+ " agent_revision, ?, 'ACTIVE', ?, ?,"
+ " agent_revision, agent_definition_digest,"
+ " ?, 'ACTIVE', ?, ?,"
+ " workspace_id, workspace_generation,"
+ " workspace_storage_id, "
+ (childCwdRelative == null ? "cwd_relative" : "?")
@@ -643,20 +663,62 @@ public class ManagedAgentStore implements AgentStateStore {
}
}
// Called only for a new admission; a retry has already replayed.
private void requireAgentRevision(String requested) {
if (requested != null && !requested.equals(agentRevision)) {
throw new ApiException(HttpStatus.BAD_REQUEST,
"unsupported_feature",
"Only the current agent revision can be selected.");
/**
* The execution identity a new Session pins (D8b): for the built-in
* {@code qwen-code} agent the deployment revision with no definition
* digest; for a stored definition the resolved revision, its digest and
* the approval mode and tool profile compiled from its content (D8c-1).
*/
private record AgentPin(String revision, String digest,
String approvalMode, String toolProfile) {
}
// Called only for a new admission; a retry has already replayed and
// never resolves a definition again.
private AgentPin resolveAgentPin(String tenantId, String agentId,
String requested, boolean workspaceBound) {
if ("qwen-code".equals(agentId)) {
if (requested != null && !requested.equals(agentRevision)) {
throw new ApiException(HttpStatus.BAD_REQUEST,
"unsupported_feature",
"Only the current agent revision can be selected.");
}
return new AgentPin(agentRevision, null, approvalMode,
workspaceBound ? "hosted-workspace-files/1" : null);
}
if (definitions == null) {
throw ManagedAgentDefinitionStore.notFound();
}
ManagedAgentDefinitionStore.DefinitionRevision revision =
resolveDefinitionRevision(tenantId, agentId, requested);
AgentDefinitionCompiler.Compiled compiled =
AgentDefinitionCompiler.compile(revision.definitionJson(),
workspaceBound, approvalMode, definitionToolProfiles,
definitionModels);
return new AgentPin(Long.toString(revision.revision()),
revision.digest(), compiled.approvalMode(),
compiled.toolProfile());
}
private ManagedAgentDefinitionStore.DefinitionRevision resolveDefinitionRevision(
String tenantId, String agentId, String requested) {
if (requested == null) {
return definitions.latestForUpdate(tenantId, agentId)
.orElseThrow(ManagedAgentDefinitionStore::notFound);
}
if (!DEFINITION_REVISION.matcher(requested).matches()) {
throw ManagedAgentDefinitionStore.notFound();
}
return definitions
.find(tenantId, agentId, Long.parseLong(requested))
.orElseThrow(ManagedAgentDefinitionStore::notFound);
}
private Admission insertSession(String tenantId, String operation,
String idempotencyKey, String requestDigest, String agentId,
String title, List<Map<String, Object>> input,
String payloadDigest, ResolvedBinding resolved,
String actorId) {
String actorId, AgentPin pin) {
ContextBinding workspace = resolved == null ? null
: resolved.binding();
long now = clock.millis();
@@ -667,16 +729,18 @@ public class ManagedAgentStore implements AgentStateStore {
byte[] actorKey = actorId == null ? null
: ManagedWorkspaceRegistry.actorKey(tenantId, actorId);
jdbc.update("INSERT INTO managed_agent_session (tenant_id,"
+ " session_id, agent_id, agent_revision, title,"
+ " session_id, agent_id, agent_revision,"
+ " agent_definition_digest, title,"
+ " status, created_at, updated_at, workspace_id,"
+ " workspace_generation, workspace_storage_id,"
+ " cwd_relative, context_config_ref,"
+ " context_revision, workspace_config_ref,"
+ " workspace_policy_ref, tool_profile,"
+ " creator_actor_key, owner_actor_key) VALUES"
+ " (?, ?, ?, ?, ?, 'ACTIVE', ?, ?, ?, ?, ?, ?, ?,"
+ " (?, ?, ?, ?, ?, ?, 'ACTIVE', ?, ?, ?, ?, ?, ?, ?,"
+ " ?, ?, ?, ?, ?, ?)",
tenantId, sessionId, agentId, agentRevision, title, now, now,
tenantId, sessionId, agentId, pin.revision(), pin.digest(),
title, now, now,
workspace == null ? null : workspace.getWorkspaceId(),
workspace == null ? null : workspace.getWorkspaceGeneration(),
workspace == null ? null : workspace.getStorageId(),
@@ -685,7 +749,7 @@ public class ManagedAgentStore implements AgentStateStore {
workspace == null ? null : workspace.getContextRevision(),
resolved == null ? null : resolved.configRef(),
resolved == null ? null : resolved.policyRef(),
workspace == null ? null : "hosted-workspace-files/1",
workspace == null ? null : pin.toolProfile(),
actorKey, actorKey);
jdbc.update("INSERT INTO managed_agent_consumer_progress"
+ " (tenant_id, session_id, consumer_name,"
@@ -707,11 +771,13 @@ public class ManagedAgentStore implements AgentStateStore {
tenantId, actorKey, idempotencyKey, requestDigest,
sessionId, turnId, now);
}
if (workspace != null) {
// A definition pin applies to unbound Sessions too; qwen-code keeps
// the column default unless bound.
if (workspace != null || pin.digest() != null) {
jdbc.update(
"UPDATE managed_agent_session SET approval_mode = ? WHERE tenant_id = ? AND"
+ " session_id = ?",
approvalMode,
pin.approvalMode(),
tenantId,
sessionId);
}
@@ -776,6 +776,43 @@ public class ManagedSessionStore {
}
}
@Transactional
public ToolResultResourceRef publishAgentInstructions(String tenantId,
String workspaceId, String sessionId, String instructions) {
validateScope(tenantId, workspaceId, sessionId);
byte[] bytes = instructions.getBytes(StandardCharsets.UTF_8);
if (bytes.length > ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES) {
throw payloadTooLarge("Agent instructions exceed 64 KiB.");
}
String digest = sha256(bytes);
String resourceId = "agent-instructions-" + digest;
String scopeKey = sessionScopeKey(tenantId, sessionId);
Timestamp now = databaseNow();
ResourceRow existing = findResource(scopeKey, resourceId);
if (existing == null) {
jdbc.update("INSERT INTO qwen_managed_session_resource"
+ " (session_scope_key, tenant_id, workspace_id, session_id, resource_id,"
+ " kind, schema_version, byte_length, sha256, storage_kind, inline_bytes,"
+ " publish_command_id, state, created_at, last_verified_at)"
+ " VALUES (?, ?, ?, ?, ?, 'managed-agent-instructions', 1, ?, ?,"
+ " 'MYSQL_INLINE', ?, ?, 'PUBLISHED', ?, ?)",
scopeKey, tenantId, workspaceId, sessionId, resourceId,
bytes.length, digest, bytes, resourceId, now, now);
} else {
requireResourceScope(existing, tenantId, workspaceId, sessionId, resourceId);
verifyStoredResource(existing);
if (!"managed-agent-instructions".equals(existing.kind())
|| existing.schemaVersion() != 1
|| existing.byteLength() != bytes.length
|| !digest.equals(existing.digest())) {
throw conflict("managed_session_resource_conflict",
"Agent instructions resource does not match.");
}
}
return new ToolResultResourceRef(resourceId,
"managed-agent-instructions", 1, bytes.length, digest);
}
@Transactional
public ToolResultResourceRef publishToolResult(String tenantId, String sessionId,
String writerToken, PublishToolResultRequest request) {
@@ -1398,7 +1435,10 @@ public class ManagedSessionStore {
if (!"MYSQL_INLINE".equals(resource.storageKind())
|| !("REFERENCED".equals(resource.state())
|| "PUBLISHED".equals(resource.state()) && resource.schemaVersion() == 1
&& resource.byteLength() > 0 && resource.byteLength() <= toolResultLimit(resource.kind()))
&& (resource.byteLength() > 0 && resource.byteLength() <= toolResultLimit(resource.kind())
|| "managed-agent-instructions".equals(resource.kind())
&& resource.byteLength() >= 0
&& resource.byteLength() <= ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES))
|| resource.bytes() == null
|| resource.objectKey() != null
|| resource.objectVersionId() != null
@@ -14,6 +14,9 @@ public final class ManagedSessionStoreModels {
public static final String WRITER_TOKEN_HEADER =
"X-Qwen-Managed-Writer-Token";
public static final int MAX_INLINE_RESOURCE_BYTES = 64 * 1024;
/** The agent-instructions bound the publisher and both verifiers
* must agree on (admission, publish, verify, recovery read). */
public static final int MAX_AGENT_INSTRUCTIONS_BYTES = 64 * 1024;
public static final int MAX_RESOURCES_PER_TRANSACTION = 1024;
public static final int MAX_TRANSACTION_BYTES = 8 * 1024 * 1024;
public static final int MAX_TRANSACTION_EVENTS = 256;
@@ -18,7 +18,21 @@ public final class StoreModels {
long harnessLastEventId, long lastSequence,
long replayFloorSequence, long createdAt, long updatedAt,
Long deletedAt, long version, ContextBinding workspace,
String approvalMode, String toolProfile) {
String approvalMode, String toolProfile,
String agentDefinitionDigest) {
public SessionRecord(String tenantId, String sessionId,
String agentId, String agentRevision, String title,
String status, String harnessBootId, String harnessEventEpoch,
long harnessLastEventId, long lastSequence,
long replayFloorSequence, long createdAt, long updatedAt,
Long deletedAt, long version, ContextBinding workspace,
String approvalMode, String toolProfile) {
this(tenantId, sessionId, agentId, agentRevision, title, status,
harnessBootId, harnessEventEpoch, harnessLastEventId,
lastSequence, replayFloorSequence, createdAt, updatedAt,
deletedAt, version, workspace, approvalMode, toolProfile,
null);
}
public SessionRecord(String tenantId, String sessionId,
String agentId, String title, String status,
String harnessBootId, String harnessEventEpoch,
@@ -27,7 +41,7 @@ public final class StoreModels {
this(tenantId, sessionId, agentId, null, title, status,
harnessBootId, harnessEventEpoch, harnessLastEventId,
lastSequence, 0, createdAt, updatedAt, deletedAt, version,
null, "yolo", null);
null, "yolo", null, null);
}
}
@@ -136,7 +136,7 @@ public class WorkspaceExecutionStore {
String expectedStatus = authority == null && !legacyClose ? "ACTIVE" : session.status();
if (binding == null || !(legacyClose ? "CLOSING".equals(session.status()) : authority == null ? "ACTIVE".equals(session.status())
: java.util.List.of("CLOSING", "DELETING").contains(session.status()))
|| session.deletedAt() != null || !"qwen-code".equals(session.agentId())
|| session.deletedAt() != null || !executableAgent(session.agentId(), session.agentDefinitionDigest())
|| !session.tenantId().equals(binding.getTenantId())
|| !WorkspaceExecutionProfile.CONTEXT_CONFIG_REF.equals(
binding.getContextConfigRef())) {
@@ -146,7 +146,9 @@ public class WorkspaceExecutionStore {
// Verdicts per matched row: 0 structural mismatch, 1 refused only by
// operator-mutable grant or registry state, 2 authorized.
List<Integer> grants = jdbc.query("SELECT s.tenant_id, s.session_id,"
+ " s.agent_id AS session_agent, s.status AS session_status,"
+ " s.agent_id AS session_agent,"
+ " s.agent_definition_digest AS session_definition_digest,"
+ " s.status AS session_status,"
+ " s.deleted_at AS session_deleted_at,"
+ " s.workspace_id AS session_workspace,"
+ " s.workspace_generation AS session_generation,"
@@ -180,7 +182,8 @@ public class WorkspaceExecutionStore {
(row, index) -> {
boolean structural = session.tenantId().equals(row.getString("tenant_id"))
&& session.sessionId().equals(row.getString("session_id"))
&& "qwen-code".equals(row.getString("session_agent"))
&& executableAgent(row.getString("session_agent"),
row.getString("session_definition_digest"))
&& expectedStatus.equals(row.getString("session_status"))
&& row.getObject("session_deleted_at") == null
&& binding.getWorkspaceId().equals(row.getString("session_workspace"))
@@ -518,6 +521,12 @@ public class WorkspaceExecutionStore {
});
}
// qwen-code, or a Session that pinned a compiled definition (D8c-1).
private static boolean executableAgent(String agentId,
String agentDefinitionDigest) {
return "qwen-code".equals(agentId) || agentDefinitionDigest != null;
}
public static RuntimeBrokerException unavailable() {
return new RuntimeBrokerException(409, "workspace_unavailable",
"Workspace execution authority is unavailable.", false);
@@ -69,7 +69,13 @@ final class WorkspaceRecoveryReader {
check(!"COLLECTED".equals(row.get("state")), "resource_collected");
if ("MYSQL_INLINE".equals(row.get("storage_kind"))) {
boolean published = "PUBLISHED".equals(row.get("state"))
&& Set.of("managed-tool-result-content", "managed-tool-result-manifest", "managed-tool-result-page").contains(text(ref, "kind"));
&& (Set.of("managed-tool-result-content", "managed-tool-result-manifest",
"managed-tool-result-page").contains(text(ref, "kind"))
// The digest-addressed instructions resource never
// commits; it stays PUBLISHED for its whole life.
|| "managed-agent-instructions".equals(text(ref, "kind"))
&& number(row, "byte_length") >= 0
&& number(row, "byte_length") <= ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES);
check(("REFERENCED".equals(row.get("state")) || published) && row.get("object_key") == null
&& row.get("object_version_id") == null && row.get("encryption_key_id") == null,
"resource_layout_unsupported");
@@ -512,7 +512,8 @@ public final class WorkspaceRecoveryStore {
+ " AND workspace_storage_id = ?" + (lockSources ? " FOR UPDATE" : ""), tenant, session, storage);
check(rows.size() == 1, "source_drift");
Map<String, Object> row = rows.getFirst();
check("qwen-code".equals(row.get("agent_id")) && Set.of("ACTIVE", "CLOSED", "ARCHIVED", "DELETED")
check(("qwen-code".equals(row.get("agent_id")) || row.get("agent_definition_digest") != null)
&& Set.of("ACTIVE", "CLOSED", "ARCHIVED", "DELETED")
.contains(row.get("status")), "source_drift");
var receipts = jdbc.queryForList("SELECT actor_id, idempotency_key, request_digest, turn_id, created_at"
+ " FROM managed_workspace_create_command WHERE tenant_id = ? AND session_id = ?", tenant, session);
@@ -533,6 +534,9 @@ public final class WorkspaceRecoveryStore {
}
check(binding.path("workspaceGeneration").asLong() > 0 && binding.path("contextRevision").asLong() > 0, "source_drift");
fields(source, row, "configRef", "workspace_config_ref", "policyRef", "workspace_policy_ref", "approvalMode", "approval_mode");
// The pinned definition identity (D8c-1); null for qwen-code.
fields(source, row, "agentRevision", "agent_revision",
"agentDefinitionDigest", "agent_definition_digest");
ObjectNode product = source.putObject("publicSession");
fields(product, row, "version", "version", "status", "status", "lastSequence", "last_sequence",
"harnessBootId", "harness_boot_id", "harnessEventEpoch", "harness_event_epoch",
@@ -33,6 +33,8 @@ management:
qwen:
managed-agent:
agent-revision: '${QWEN_MANAGED_AGENT_REVISION:1}'
definition-models: '${QWEN_MANAGED_AGENT_DEFINITION_MODELS:}'
definition-tool-profiles: '${QWEN_MANAGED_AGENT_DEFINITION_TOOL_PROFILES:hosted-workspace-files/1}'
trusted-actor-header: '${QWEN_MANAGED_AGENT_TRUSTED_ACTOR_HEADER:}'
auth:
mode: '${QWEN_MANAGED_AGENT_AUTH_MODE:auto}'
@@ -0,0 +1 @@
ALTER TABLE managed_agent_session ADD COLUMN agent_definition_digest VARCHAR(64) NULL;
@@ -2,8 +2,8 @@
"openapi": "3.1.0",
"info": {
"title": "Qwen Managed Agent Public and WebShell API",
"version": "1.40.0",
"description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 adds O3 durable tool-result projection, artifact metadata, immutable range/streaming content and WebShell result reads. Artifact capability requires a configured O3 reader and a Workspace-bound Session; execution admission remains independent. v1.28 admits later Turns of a Workspace-bound Session for the SLine truncated
"version": "1.41.0",
"description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 adds O3 durable tool-result projection, artifact metadata, immutable range/streaming content and WebShell result reads. Artifact capability requires a configured O3 reader and a Workspace-bound Session; execution admission remains independent. v1.28 admits later Turns of a Workspace-bound Session for the SLine truncated
},
"servers": [
{
@@ -37,7 +37,7 @@
"post": {
"tags": ["Public Resources"],
"operationId": "createAgent",
"description": "Stores revision 1 of a new tenant-scoped definition under a server-generated agent ID. The same Idempotency-Key with the same body replays the original revision; a different body answers 409 idempotency_conflict. Sessions do not use definitions yet, and no field changes execution.",
"description": "Stores revision 1 of a new tenant-scoped definition under a server-generated agent ID. The same Idempotency-Key with the same body replays the original revision; a different body answers 409 idempotency_conflict. Sessions may be created from a stored definition by agent_id (v1.41): an explicit agent_revision pins that revision, an omitted one resolves the latest at admission, and permission_policy, tools, model and instructions then take effect for that Session (model must be on the deployment's agent-model allowlist, the tool profile on its tool-profile allowlist); content that cannot take effect yet is refused at admission with 409 agent_definition_unsupported.",
"x-qwen-implementation-status": "implemented",
"parameters": [
{
@@ -204,7 +204,7 @@
"tags": ["Public Sessions"],
"operationId": "createSession",
"x-qwen-implementation-status": "implemented",
"description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. Any caller holding the OPERATOR role may submit later Turns and rename the Session under the same opt-in, while the Workspace registry row stays ACTIVE and still backs the binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is an active, undeleted qwen-code Session on the frozen execution profile; Workspace close follows its separate close capability and lifecycle admission; the same caller may cancel a running Turn (its delivery re-checks no grants); archive and unarchive follow their separate retention capabilities after reliable Workspace close; delete supports either L3 idle ACTIVE files Sessions or L2 CLOSED/ARCHIVED Sessions; controlled same-Workspace cwd changes ship through the durable cwd_change operations (v1.32). This surface has no per-caller capability flag for later Turns, so callers must handle a refused later Turn — 403 session_operation_forbidden below the role and 409 workspace_unavailable past it, or 404 session_not_found without Workspace read. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.",
"description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile, and a Session pinned to a stored definition may include initial input when its single tools entry selected a profile from the deployment's definition tool-profile allowlist. Otherwise Workspace creation must have empty input. Any caller holding the OPERATOR role may submit later Turns and rename the Session under the same opt-in, while the Workspace registry row stays ACTIVE and still backs the binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session on the frozen execution profile or pinned to a stored definition; Workspace close follows its separate close capability and lifecycle admission; the same caller may cancel a running Turn (its delivery re-checks no grants); archive and unarchive follow their separate retention capabilities after reliable Workspace close; delete supports either L3 idle ACTIVE files Sessions or L2 CLOSED/ARCHIVED Sessions; controlled same-Workspace cwd changes ship through the durable cwd_change operations (v1.32). This surface has no per-caller capability flag for later Turns, so callers must handle a refused later Turn — 403 session_operation_forbidden below the role and 409 workspace_unavailable past it, or 404 session_not_found without Workspace read. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness. A stored AgentDefinition may be selected by agent_id: an explicit agent_revision pins that revision, an omitted one resolves the latest revision at admission and pins it for the Session's life, an unknown agent or revision answers 404 agent_not_found, and definition content that cannot take effect yet answers 409 agent_definition_unsupported with error.field naming the refused field.",
"parameters": [
{
"$ref": "#/components/parameters/IdempotencyKey"
@@ -1209,7 +1209,7 @@
"tags": ["WebShell"],
"operationId": "webShellCreateSession",
"x-qwen-implementation-status": "implemented",
"description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. Any caller holding the OPERATOR role may submit later Turns and rename the Session under the same opt-in, while the Workspace registry row stays ACTIVE and still backs the binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is an active, undeleted qwen-code Session on the frozen execution profile; Workspace close follows its separate close capability and lifecycle admission; the same caller may cancel a running Turn (its delivery re-checks no grants); archive and unarchive follow their separate retention capabilities after reliable Workspace close; delete supports either L3 idle ACTIVE files Sessions or L2 CLOSED/ARCHIVED Sessions; controlled same-Workspace cwd changes ship through the durable cwd_change operations (v1.32). The per-caller workspaceTurns capability on this surface advertises the same submit/rename admission rule; it does not gate cancellation. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.",
"description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission with public Session creation: the fixed server-owned profile for the built-in qwen-code agent, and the profile selected by a stored definition's single allowlisted hosted_profile tools entry for a definition-pinned Session. Any caller holding the OPERATOR role may submit later Turns and rename the Session under the same opt-in, while the Workspace registry row stays ACTIVE and still backs the binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session on the frozen execution profile or pinned to a stored definition; Workspace close follows its separate close capability and lifecycle admission; the same caller may cancel a running Turn (its delivery re-checks no grant it needs survives the cancellation), and the Session's own liveness keeps OPERATOR access even when its Workspace's background-exit observation closes some other bound Session; cancel is always allowed for its own running Turn even outside the Workspace files opt-in. A definition-pinned Session keeps its pinned revision's tool profile, model and instructions for its whole life, and a replay of the create command reuses the original pin; a WebShell Session inherits its Workspace's default Workspace selection when the request omits one.",
"requestBody": {
"required": true,
"content": {
@@ -4158,7 +4158,7 @@
"agent_revision": {
"type": ["string", "null"],
"maxLength": 128,
"description": "Optional explicit revision; omitted resolves current revision atomically at admission."
"description": "Optional explicit revision; omitted resolves the latest revision at admission and pins it. For the built-in qwen-code agent it must equal the deployment revision."
},
"input": {
"type": "array",
@@ -4325,6 +4325,12 @@
"agent_revision": {
"type": "string"
},
"agent_digest": {
"type": "string",
"minLength": 64,
"maxLength": 64,
"description": "Digest of the pinned stored AgentDefinition revision (D8b); absent for the built-in qwen-code agent."
},
"status": {
"type": "string",
"enum": [
@@ -5393,7 +5399,7 @@
"workspaceTurns": {
"type": "boolean",
"default": false,
"description": "True when the caller may submit later Turns of this Workspace-bound Session or rename it: the deployment enables Workspace files, the caller holds the OPERATOR role on a Workspace registry row whose state is ACTIVE and still backs the Session's binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is an active qwen-code Session on the frozen execution profile. Cancelling only aborts work already running, so an OPERATOR caller may cancel a running Turn even when this is false; the capability does not gate cancellation. False for every other caller and for unbound Sessions, which do not use it."
"description": "True when the caller may submit later Turns of this Workspace-bound Session or rename it: the deployment enables Workspace files, the caller holds the OPERATOR role on a Workspace registry row whose state is ACTIVE and still backs the Session's binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session or a Session pinned to a stored definition (both on the Workspace's frozen execution profile). Cancelling only aborts work already running, so an OPERATOR caller may cancel a running Turn even when this is false; the capability does not gate cancellation. False for every other caller and for unbound Sessions, which do not use it."
},
"tasks": {
"type": "boolean"
@@ -5702,6 +5708,10 @@
"snapshot_through_sequence": {
"type": ["integer", "null"],
"format": "int64"
},
"field": {
"type": "string",
"description": "Names the definition field that cannot take effect on 409 agent_definition_unsupported; other errors may omit this field."
}
}
}
@@ -750,7 +750,9 @@ class HostedPublicWorkspaceIT {
.path("error").path("code").asText()).isEqualTo("unsupported_feature");
Map<String, Object> unsupportedAgent = new java.util.LinkedHashMap<>(denied);
unsupportedAgent.put("agent_id", "another-agent");
assertUnavailable(request("POST", "/v1/agents/sessions", unsupportedAgent, "unsupported-agent", "actor", 409));
// D8b: only qwen-code or a stored definition's agent may be admitted.
assertThat(request("POST", "/v1/agents/sessions", unsupportedAgent, "unsupported-agent", "actor", 404)
.path("error").path("code").asText()).isEqualTo("agent_not_found");
Map<String, Object> unknown = new java.util.LinkedHashMap<>(denied);
unknown.put("workspace", Map.of("workspace_id", "unknown"));
request("POST", "/v1/agents/sessions", unknown, "unknown", "actor", 404);
@@ -0,0 +1,425 @@
package com.alibaba.qwen.code.managedagent;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.alibaba.qwen.code.managedagent.api.ApiException;
import com.alibaba.qwen.code.managedagent.api.ApiModels.InputBlock;
import com.alibaba.qwen.code.managedagent.api.AuthenticatedTenantActor;
import com.alibaba.qwen.code.managedagent.api.TenantContextFilter;
import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection;
import com.alibaba.qwen.code.managedagent.harness.UnavailableHarnessConnector;
import com.alibaba.qwen.code.managedagent.service.ManagedAgentService;
import com.alibaba.qwen.code.managedagent.service.RequestDigests;
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry;
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
/**
* D8b and D8c-1 Session admission: a stored AgentDefinition revision pins
* (agent_id, revision, digest), compiles its approval mode and tool profile
* into the Session's columns, refuses content that cannot take effect, and
* replays without re-resolving. The built-in qwen-code path is unchanged.
*/
@SpringBootTest(properties = {
"spring.datasource.url=jdbc:h2:mem:managed-agent-definition-admission;"
+ "MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"qwen.managed-agent.harness.enabled=false",
"qwen.managed-agent.definition-models=configured"
})
@AutoConfigureMockMvc
class ManagedAgentDefinitionAdmissionTest {
private static final String FILES_DEFINITION = """
{"model":{},"instructions":"",
"tools":[{"type":"hosted_profile",
"profile":"hosted-workspace-files/1"}],
"permission_policy":{"approval_mode":"default"}}
""";
private static final String MODEL_ONLY_DEFINITION = """
{"model":{},"instructions":"","tools":[],
"permission_policy":{}}
""";
@Autowired
private MockMvc mvc;
@Autowired
private ObjectMapper mapper;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private ManagedAgentStore store;
@Autowired
private ManagedWorkspaceRegistry registry;
@Test
void omittedAndExplicitRevisionsPinAndExposeTheDigest() throws Exception {
String tenant = tenant();
register(tenant, "ws-a", "storage-a");
grant(tenant, "ws-a", "actor", true);
JsonNode definition = define(tenant, "create", FILES_DEFINITION);
String agentId = definition.get("id").asText();
String digest = definition.get("digest").asText();
var response = createBound(tenant, agentId, null, "pin-bound")
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.agent_revision").value("1"))
.andExpect(jsonPath("$.agent_digest").value(digest))
.andReturn();
String sessionId = mapper.readTree(response.getResponse()
.getContentAsString()).get("id").asText();
assertThat(jdbc.queryForObject(
"SELECT tool_profile FROM managed_agent_session"
+ " WHERE tenant_id = ? AND session_id = ?",
String.class, tenant, sessionId))
.isEqualTo("hosted-workspace-files/1");
assertThat(jdbc.queryForObject(
"SELECT approval_mode FROM managed_agent_session"
+ " WHERE tenant_id = ? AND session_id = ?",
String.class, tenant, sessionId)).isEqualTo("default");
assertThat(jdbc.queryForObject(
"SELECT agent_definition_digest FROM managed_agent_session"
+ " WHERE tenant_id = ? AND session_id = ?",
String.class, tenant, sessionId)).isEqualTo(digest);
// A second revision supersedes nothing already pinned. The update
// keeps the bound-compatible shape and flips the approval mode.
define(tenant, "update", agentId, """
{"model":{},"instructions":"",
"tools":[{"type":"hosted_profile",
"profile":"hosted-workspace-files/1"}],
"permission_policy":{"approval_mode":"yolo"}}
""");
createBound(tenant, agentId, null, "pin-latest")
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.agent_revision").value("2"));
createBound(tenant, agentId, "1", "pin-explicit")
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.agent_revision").value("1"))
.andExpect(jsonPath("$.agent_digest").value(digest));
}
@Test
void replayReturnsThePinnedRevisionWithoutResolvingAgain()
throws Exception {
String tenant = tenant();
String agentId = define(tenant, "create", MODEL_ONLY_DEFINITION)
.get("id").asText();
String body = "{\"agent_id\":\"" + agentId + "\",\"input\":[]}";
String first = mvc.perform(post("/v1/agents/sessions")
.header(TenantContextFilter.HEADER, tenant)
.header("Idempotency-Key", "replay")
.principal(actor(tenant, "actor"))
.contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.agent_revision").value("1"))
.andReturn().getResponse().getContentAsString();
JsonNode firstJson = mapper.readTree(first);
define(tenant, "update", agentId, FILES_DEFINITION);
String replayed = mvc.perform(post("/v1/agents/sessions")
.header(TenantContextFilter.HEADER, tenant)
.header("Idempotency-Key", "replay")
.principal(actor(tenant, "actor"))
.contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.agent_revision").value("1"))
.andReturn().getResponse().getContentAsString();
// The replay returns the original Session, pinned revision included;
// background snapshot progress is not part of the receipt.
JsonNode replayedJson = mapper.readTree(replayed);
assertThat(replayedJson.get("id")).isEqualTo(firstJson.get("id"));
assertThat(replayedJson.get("agent_digest"))
.isEqualTo(firstJson.get("agent_digest"));
}
@Test
void unknownAgentOrRevisionAnswers404AcrossTenants() throws Exception {
String tenant = tenant();
String agentId = define(tenant, "create", MODEL_ONLY_DEFINITION)
.get("id").asText();
register(tenant, "ws-a", "storage-a");
grant(tenant, "ws-a", "actor", true);
createBound(tenant, "agent_" + "f".repeat(32), null, "missing")
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error.code").value("agent_not_found"));
createBound(tenant, agentId, "9", "missing-revision")
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error.code").value("agent_not_found"));
createBound(tenant, agentId, "abc", "malformed-revision")
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error.code").value("agent_not_found"));
// Another tenant cannot resolve the same definition.
String other = tenant();
register(other, "ws-a", "storage-a");
grant(other, "ws-a", "actor", true);
createBound(other, agentId, null, "cross-tenant")
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error.code").value("agent_not_found"));
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.MethodSource("unsupportedDefinitions")
void storedContentThatCannotTakeEffectIsRefusedAtAdmission(
String definition, String field) throws Exception {
String tenant = tenant();
String agentId = define(tenant, "create", definition).get("id")
.asText();
createBound(tenant, agentId, null, "unsupported")
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error.code")
.value("agent_definition_unsupported"))
.andExpect(jsonPath("$.error.field").value(field));
}
private static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments>
unsupportedDefinitions() {
return java.util.stream.Stream.of(
org.junit.jupiter.params.provider.Arguments.of(
"{\"model\":{\"id\":\"qwen3\"},\"instructions\":\"\","
+ "\"tools\":[],\"permission_policy\":{}}",
"model"),
org.junit.jupiter.params.provider.Arguments.of(
"{\"model\":{},\"instructions\":\"" + "界".repeat(21846)
+ "\",\"tools\":[],\"permission_policy\":{}}",
"instructions"),
org.junit.jupiter.params.provider.Arguments.of(
"{\"model\":{},\"instructions\":\"\",\"tools\":[],"
+ "\"permission_policy\":{\"approval_timeout_ms\":5000}}",
"permission_policy"),
org.junit.jupiter.params.provider.Arguments.of(
"{\"model\":{},\"instructions\":\"\",\"tools\":[{"
+ "\"type\":\"hosted_profile\",\"profile\":"
+ "\"hosted-workspace-files/2\"}],"
+ "\"permission_policy\":{}}",
"tools"),
org.junit.jupiter.params.provider.Arguments.of(
"{\"model\":{},\"instructions\":\"\",\"tools\":[{"
+ "\"type\":\"hosted_profile\",\"profile\":"
+ "\"hosted-workspace-files/1\"}],"
+ "\"permission_policy\":{},"
+ "\"skills\":[{\"name\":\"x\"}]}",
"skills"),
org.junit.jupiter.params.provider.Arguments.of(
"{\"model\":{},\"instructions\":\"\",\"tools\":[{"
+ "\"type\":\"hosted_profile\",\"profile\":"
+ "\"hosted-workspace-files/1\"}],"
+ "\"permission_policy\":{},"
+ "\"environment_template_id\":\"tpl\"}",
"environment_template_id"));
}
@Test
void aModelOnlyDefinitionCannotBindAWorkspace() throws Exception {
String tenant = tenant();
String agentId = define(tenant, "create", MODEL_ONLY_DEFINITION)
.get("id").asText();
register(tenant, "ws-a", "storage-a");
grant(tenant, "ws-a", "actor", true);
createBound(tenant, agentId, null, "unbound-tools")
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error.code")
.value("agent_definition_unsupported"))
.andExpect(jsonPath("$.error.field").value("tools"));
// The same definition is admitted without a Workspace.
mvc.perform(post("/v1/agents/sessions")
.header(TenantContextFilter.HEADER, tenant)
.header("Idempotency-Key", "model-only")
.principal(actor(tenant, "actor"))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"agent_id\":\"" + agentId
+ "\",\"input\":[]}"))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.agent_revision").value("1"))
.andExpect(jsonPath("$.agent_digest").exists());
}
@Test
void theDefinitionCreatorMaySubmitLaterTurnsOfABoundSession() {
String tenant = tenant();
register(tenant, "ws-a", "storage-a",
WorkspaceExecutionProfile.CONFIG_REF,
WorkspaceExecutionProfile.POLICY_REF);
grant(tenant, "ws-a", "actor", true);
String agentId;
try {
agentId = define(tenant, "create", FILES_DEFINITION).get("id")
.asText();
} catch (Exception error) {
throw new AssertionError(error);
}
String digest = "sha256:" + "a".repeat(64);
String sessionId = store.insertWorkspaceSessionCommand(tenant,
"actor", "create", digest, agentId, null, null, List.of(),
null, new WorkspaceSelection("ws-a", ".")).sessionId();
UnavailableHarnessConnector harness = new UnavailableHarnessConnector() {
@Override
public boolean isWorkspaceFilesAvailable() {
return true;
}
};
ManagedAgentService service = new ManagedAgentService(store,
new RequestDigests(), null, harness, registry);
assertThat(service.getWebShellSession(tenant, "actor", sessionId)
.capabilities().workspaceTurns()).isTrue();
// The dispatch is refused only by the unavailable Harness.
assertThatThrownBy(() -> service.submitTurn(tenant, "actor", "later",
sessionId, List.of(new InputBlock("text", "go"))))
.isInstanceOfSatisfying(ApiException.class, error ->
assertThat(error.getCode())
.isNotEqualTo("workspace_unavailable"));
}
@Test
void theBuiltInAgentStaysUnchanged() throws Exception {
String tenant = tenant();
mvc.perform(post("/v1/agents/sessions")
.header(TenantContextFilter.HEADER, tenant)
.header("Idempotency-Key", "builtin")
.principal(actor(tenant, "actor"))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"agent_id\":\"qwen-code\",\"input\":[]}"))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.agent_revision").value("1"))
.andExpect(jsonPath("$.agent_digest").doesNotExist());
mvc.perform(post("/v1/agents/sessions")
.header(TenantContextFilter.HEADER, tenant)
.header("Idempotency-Key", "builtin-revision")
.principal(actor(tenant, "actor"))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"agent_id\":\"qwen-code\","
+ "\"agent_revision\":\"2\",\"input\":[]}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error.code")
.value("unsupported_feature"));
}
@Test
void admitsModelInstructionsAndPublishesADigestAddressedResource() throws Exception {
String tenant = tenant();
JsonNode definition = define(tenant, "create", """
{"model":{"id":"configured"},"instructions":"Review in Chinese.",
"tools":[],"permission_policy":{"approval_mode":"default"}}
""");
JsonNode receipt = mapper.readTree(mvc.perform(post("/v1/agents/sessions")
.header(TenantContextFilter.HEADER, tenant)
.header("Idempotency-Key", "definition-model")
.principal(actor(tenant, "actor"))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"agent_id\":\"" + definition.get("id").asText()
+ "\",\"input\":[]}"))
.andExpect(status().isAccepted()).andReturn().getResponse().getContentAsString());
var session = store.requireSession(tenant, receipt.get("id").asText());
var definitions = new com.alibaba.qwen.code.managedagent.store.ManagedAgentDefinitionStore(jdbc);
assertThat(definitions.pinnedContent(session).get("instructions"))
.isEqualTo("Review in Chinese.");
var resources = new com.alibaba.qwen.code.managedagent.store.ManagedSessionStore(jdbc);
var first = resources.publishAgentInstructions(tenant, "workspace", session.sessionId(),
"Review in Chinese.");
assertThat(resources.publishAgentInstructions(tenant, "workspace", session.sessionId(),
"Review in Chinese.")).isEqualTo(first);
assertThat(first.resourceId()).isEqualTo("agent-instructions-" + first.digest());
assertThat(jdbc.queryForObject("SELECT inline_bytes FROM qwen_managed_session_resource"
+ " WHERE tenant_id = ? AND session_id = ? AND resource_id = ?",
byte[].class, tenant, session.sessionId(), first.resourceId()))
.isEqualTo("Review in Chinese.".getBytes(StandardCharsets.UTF_8));
jdbc.update("UPDATE managed_agent_session SET agent_definition_digest = ?"
+ " WHERE tenant_id = ? AND session_id = ?", "0".repeat(64), tenant, session.sessionId());
assertThatThrownBy(() -> definitions.pinnedContent(store.requireSession(tenant, session.sessionId())))
.isInstanceOf(ApiException.class);
}
private JsonNode define(String tenant, String key, String definition)
throws Exception {
return define(tenant, key, null, definition);
}
private JsonNode define(String tenant, String key, String agentId,
String definition) throws Exception {
String path = "/v1/agents" + (agentId == null ? "" : "/" + agentId);
var result = mvc.perform(post(path)
.header(TenantContextFilter.HEADER, tenant)
.header("Idempotency-Key", key)
.principal(actor(tenant, "actor"))
.contentType(MediaType.APPLICATION_JSON)
.content(definition))
.andExpect(status().isAccepted()).andReturn();
return mapper.readTree(result.getResponse().getContentAsString());
}
private org.springframework.test.web.servlet.ResultActions createBound(
String tenant, String agentId, String revision, String key)
throws Exception {
String body = "{\"agent_id\":\"" + agentId + "\",\"input\":[],"
+ (revision == null ? ""
: "\"agent_revision\":\"" + revision + "\",")
+ "\"workspace\":{\"workspace_id\":\"ws-a\"}}";
return mvc.perform(post("/v1/agents/sessions")
.header(TenantContextFilter.HEADER, tenant)
.header("Idempotency-Key", key)
.principal(actor(tenant, "actor"))
.contentType(MediaType.APPLICATION_JSON).content(body));
}
private String register(String tenant, String id, String storageId) {
return register(tenant, id, storageId, "config-" + id,
"policy-" + id);
}
private String register(String tenant, String id, String storageId,
String configRef, String policyRef) {
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id,"
+ " workspace_id, workspace_generation, storage_id,"
+ " display_name, config_ref, policy_ref, state)"
+ " VALUES (?, ?, 1, ?, ?, ?, ?, 'ACTIVE')",
tenant, id, storageId, id, configRef, policyRef);
return id;
}
private void grant(String tenant, String workspaceId, String actorId,
boolean canCreate) {
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
+ " workspace_id, actor_id, role)"
+ " VALUES (?, ?, ?, ?)",
tenant, workspaceId,
actorId.getBytes(StandardCharsets.UTF_8),
canCreate ? "OPERATOR" : "READER");
}
private static String tenant() {
return "tenant-" + UUID.randomUUID();
}
private static AuthenticatedTenantActor actor(String tenant,
String actorId) {
return new AuthenticatedTenantActor() {
@Override
public String getName() {
return actorId;
}
@Override
public String tenantId() {
return tenant;
}
@Override
public String actorId() {
return actorId;
}
};
}
}
@@ -24,6 +24,7 @@ import com.alibaba.qwen.code.managedagent.harness.UnavailableHarnessConnector;
import com.alibaba.qwen.code.managedagent.service.HarnessCoordinator;
import com.alibaba.qwen.code.managedagent.service.ManagedAgentService;
import com.alibaba.qwen.code.managedagent.service.RequestDigests;
import com.alibaba.qwen.code.managedagent.store.ManagedAgentDefinitionStore;
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry;
import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionMutationKind;
@@ -722,18 +723,24 @@ class ManagedWorkspaceAdmissionTest {
}
@Test
void emptyBoundCreationOutsideTheProfileIsNotAdmittedForLaterTurns() {
void boundLaterTurnAdmissionReadsTheAgentPinAndFrozenProfile() {
String tenant = "tenant-" + UUID.randomUUID();
register(tenant, "ws-a", "storage-a",
WorkspaceExecutionProfile.CONFIG_REF,
WorkspaceExecutionProfile.POLICY_REF);
grant(tenant, "ws-a", "actor-a", true);
String digest = "sha256:" + "a".repeat(64);
// Empty bound creation skips the execution-profile validation by
// design, so a non qwen-code agent_id can be bound; the later-Turn
// admission gate is what must refuse it.
String sessionId = store.insertWorkspaceSessionCommand(tenant,
"actor-a", "create", digest, "another-agent", null, null,
// A Session pinned to a compiled stored definition (D8c-1) passes
// the later-Turn gate exactly like qwen-code.
String agentId = "agent_" + "b".repeat(32);
new ManagedAgentDefinitionStore(jdbc).create(tenant, "def",
"def-digest", agentId, "c".repeat(64),
"{\"model\":{},\"instructions\":\"\","
+ "\"tools\":[{\"type\":\"hosted_profile\","
+ "\"profile\":\"hosted-workspace-files/1\"}],"
+ "\"permission_policy\":{}}", 1L);
String pinnedId = store.insertWorkspaceSessionCommand(tenant,
"actor-a", "create", digest, agentId, null, null,
List.of(), null, new WorkspaceSelection("ws-a", "."))
.sessionId();
// A Session whose snapshotted profile refs are not the frozen pair
@@ -758,12 +765,12 @@ class ManagedWorkspaceAdmissionTest {
ManagedAgentService enabled = new ManagedAgentService(store,
new RequestDigests(), null, enabledHarness, registry);
assertThat(enabled.getWebShellSession(tenant, "actor-a", sessionId)
.capabilities().workspaceTurns()).isFalse();
assertThat(enabled.getWebShellSession(tenant, "actor-a", pinnedId)
.capabilities().workspaceTurns()).isTrue();
assertThat(enabled.getWebShellSession(tenant, "actor-a", driftedId)
.capabilities().workspaceTurns()).isFalse();
assertThatThrownBy(() -> enabled.submitTurn(tenant, "actor-a",
"later", sessionId,
"later", driftedId,
List.of(new InputBlock("text", "go"))))
.isInstanceOfSatisfying(ApiException.class, error ->
assertThat(error.getCode())
@@ -101,6 +101,15 @@ class ManagedAgentPropertiesTest {
.hasRootCauseMessage("Hosted Workspace files require"
+ " a supported Harness, Session Store and Session-isolated"
+ " local-process Broker with Workspace mounts"));
// A definition Session publishes instructions through the Session
// Store at Harness create, so the Harness implies the Store even
// when Workspace files are off.
context.withPropertyValues(
"qwen.managed-agent.harness.enabled=true",
"qwen.managed-agent.session-store.enabled=false")
.run(started -> assertThat(started).hasFailed()
.getFailure().hasRootCauseInstanceOf(IllegalStateException.class)
.hasRootCauseMessage("Hosted Harness requires the Managed Session Store"));
}
@Configuration(proxyBeanMethods = false)
@@ -816,6 +816,51 @@ class QwenHostedHarnessConnectorTest {
verify(client, times(2)).runMessageOperation(attached, stop);
}
// D8b: a definition-pinned child Session's message stop loads through
// the same entry, so the load must carry the pin or the Harness would
// refuse the Session with hosted_agent_definition_conflict.
@Test
void aMessageStopOfAPinnedSessionCarriesTheAgentDefinition() {
HostedHarnessClient client = mock(HostedHarnessClient.class);
HostedHarnessCapabilities capabilities = mock(HostedHarnessCapabilities.class);
HarnessSessionRef attached = mock(HarnessSessionRef.class);
SessionRecord session = new SessionRecord("tenant-a", SESSION_ID,
"agent_00112233445566778899aabbccddeeff", "2", null, "ACTIVE",
null, null, 0, 0, 0, 1, 1, null, 1,
new ContextBinding("tenant-a", "workspace", 1,
"storage", ".", "config", 1), "default",
"hosted-workspace-shell/1",
"a".repeat(64));
AgentStateStore sessions = mock(AgentStateStore.class);
WorkspaceExecutionStore execution = mock(WorkspaceExecutionStore.class);
when(client.capabilities()).thenReturn(capabilities);
when(capabilities.getBootId()).thenReturn(BOOT_ID);
when(sessions.requireSession("tenant-a", SESSION_ID)).thenReturn(session);
when(client.loadSession(any(LoadHarnessSession.class))).thenReturn(attached);
when(attached.getHarnessBootId()).thenReturn(BOOT_ID);
when(attached.getApprovalMode()).thenReturn("default");
ManagedAgentProperties properties = properties();
properties.getHarness().setWorkspaceFilesEnabled(true);
ManagedActionStore actions = mock(ManagedActionStore.class);
when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn("default");
QwenHostedHarnessConnector connector = new QwenHostedHarnessConnector(
properties, sessions, execution, actions);
ReflectionTestUtils.setField(connector, "client", client);
connector.runMessageOperation("tenant-a", SESSION_ID, Map.of(
"operationId", "66666666-6666-4666-8666-666666666666",
"kind", "stop"));
ArgumentCaptor<LoadHarnessSession> loads = ArgumentCaptor.forClass(LoadHarnessSession.class);
verify(client, times(1)).loadSession(loads.capture());
assertThat(ReflectionTestUtils.<Map<String, Object>>invokeMethod(
loads.getValue(), "toJson"))
.containsEntry("agentDefinition", Map.of(
"agentId", "agent_00112233445566778899aabbccddeeff",
"revision", "2",
"digest", "a".repeat(64)))
.containsEntry("stopMessages", true);
}
@Test
void channelOperationsReauthorizeTheWorkspaceLikeEveryNewWorkDispatch() {
HostedHarnessClient client = mock(HostedHarnessClient.class);
@@ -0,0 +1,170 @@
package com.alibaba.qwen.code.managedagent.store;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.alibaba.qwen.code.managedagent.api.ApiException;
import java.util.List;
import org.junit.jupiter.api.Test;
/**
* The D8c-1 compile: accepted shapes map to the approval mode and tool
* profile a Session pins; every other shape refuses admission with
* {@code 409 agent_definition_unsupported} and names its field.
*/
class AgentDefinitionCompilerTest {
private static final List<String> FILES_ONLY = List.of(
"hosted-workspace-files/1");
private static String definition(String tools,
String permissionPolicy) {
return "{\"model\":{},\"instructions\":\"\",\"tools\":" + tools
+ ",\"permission_policy\":" + permissionPolicy + "}";
}
private static String hostedProfile(String profile) {
return "[{\"type\":\"hosted_profile\",\"profile\":\"" + profile
+ "\"}]";
}
@Test
void compilesTheApprovalModeAndProfileForABoundSession() {
AgentDefinitionCompiler.Compiled compiled =
AgentDefinitionCompiler.compile(definition(
hostedProfile("hosted-workspace-files/1"),
"{\"approval_mode\":\"default\"}"), true, "yolo",
FILES_ONLY);
assertThat(compiled.approvalMode()).isEqualTo("default");
assertThat(compiled.toolProfile())
.isEqualTo("hosted-workspace-files/1");
}
@Test
void inheritsTheDeploymentApprovalModeFromAnEmptyPolicy() {
AgentDefinitionCompiler.Compiled compiled =
AgentDefinitionCompiler.compile(definition(
hostedProfile("hosted-workspace-files/1"), "{}"),
true, "auto-edit", FILES_ONLY);
assertThat(compiled.approvalMode()).isEqualTo("auto-edit");
}
@Test
void unboundSessionsCompileModelOnly() {
AgentDefinitionCompiler.Compiled compiled =
AgentDefinitionCompiler.compile(definition("[]",
"{\"approval_mode\":\"auto-edit\"}"), false, "yolo",
FILES_ONLY);
assertThat(compiled.approvalMode()).isEqualTo("auto-edit");
assertThat(compiled.toolProfile()).isNull();
}
@Test
void aNonDefaultProfileJoinsThroughTheDeploymentAllowlist() {
AgentDefinitionCompiler.Compiled compiled =
AgentDefinitionCompiler.compile(definition(
hostedProfile("hosted-workspace-files/2"), "{}"),
true, "yolo", List.of("hosted-workspace-files/1",
"hosted-workspace-files/2"));
assertThat(compiled.toolProfile())
.isEqualTo("hosted-workspace-files/2");
}
@Test
void shellProfilesNeverPairWithYolo() {
List<String> allowlist = List.of("hosted-workspace-shell/1");
assertThatThrownBy(() -> AgentDefinitionCompiler.compile(definition(
hostedProfile("hosted-workspace-shell/1"), "{}"), true,
"yolo", allowlist))
.isInstanceOfSatisfying(ApiException.class,
error -> assertThat(error.getDetails().get("field"))
.isEqualTo("tools"));
AgentDefinitionCompiler.Compiled compiled =
AgentDefinitionCompiler.compile(definition(
hostedProfile("hosted-workspace-shell/1"),
"{\"approval_mode\":\"default\"}"), true, "yolo",
allowlist);
assertThat(compiled.toolProfile())
.isEqualTo("hosted-workspace-shell/1");
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource(textBlock = """
'{"model":{"id":"qwen3"},"instructions":"","tools":[],"permission_policy":{}}', model
'{"model":{},"instructions":7,"tools":[],"permission_policy":{}}', instructions
'{"model":{},"instructions":"","tools":[],"permission_policy":{"plan":true}}', permission_policy
'{"model":{},"instructions":"","tools":[],"permission_policy":{"approval_timeout_ms":5000}}', permission_policy
'{"model":{},"instructions":"","tools":[],"permission_policy":{"approval_mode":"plan"}}', permission_policy
'{"model":{},"instructions":"","tools":[],"permission_policy":{"approval_mode":"default","extra":1}}', permission_policy
'{"model":{},"instructions":"","tools":[],"permission_policy":{}, "skills":[{"name":"review"}]}', skills
'{"model":{},"instructions":"","tools":[],"permission_policy":{}, "mcp_servers":[{"id":"fs"}]}', mcp_servers
'{"model":{},"instructions":"","tools":[],"permission_policy":{}, "environment_template_id":"tpl"}', environment_template_id
""")
void refusesContentThatCannotTakeEffect(String json, String field) {
assertThatThrownBy(() -> AgentDefinitionCompiler.compile(json, false,
"yolo", FILES_ONLY))
.isInstanceOfSatisfying(ApiException.class, error -> {
assertThat(error.getStatus().value()).isEqualTo(409);
assertThat(error.getCode())
.isEqualTo("agent_definition_unsupported");
assertThat(error.getDetails().get("field"))
.isEqualTo(field);
});
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource(textBlock = """
'[{"type":"hosted_profile","profile":"hosted-workspace-files/2"}]'
'[{"type":"hosted_profile","profile":"hosted-workspace-shell/1"}]'
'[{"type":"tool","name":"read_file"}]'
'[{"type":"hosted_profile"}]'
'[{"type":"hosted_profile","profile":"hosted-workspace-files/1","extra":1}]'
'[{"type":"hosted_profile","profile":"hosted-workspace-files/1"},{"type":"hosted_profile","profile":"hosted-workspace-files/1"}]'
'[]'
""")
void boundSessionsRefuseAnythingButOneAllowlistedProfile(String tools) {
assertThatThrownBy(() -> AgentDefinitionCompiler.compile(
definition(tools, "{}"), true, "yolo", FILES_ONLY))
.isInstanceOfSatisfying(ApiException.class, error ->
assertThat(error.getDetails().get("field"))
.isEqualTo("tools"));
}
@Test
void unboundSessionsRefuseAnyToolProfile() {
assertThatThrownBy(() -> AgentDefinitionCompiler.compile(definition(
hostedProfile("hosted-workspace-files/1"), "{}"), false,
"yolo", FILES_ONLY))
.isInstanceOfSatisfying(ApiException.class, error ->
assertThat(error.getDetails().get("field"))
.isEqualTo("tools"));
}
@Test
void metadataNeverAffectsExecution() {
String json = "{\"model\":{},\"instructions\":\"\",\"tools\":[],"
+ "\"permission_policy\":{},"
+ "\"metadata\":{\"team\":\"review\",\"mode\":\"yolo\"}}";
assertThat(AgentDefinitionCompiler.compile(json, false, "yolo",
FILES_ONLY).approvalMode()).isEqualTo("yolo");
}
@Test
void acceptsAnAllowlistedModelAndBoundedUtf8Instructions() {
String accepted = "{\"model\":{\"id\":\"configured\"},"
+ "\"instructions\":\"" + "界".repeat(21845) + "\","
+ "\"tools\":[],\"permission_policy\":{}}";
assertThat(AgentDefinitionCompiler.compile(accepted, false, "yolo",
FILES_ONLY, List.of("configured")).toolProfile()).isNull();
assertThatThrownBy(() -> AgentDefinitionCompiler.compile(
accepted.replace("界\"", "界界\""), false, "yolo",
FILES_ONLY, List.of("configured")))
.isInstanceOfSatisfying(ApiException.class, error ->
assertThat(error.getDetails().get("field"))
.isEqualTo("instructions"));
assertThatThrownBy(() -> AgentDefinitionCompiler.compile(accepted,
false, "yolo", FILES_ONLY, List.of()))
.isInstanceOfSatisfying(ApiException.class, error ->
assertThat(error.getDetails().get("field"))
.isEqualTo("model"));
}
}
@@ -64,7 +64,7 @@ class WorkspaceMigrationMySqlIT {
+ " WHERE installed_rank > ? AND success = TRUE ORDER BY installed_rank",
String.class, lastRank)).containsExactly("48", "49", "50",
"51", "52", "53", "54", "55", "56", "57", "60", "62", "64",
"65");
"65", "66");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_workspace_migration", Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_runtime_storage_fence", Integer.class)).isZero();
}
@@ -217,6 +217,126 @@ class WorkspaceRecoveryStoreTest {
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_workspace_recovery_operation", Integer.class)).isZero();
}
@Test
void capturesAndVerifiesSessionsThatPinStoredDefinitions() throws Exception {
String builtin = session("workspace-a");
// A stored definition is compiled and pinned at admission (D8c-1);
// the storage also holds a deleted Session pinned to it.
String agentId = "agent_" + "b".repeat(32);
String definitionDigest = "c".repeat(64);
String definitionJson = "{\"model\":{},\"instructions\":\"\","
+ "\"tools\":[{\"type\":\"hosted_profile\","
+ "\"profile\":\"hosted-workspace-files/1\"}],"
+ "\"permission_policy\":{\"approval_mode\":\"default\"}}";
sessions.setManagedAgentDefinitionStore(
new ManagedAgentDefinitionStore(jdbc));
String pinned = new TransactionTemplate(manager).execute(status -> {
new ManagedAgentDefinitionStore(jdbc).create("tenant", "def",
"def-digest", agentId, definitionDigest, definitionJson,
1L);
return sessions.insertWorkspaceSessionCommand("tenant", "actor",
UUID.randomUUID().toString(),
"sha256:" + "a".repeat(64), agentId, null, null, List.of(),
null, new WorkspaceSelection("workspace-b", "."))
.sessionId();
});
String deleted = new TransactionTemplate(manager).execute(status ->
sessions.insertWorkspaceSessionCommand("tenant", "actor",
UUID.randomUUID().toString(),
"sha256:" + "a".repeat(64), agentId, "1", null,
List.of(), null,
new WorkspaceSelection("workspace-b", "."))
.sessionId());
head(deleted);
retireSession(deleted);
// The pinned Session carries non-empty agent instructions published
// through the production publisher; W1 must read them back.
head(pinned);
jdbc.update("UPDATE qwen_managed_session_journal_head SET workspace_id = 'workspace-b'"
+ " WHERE session_id = ?", pinned);
var instructions = new ManagedSessionStore(jdbc).publishAgentInstructions(
"tenant", "workspace-b", pinned, "Keep the agent instructions. 保留指令。");
JsonNode sources = call(capture(), "sessions").path("sessions");
assertThat(sources).hasSize(3);
JsonNode pinnedSource = null;
JsonNode builtinSource = null;
JsonNode deletedSource = null;
for (JsonNode entry : sources) {
JsonNode source = entry.path("source");
String id = source.path("sessionId").asText();
if (id.equals(pinned)) {
pinnedSource = source;
} else if (id.equals(builtin)) {
builtinSource = source;
} else if (id.equals(deleted)) {
deletedSource = source;
}
}
assertThat(pinnedSource).isNotNull();
assertThat(deletedSource).isNotNull();
for (JsonNode source : List.of(pinnedSource, deletedSource)) {
assertThat(source.path("agentRevision").asText()).isEqualTo("1");
assertThat(source.path("agentDefinitionDigest").asText())
.isEqualTo(definitionDigest);
}
assertThat(builtinSource.path("agentRevision").asText())
.isEqualTo("1");
assertThat(builtinSource.path("agentDefinitionDigest").isNull())
.isTrue();
// The recovery read of the PUBLISHED instructions resource must
// return the published bytes (resource_layout_unsupported before
// the reader accepted the kind).
var capture = capture();
ObjectNode ref = object().put("resourceId", instructions.resourceId())
.put("kind", instructions.kind())
.put("schemaVersion", instructions.schemaVersion())
.put("byteLength", (int) instructions.byteLength())
.put("digest", instructions.digest());
ObjectNode read = object().put("sessionId", pinned);
read.set("ref", ref);
assertThat(capture.call("resource", read).path("bytesBase64").asText())
.isEqualTo(java.util.Base64.getEncoder().encodeToString(
"Keep the agent instructions. 保留指令。"
.getBytes(StandardCharsets.UTF_8)));
capture.call("enqueueRef", read);
assertThat(capture.call("nextRef", object().put("sessionId", pinned)).toString())
.isEqualTo(ref.toString());
capture.call("completeRef", read);
assertThat(capture.inspect().path("pendingReferences").asLong())
.isZero();
// Capture and verify still pass on the mixed storage.
ObjectNode asset = object().put("key", "b".repeat(64));
asset.set("metadata", object().put("type", "entry").put("path",
"workspace").put("entryType", "directory").put("mode", 493));
capture.call("asset", asset);
for (String id : List.of(builtin, pinned, deleted)) {
capture.call("sessionComplete", object().put("sessionId", id)
.set("summary", object()));
}
Path manifest = Files.createDirectories(bundle
.resolve(".w1-recovery")).resolve("manifest.json");
Files.writeString(manifest, manifest(capture).toString());
ObjectNode finish = object().put("manifestDigest",
WorkspaceRecoveryStore.hash(Files.readAllBytes(manifest)));
finish.set("result", object().put("contentVerified", true)
.put("activation", false));
assertThat(capture.call("finish", finish).path("result")
.path("authorityCompatible").asBoolean()).isTrue();
String original = request.path("operationId").asText();
request.put("captureOperationId", original).put("operationId",
UUID.randomUUID().toString());
Files.move(root, temporary.resolve("lost-source"));
var verify = store("verify");
for (String id : List.of(builtin, pinned, deleted)) {
verify.call("sessionComplete", object().put("sessionId", id)
.set("summary", object()));
}
verify.call("asset", asset);
assertThat(verify.call("finish", finish).path("state").asText())
.isEqualTo("VERIFIED");
}
@Test
void leaseFingerprintKeepsWallClockPrecisionAcrossConnectionTimeZones() {
String session = session("workspace-a");
@@ -10,6 +10,7 @@ public final class CreateHarnessSession {
private final ManagedSessionStoreConnection managedSessionStore;
private final String toolProfile;
private final Long approvalTimeoutMs;
private final Map<String, Object> agentDefinition;
private final Map<String, Object> lineage;
private final boolean childWorkspaces;
@@ -20,6 +21,7 @@ public final class CreateHarnessSession {
this.managedSessionStore = builder.managedSessionStore;
this.toolProfile = builder.toolProfile;
this.approvalTimeoutMs = builder.approvalTimeoutMs;
this.agentDefinition = builder.agentDefinition;
this.lineage = builder.lineage;
this.childWorkspaces = builder.childWorkspaces;
}
@@ -48,6 +50,9 @@ public final class CreateHarnessSession {
if (approvalTimeoutMs != null) {
result.put("approvalTimeoutMs", approvalTimeoutMs);
}
if (agentDefinition != null) {
result.put("agentDefinition", agentDefinition);
}
if (lineage != null) {
result.put("lineage", lineage);
}
@@ -63,6 +68,7 @@ public final class CreateHarnessSession {
private ManagedSessionStoreConnection managedSessionStore;
private String toolProfile;
private Long approvalTimeoutMs;
private Map<String, Object> agentDefinition;
private Map<String, Object> lineage;
private boolean childWorkspaces;
@@ -77,6 +83,11 @@ public final class CreateHarnessSession {
return this;
}
public Builder agentDefinition(Map<String, Object> value) {
this.agentDefinition = Map.copyOf(value);
return this;
}
public Builder approvalTimeoutMs(long value) {
if (value < 1000 || value > 86400000) {
throw new IllegalArgumentException("Invalid approval timeout");
@@ -12,16 +12,24 @@ public final class LoadHarnessSession {
private final boolean driveRuntimeRecovery;
private final boolean cancellationTakeover;
private Map<String, Object> lifecycleAuthority;
private Map<String, Object> agentDefinition;
private boolean stopMessages;
private boolean childWorkspaces;
/**
* #13753 I2: a copy that tells the Hosted side this control plane
* serves child Workspaces (see {@link CreateHarnessSession.Builder#childWorkspaces}).
*/
private boolean childWorkspaces;
public LoadHarnessSession withAgentDefinition(Map<String, Object> value) {
this.agentDefinition = value == null ? null : Map.copyOf(value);
return this;
}
public LoadHarnessSession withChildWorkspaces(boolean value) {
LoadHarnessSession copy = new LoadHarnessSession(harnessSessionId, managedSessionStore,
passiveManagedRuntimeRecovery, toolProfile, driveRuntimeRecovery, cancellationTakeover);
copy.agentDefinition = agentDefinition;
copy.lifecycleAuthority = lifecycleAuthority;
copy.childWorkspaces = value;
copy.stopMessages = stopMessages;
@@ -34,6 +42,7 @@ public final class LoadHarnessSession {
}
LoadHarnessSession copy = new LoadHarnessSession(harnessSessionId, managedSessionStore,
passiveManagedRuntimeRecovery, toolProfile, driveRuntimeRecovery, cancellationTakeover);
copy.agentDefinition = agentDefinition;
copy.lifecycleAuthority = Map.of("operationId", operationId, "claimGeneration", claimGeneration);
copy.childWorkspaces = childWorkspaces;
copy.stopMessages = stopMessages;
@@ -45,6 +54,7 @@ public final class LoadHarnessSession {
public LoadHarnessSession withStoppedMessages() {
LoadHarnessSession copy = new LoadHarnessSession(harnessSessionId, managedSessionStore,
passiveManagedRuntimeRecovery, toolProfile, driveRuntimeRecovery, cancellationTakeover);
copy.agentDefinition = agentDefinition;
copy.lifecycleAuthority = lifecycleAuthority;
copy.childWorkspaces = childWorkspaces;
copy.stopMessages = true;
@@ -128,6 +138,9 @@ public final class LoadHarnessSession {
if (cancellationTakeover) {
result.put("cancellationTakeover", true);
}
if (agentDefinition != null) {
result.put("agentDefinition", agentDefinition);
}
if (stopMessages) {
result.put("stopMessages", true);
}
@@ -0,0 +1,35 @@
package com.alibaba.qwen.code.daemon;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.util.Map;
import org.junit.jupiter.api.Test;
class HostedAgentDefinitionRequestTest {
private static final String SESSION = "22222222-2222-4222-8222-222222222222";
@Test
void createDeliversTheFullPinAndLifecycleLoadRetainsOnlyTheIdentity() {
Map<String, Object> identity = Map.of("agentId", "agent_" + "a".repeat(32),
"revision", "1", "digest", "b".repeat(64));
Map<String, Object> full = Map.of("agentId", identity.get("agentId"),
"revision", "1", "digest", identity.get("digest"),
"model", Map.of("id", "configured"),
"instructionsRef", Map.of("resourceId", "instructions",
"kind", "managed-agent-instructions", "schemaVersion", 1,
"byteLength", 4, "digest", "c".repeat(64)));
Map<String, Object> create = CreateHarnessSession.builder().harnessSessionId(SESSION)
.agentDefinition(full).lineage(SESSION, SESSION, "child-run-1", 1)
.build().toJson();
assertEquals(full, create.get("agentDefinition"));
assertEquals(Map.of("parentSessionId", SESSION, "rootSessionId", SESSION,
"parentChildRunId", "child-run-1", "depth", 1), create.get("lineage"));
Map<String, Object> load = new LoadHarnessSession(SESSION)
.withAgentDefinition(identity).forLifecycle("close-1", 2).toJson();
assertEquals(identity, load.get("agentDefinition"));
assertFalse(((Map<?, ?>) load.get("agentDefinition")).containsKey("model"));
assertEquals(Map.of("operationId", "close-1", "claimGeneration", 2L),
load.get("lifecycleAuthority"));
}
}
@@ -76,7 +76,7 @@ export interface paths {
};
get?: never;
put?: never;
/** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. Any caller holding the OPERATOR role may submit later Turns and rename the Session under the same opt-in, while the Workspace registry row stays ACTIVE and still backs the binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is an active, undeleted qwen-code Session on the frozen execution profile; Workspace close follows its separate close capability and lifecycle admission; the same caller may cancel a running Turn (its delivery re-checks no grants); archive and unarchive follow their separate retention capabilities after reliable Workspace close; delete supports either L3 idle ACTIVE files Sessions or L2 CLOSED/ARCHIVED Sessions; controlled same-Workspace cwd changes ship through the durable cwd_change operations (v1.32). The per-caller workspaceTurns capability on this surface advertises the same submit/rename admission rule; it does not gate cancellation. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */
/** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission with public Session creation: the fixed server-owned profile for the built-in qwen-code agent, and the profile selected by a stored definition's single allowlisted hosted_profile tools entry for a definition-pinned Session. Any caller holding the OPERATOR role may submit later Turns and rename the Session under the same opt-in, while the Workspace registry row stays ACTIVE and still backs the binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session on the frozen execution profile or pinned to a stored definition; Workspace close follows its separate close capability and lifecycle admission; the same caller may cancel a running Turn (its delivery re-checks no grant it needs survives the cancellation), and the Session's own liveness keeps OPERATOR access even when its Workspace's background-exit observation closes some other bound Session; cancel is always allowed for its own running Turn even outside the Workspace files opt-in. A definition-pinned Session keeps its pinned revision's tool profile, model and instructions for its whole life, and a replay of the create command reuses the original pin; a WebShell Session inherits its Workspace's default Workspace selection when the request omits one. */
post: operations["webShellCreateSession"];
delete?: never;
options?: never;
@@ -546,7 +546,7 @@ export interface components {
/** @default false */
actions: boolean;
/**
* @description True when the caller may submit later Turns of this Workspace-bound Session or rename it: the deployment enables Workspace files, the caller holds the OPERATOR role on a Workspace registry row whose state is ACTIVE and still backs the Session's binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is an active qwen-code Session on the frozen execution profile. Cancelling only aborts work already running, so an OPERATOR caller may cancel a running Turn even when this is false; the capability does not gate cancellation. False for every other caller and for unbound Sessions, which do not use it.
* @description True when the caller may submit later Turns of this Workspace-bound Session or rename it: the deployment enables Workspace files, the caller holds the OPERATOR role on a Workspace registry row whose state is ACTIVE and still backs the Session's binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session or a Session pinned to a stored definition (both on the Workspace's frozen execution profile). Cancelling only aborts work already running, so an OPERATOR caller may cancel a running Turn even when this is false; the capability does not gate cancellation. False for every other caller and for unbound Sessions, which do not use it.
* @default false
*/
workspaceTurns: boolean;
@@ -664,6 +664,8 @@ export interface components {
replay_floor_sequence?: number | null;
/** Format: int64 */
snapshot_through_sequence?: number | null;
/** @description Names the definition field that cannot take effect on 409 agent_definition_unsupported; other errors may omit this field. */
field?: string;
};
};
/**