diff --git a/docs/architecture/proactive-multi-agent-orchestration/spec.md b/docs/architecture/proactive-multi-agent-orchestration/spec.md index 26532d9b4..58d306743 100644 --- a/docs/architecture/proactive-multi-agent-orchestration/spec.md +++ b/docs/architecture/proactive-multi-agent-orchestration/spec.md @@ -5,7 +5,7 @@ Active. DeepChat has one Subagent execution plane: durable live delegation through child Sessions. The unreleased QuickJS-based durable Workflow runtime is retired before merge. -Last reviewed: 2026-08-04. +Last reviewed: 2026-08-09. ## Decision @@ -82,6 +82,12 @@ receives a bounded semantic Handoff plus a typed `resultRef` containing immutabl identity, content hash, byte/token size, and explicit truncation state. `read_result` pages the referenced answer without starting new model work. +For contract-bearing turns, the parent also receives a bounded structured evaluation through +`wait`, `inspect`, and `read_result`. Evaluation kind, Handoff format status, reason/evidence records, +and the complete `evaluationRef` are projected outside child-authored text. Tape remains the +historical source for the evaluation fact; live-delegation rows and mailbox events are the online +projection consumed by the orchestration runtime. + Child answers are untrusted evidence, not instructions. Every model-facing child result uses one shared orchestration envelope that: @@ -98,6 +104,50 @@ to the model runtime. A malformed envelope is a host-contract failure and is rej falling back to raw child text. This structural validation preserves the trust boundary; it does not sanitize or reinterpret valid child payload text, which remains untrusted evidence. +## Task And Execution Contracts + +Every new live-delegation turn freezes one immutable `TaskContract` containing task schema, stable +task configuration, task description, and the harness Handoff-format/ceiling rules. The parent appends +`contract/task_frozen` in the same transaction that creates the turn and stores the same canonical +value plus a full Session/Tape/entry/hash reference on the turn projection. + +Before the first child provider dispatch, the child appends the same TaskContract by value to its +own Tape. The inherited fact carries the complete parent `originRef`; it does not copy parent +transcript context or require a child hot-path lookup into the parent Tape. Reset recovery +re-anchors the hash-verified row projection into the new parent or child Tape incarnation before the +next strict boundary. Initial delivery, restart resend, and repeated recovery are idempotent. + +Each contract-bearing DeepChat View embeds one immutable `ExecutionContract` with three structural +groups: + +- `ceilings`: stable tool targets, effect policy, normalized workdir binding, and Subagent depth; +- `dynamicControlSnapshot`: View-time permission, admission, and cancellation observations; +- `provenance`: structured prompt sections and hashes for provider/model, generation config, + provider-visible tool definitions, internal execution policy, assembler, and TaskContract ref. + +The same ExecutionContract value follows the provider request, loop run, tool batch, dispatch guard, +and schema-v5 ViewManifest. Contract-bearing DeepChat child Views fail closed before provider +dispatch when the manifest or TaskContract binding cannot be persisted. Ordinary interactive chat +and ACP compatibility continue to use schema-v4 manifests without ExecutionContract construction or +dispatch enforcement. + +Terminal settlement validates the persisted complete child answer against the required level-two +Markdown Handoff sections. It keeps execution and format status independent: + +```text +executionStatus = completed | failed | cancelled | interrupted +evaluationKind = handoff_format +formatStatus = valid | invalid | indeterminate +``` + +A format-valid result only proves the required sections have non-empty bodies. It does not prove +task completion, factual correctness, or parent acceptance. A generated answer with invalid format +remains `completed` and returns the delegation to `idle`, allowing an explicit parent `follow_up`. +Every contract-bearing terminal settlement atomically appends `contract/evaluated`, updates the turn +and delegation projections, and emits the terminal mailbox event with the same canonical evaluation. +If that transaction cannot complete, the turn remains recoverable rather than becoming terminal +without an evaluation. + ## Consent And Permissions Host enforcement, not prompt wording, owns delegation consent. @@ -115,6 +165,13 @@ Host enforcement, not prompt wording, owns delegation consent. same composition to catalog construction and execution-time MCP dispatch, and a missing parent or unreadable child policy fails closed. Assignment, catalog, and execution use one pure authority composer; each boundary supplies every persisted and configured parent/child source it owns. +- Tool dispatch additionally computes a typed meet between the exact View's frozen ceilings and + current runtime authority. Tool sets intersect, numeric maxima use `min`, effect classes choose the + more restrictive value, and the current normalized Session workdir must exactly match the frozen + View workdir. This workdir binding rejects stale Views after a directory change; it is not + argument-level path authorization or a filesystem sandbox. Frozen ceilings cannot expand within + a View; permission mode and other dynamic controls use the current runtime value and may tighten + or relax according to their existing host contract. - Tool-catalog context records the immutable Session kind. A successfully identified regular Session may bypass Subagent composition until that context is cleared, but an unknown or known Subagent identity that can no longer be resolved fails closed. Execution checks current authority @@ -133,7 +190,7 @@ Host enforcement, not prompt wording, owns delegation consent. Generation settings and safety state have different lifetimes: - model and generation settings are frozen when each child turn starts; -- permission mode, workspace authority, Session deletion, and capability revocation are checked +- permission mode, workdir identity, Session deletion, and capability revocation are checked continuously and take effect for active work; - the host revalidates safety before authorization, immediately before tool dispatch, and before a suspended child resumes; a permission change between authorization and dispatch fails closed; @@ -173,6 +230,11 @@ Compatibility handling for malformed or oversized unreleased rows must otherwise of repeatedly rolling back on the same data. Character-count validation must not claim to enforce a byte limit. +A follow-up is a new turn with a newly frozen TaskContract. Its task configuration cites the prior +`evaluationRef` from the same parent Session; it does not mutate the previous contract, replay the +previous Run identity, or reinterpret a format-valid child claim as trusted. Cross-Session +predecessor references fail canonical contract validation. + Child-to-parent terminal events are a durable cursor stream and remain available until their parent Session is deleted. Only already-consumed parent-to-child messages may be compacted without a persisted reader cursor; an arbitrary row-count window must not discard unread completion events. @@ -201,6 +263,12 @@ Databases that ran the feature branch may already record version 63. Version 64 decommission migration that removes Workflow artifacts and preserves monotonic schema history. The code must never lower the latest schema version below a version already observed by those databases. +Version 65 adds nullable, bounded TaskContract, parent/child reference, and evaluation projection +columns to `live_delegation_turns`. Version 66 adds the bounded evaluation value/reference projection +to `live_delegation_events`, so a parent mailbox consumer receives the same Handoff format status +without querying Tape. Existing rows remain valid with null contract/evaluation fields; historical terminal +turns are not assigned fabricated evaluations. + Schema version numbers are monotonic high-water marks. Upgrade paths record intentionally empty versions so abandoned numbers cannot be reused later. Database import and encryption migration use the same dependency-aware table-copy planner, including trigger-enforced dependencies that SQLite @@ -227,7 +295,14 @@ Workflow panels, saved Workflow commands, launch approvals, and `/workflow` are - Direct ACP Sessions and child Sessions cannot enable proactive collaboration. - Existing released Sessions default to `explicit`; intent is never inferred from disabled tools. -- Existing feature-branch databases migrate forward through version 64. +- Existing feature-branch databases migrate forward through version 66; pre-contract rows remain + readable with nullable projections. +- ViewManifest schemas 1-4 remain readable. Contract-bearing DeepChat child schema-v5 manifests bind + one ExecutionContract to the exact request and fail closed on a missing or conflicting binding; + ordinary interactive chat and ACP continue to write schema 4 without an ExecutionContract. +- Legacy active turns freeze an explicit `legacy_recovery` contract before continuation. Historical + terminal turns remain unevaluated; a contract-bearing terminal turn without an evaluation is + invalid and remains recoverable. - Historical `subagent_orchestrator` transcript blocks remain renderable but cannot start the old in-memory batch executor. The legacy name remains reserved only as a renderer trust tombstone. - Generic MCP tools remain reachable unless their names collide with an active native tool or an @@ -242,8 +317,8 @@ Workflow panels, saved Workflow commands, launch approvals, and `/workflow` are all generation settings. 2. `deepchat_subagents` is the only model-facing Subagent execution tool. 3. No QuickJS, Workflow runtime, saved Workflow, Workflow route/event, or Workflow UI surface ships. -4. Schema version 64 removes Workflow triggers and tables without regressing released or - feature-branch databases. +4. Schema version 64 removes Workflow triggers and tables, and versions 65-66 add nullable bounded + contract/evaluation projections without regressing released or feature-branch databases. 5. Explicit policy requires host confirmation for `spawn` and `follow_up`; proactive policy is standing authorization for those operations. 6. Child tool permissions remain governed by ordinary permission mode and live safety state. @@ -258,6 +333,15 @@ Workflow panels, saved Workflow commands, launch approvals, and `/workflow` are compatible. 12. Reasoning controls remain available whenever the selected model supports them, independently of proactive-collaboration availability. +13. Every new delegation turn freezes a parent TaskContract, and the child durably inherits the + same value before provider dispatch without reading the parent Tape on its hot path. +14. Every contract-bearing DeepChat child View carries one schema-v5 ExecutionContract and enforces + the typed meet of its frozen ceilings with current runtime authority. +15. Every contract-bearing terminal settlement atomically persists one evaluation fact, turn and + delegation projections, and mailbox event; execution status remains independent from Handoff + format status. +16. `wait`, `inspect`, and `read_result` expose bounded structured evaluation metadata outside + untrusted child text. ## Non-Goals @@ -270,6 +354,8 @@ Workflow panels, saved Workflow commands, launch approvals, and `/workflow` are - Making direct ACP backends participate in DeepChat-owned local orchestration. - Treating Tape as the mutable scheduler or promising exactly-once side effects. - Adding a batch/fan-out DSL before ordinary multi-call spawning proves insufficient. +- Automatically repairing, retrying, or overriding a parked result in V1. +- Treating ReplaySlice or a contract hash as a second online authority. ## Reconsideration Triggers diff --git a/docs/architecture/tape-contract-lineage/plan.md b/docs/architecture/tape-contract-lineage/plan.md new file mode 100644 index 000000000..b4845c9f8 --- /dev/null +++ b/docs/architecture/tape-contract-lineage/plan.md @@ -0,0 +1,115 @@ +# Tape Contract Lineage Implementation Plan + +## 1. Establish Canonical Contract Domains + +- Add shared, bounded schemas for prompt-section provenance, TaskContract, TaskContract references, + ExecutionContract, and Handoff format evaluation. +- Add main-process canonical builders and versioned hashes using the existing canonical JSON helper. +- Define stable tool target identity and typed ceiling comparison without importing runtime services + into the domain layer. +- Add focused domain tests for canonical ordering, hash exclusion rules, bounds, typed meet, and + evaluation invariants. + +## 2. Make Prompt Assembly Constructive + +- Introduce a structured prompt assembly result while retaining the existing string-returning helper + as a compatibility wrapper for narrow callers and tests. +- Give each system-prompt contribution a stable kind, source reference, inclusion state, content + hash, and bounded degradation list. +- Expose AGENTS.md cache provenance, including fresh, cached, deferred, missing, and read-error + states, without copying AGENTS.md into the manifest. +- Record pinned-skill load omissions and tooling/environment construction failures as provenance. +- Thread the structured result through BasePromptAssembler, turn setup, compaction rebuild, and loop + recovery without changing the provider-visible prompt text. + +## 3. Build And Persist ExecutionContract At View + +- Construct one immutable ExecutionContract after final provider messages, tools, model identity, + token budget, runtime settings, and TaskContract context are known. +- Store the value on the request/run path; do not add a per-Session latest-contract cache. +- Upgrade contract-bearing DeepChat child ViewManifest writes to schema 5 and the next hash version + while preserving v1-v4 readers and schema-v4 writes for ordinary interactive chat and ACP. +- Include full ExecutionContract content in `view/assembled`; reference the TaskContract by durable + local/origin identity where present. +- Keep ordinary interactive writes fail-open and make contract-bearing DeepChat child View writes + fail closed before provider request admission. + +## 4. Enforce The Frozen View Ceiling + +- Carry ExecutionContract identity through the exact logical round and tool batch that consumed the + provider response. +- Persist a bounded View binding on paused permission actions, retain the exact value in the live + batch projection, and recover it from a hash-verified v5 manifest only after runtime loss. +- Validate stable tool target, reviewed effect class, exact normalized View workdir binding, and + nesting ceiling before crossing ToolService dispatch. +- Retain existing live permission, workdir identity, deletion, and Subagent-authority checks as the + current runtime side of the meet; do not represent the workdir binding as argument-level path + authorization. +- Reject stale, missing, or mismatched contract identity for contract-bearing child dispatch. +- Add tests for mid-run revocation, permission relaxation, tool-catalog expansion, workdir change, + transient provider retry, and multiple logical rounds. + +## 5. Add Strict Contract Tape Capabilities + +- Reserve `contract/*` in the generic Tape writer. +- Add a contract writer/reader with canonical payload conflict checking and transaction-aware append. +- Expose complete Tape identity for contract references without making repositories query concrete + Tape tables. +- Keep ExecutionJournalService's independent-transaction prohibition unchanged. +- Add architecture guards that permit contract persistence only through its application capability. + +## 6. Persist TaskContract Runtime Projection + +- Add a forward-only database migration for nullable TaskContract value/reference, inherited + reference, and evaluation value/reference columns on `live_delegation_turns`. +- Extend shared orchestration schemas with nullable projections for historical compatibility. +- Build a TaskContract from the parent request, resolved slot, stable target, default or configured + Handoff format, and optional predecessor evaluation. +- Coordinate parent contract append and initial/follow-up turn creation in one MainDatabase + transaction. +- Keep a canonical runtime projection on the turn so restart and parent Tape reset do not erase the + in-flight task semantics. +- Re-anchor the same hash-verified canonical projection into a new parent Tape incarnation before a + strict operation and atomically update only its runtime reference. + +## 7. Inherit TaskContract Into Child Tape + +- Append the canonical value to the child Tape with complete origin identity before marking the + Handoff deliverable. +- Persist the child-local reference on the turn projection and make repeated recovery idempotent. +- Re-inherit the same hash-verified projection after a child Tape reset before another provider + request can start. +- Expose the active child TaskContract context to prompt/View assembly through a narrow read port. +- Reconcile legacy active turns by freezing an explicitly degraded compatibility contract before + continuation. +- Fail closed on origin hash conflict, child-local content conflict, or missing contract projection. + +## 8. Evaluate And Atomically Settle + +- Parse the persisted complete child answer, not its bounded Handoff projection. +- Implement required-section evaluation with the existing fence-aware Markdown rules. +- Create `valid`, `invalid`, or `indeterminate` Handoff format evaluation with bounded evidence and + reason codes; do not represent it as task success or parent acceptance. +- Replace terminal fallback paths that can commit without evaluation. +- Commit evaluation fact, evaluation projection, execution status, delegation projection, and + mailbox event in one transaction. +- Preserve `executionStatus=completed`, `formatStatus=invalid`, and `delegationStatus=idle` for + format-invalid but successfully generated results. + +## 9. Surface Evaluation To The Parent + +- Add evaluation summary/reference to turn inspection, wait event projection, and result pages. +- Keep evaluation metadata outside the untrusted child text in the child-result envelope. +- Include predecessor evaluation identity when a parent starts a follow-up turn. +- Do not add automatic repair, retry, override, or a new persisted parked status. + +## 10. Documentation And Validation + +- Update the maintained proactive multi-Agent specification and `tape-system.md` write-discipline, + ViewManifest, lineage, and evaluation sections. +- Add migration tests from pre-contract schema versions and fresh-install schema tests. +- Run focused prompt, ViewManifest, Tape, dispatch, orchestration repository/service, and integration + suites after their owning slices. +- Before each commit, review the staged diff for hidden side effects, compatibility, edge cases, + performance, security, naming, test gaps, and maintenance cost; fix findings before committing. +- Before handoff, run format, i18n, lint, Node/web typecheck, and the relevant main-process suites. diff --git a/docs/architecture/tape-contract-lineage/spec.md b/docs/architecture/tape-contract-lineage/spec.md new file mode 100644 index 000000000..c61b8b037 --- /dev/null +++ b/docs/architecture/tape-contract-lineage/spec.md @@ -0,0 +1,308 @@ +# Tape Contract Lineage Specification + +## Status + +V1 implementation and local validation are complete. This architecture extends DeepChat's existing +Tape, provider View, and live-delegation execution planes with explicit task and execution contracts. +Automatic repair/retry/override and ReplaySlice expansion remain deferred. The implementation does +not add a second scheduler or make Tape an online permission service. + +Last reviewed: 2026-08-09. + +## Decision + +DeepChat will represent agent contracts at two lifetimes: + +- `TaskContract` freezes stable task semantics for one live-delegation turn; +- `ExecutionContract` records and constrains one provider-visible View. + +The contracts form one lineage without sharing one physical record. A TaskContract is an +append-only Tape fact plus a runtime projection. An ExecutionContract is embedded in the existing +`view/assembled` fact because it has the same identity and lifetime as that View. + +Runtime remains the online authority. Runtime never reads Tape on the tool-dispatch hot path. +Canonical values are constructed once and passed through request assembly, enforcement, and +persistence. Recovery may rebuild runtime projections from persisted facts. + +## Motivation + +DeepChat can currently prove which messages and provider-visible tool definitions formed a View, +but it cannot prove the section-level source of the system prompt, the internal execution policy +that accompanied provider-visible tools, or the Handoff format contract applied to a delegated result. +Live delegation asks child Sessions to return a structured Handoff, but terminal settlement only +requires a non-empty answer and silently falls back when expected sections are absent. + +That gap prevents a parent Agent from distinguishing a structurally valid child Handoff from a +malformed one. It does not let the host determine whether the child's claims are correct. The View +gap also makes historical provider requests difficult to explain and compare. + +## Goals + +1. Record the exact structured prompt, capability, dynamic-control, and provenance inputs used by + every contract-bearing DeepChat child View. +2. Enforce the immutable capability ceiling associated with the exact View that produced a tool + call while continuing to honor current runtime revocation. +3. Freeze one durable TaskContract for every new live-delegation turn. +4. Hand the canonical TaskContract to the child by value and persist an inherited copy in the + child Tape before provider dispatch. +5. Produce one explicit evaluation for every terminal settlement of a contract-bearing turn. +6. Atomically persist the evaluation fact, live-delegation projection, and terminal mailbox event. +7. Surface Handoff format status through existing parent-facing orchestration operations. +8. Preserve old View manifests and live-delegation rows without retroactive evaluation. + +## Non-Goals + +- Automatic format repair, task retry, or result override. +- A third contract layer for Agent configuration. +- A new replay store or replay authority. +- Deterministic replay with copied prompt or tool payload bodies. +- Generic workflow execution, recursive Subagents, or scheduler policy. +- Treating a contract hash, Tape fact, or ReplaySlice as online permission authority. +- Retrofitting terminal historical turns with evaluations they never received. + +## Domain Model + +### TaskContract + +A TaskContract is stable for one live-delegation turn and contains the four task inputs described by +tape.systems: + +- `taskSchema`: task/result structure and contract schema versions; +- `taskConfig`: stable task-level configuration and consumer-driven retry mode; +- `taskDescription`: title, prompt, scope, slot, and target identity; +- `taskHarness`: Handoff format requirements and host-enforced behavioral ceilings. + +The persisted V1 field remains named `taskHarness.acceptance` for feature-branch schema +compatibility. Its only producer is the fixed Handoff format requirement below; the field name does +not represent task success or parent acceptance. + +For a contract-bearing child, every per-View ExecutionContract ceiling must be less than or equal +to the stable Task Harness ceiling. A later View may narrow that maximum but cannot expand it. + +TaskConfig v1 records `creationReason=delegation_created|legacy_recovery`. Compatibility recovery +uses `legacy_recovery` with no retroactive Handoff format requirements, so a recovered contract remains +distinguishable without adding a second runtime flag. + +V1 supports one Handoff format requirement kind: `required_sections`, a list of required level-two +Markdown section names. Requirements compose conjunctively. A named section is valid only when its +heading is recognized outside a Markdown fence and its body is non-empty. This check proves Handoff +shape only; it does not prove task completion, factual correctness, or parent acceptance. Missing +candidate data, cancellation, or interruption makes format status `indeterminate`. Any semantic +change to Markdown section extraction, evidence normalization, or format-status reduction must bump +`evaluatorVersion`. + +The canonical contract excludes timestamps, entry IDs, and origin references from its content hash. +Its identity is the canonical JSON value plus a versioned SHA-256 hash. + +V1 applies these UTF-8 persistence limits before mutation: + +- canonical TaskContract: 128 KiB, including at most 64 Handoff format requirements; +- canonical ExecutionContract: 64 KiB, including at most 256 tool identities and 64 prompt sections; +- canonical evaluation projection: 32 KiB, including at most 64 bounded reason/evidence records. + +### TaskContract Reference And Inheritance + +The parent freezes `contract/task_frozen` in its Tape in the same SQLite transaction that creates +the live-delegation turn. The turn row stores the same canonical value and its origin reference as +the runtime projection. + +Before sending the child Handoff, DeepChat appends the same canonical value to the child Tape as +`contract/task_frozen`, with an `originRef` containing: + +- parent Session ID; +- parent Tape identity; +- parent entry ID; +- TaskContract hash. + +The inherited append is idempotent and strict. It records that the child received that contract; it +does not copy parent transcript history. The child provider request cannot begin until the inherited +fact is durable. The child runtime consumes the passed value or its local projection and never +performs a hot-path parent Tape lookup. + +Tape reset creates a new incarnation and invalidates the old physical reference. When a parent or +child contract reference no longer matches the current incarnation, the contract service may append +the same hash-verified canonical value from the turn projection into the new Tape and atomically +replace only the runtime reference. The new fact records `projection_recovery` provenance and the +superseded reference. It does not change task semantics or invent a new contract. Re-anchoring must +complete before child provider dispatch or terminal evaluation; otherwise the turn remains +recoverable and non-terminal. + +### ExecutionContract At View + +Every schema-v5 ViewManifest embeds one ExecutionContract with three structural groups: + +- `ceilings`: provider-visible tool identities, reviewed effect ceiling, normalized workdir binding, + and Subagent nesting ceiling; +- `dynamicControlSnapshot`: View-time permission and admission/cancellation observations; +- `provenance`: prompt sections, provider/model identity, effective generation-config hash, + provider-visible tool-definition hash, internal execution-policy hash, source hashes, and + assembler version. + +Prompt sections record stable kind, source reference, inclusion state, content hash, and bounded +degradation codes. Omitted or degraded contributions remain visible without copying their source +body into the manifest. + +The final provider payload and the ExecutionContract are immutable siblings. The same contract +value is retained by the loop run and passed to tool dispatch. A session-global "latest contract" +cache is forbidden because retries, tool rounds, steering, and concurrent Session work make it an +ambiguous authority. + +If a tool batch pauses for a host permission interaction, its action projection stores the complete +View request identity and contract hash while the Session runtime retains the exact contract value. +Normal continuation uses that runtime value without reading Tape. After process restart, the host +may reconstruct the value only from the single hash-verified schema-v5 ViewManifest named by the +binding. A present binding with a missing, duplicate, malformed, or conflicting View fails closed; +legacy interactive projections without a binding retain their existing compatibility behavior. + +### Runtime Enforcement + +Effective authority is a typed meet: + +```text +effectiveCapability = meet(frozenCeilings, currentRuntimeAuthority) +``` + +Meet semantics are field-specific: + +- sets use intersection; +- numeric maxima use `min`; +- side-effect classes use the declared partial order; +- a View workdir must be within the TaskContract workdir at construction, and dispatch requires the + current normalized Session workdir to equal the exact frozen View workdir; +- dynamic controls use the current runtime value and are not frozen ceilings. + +The schema retains the field name `workspace`, but V1 uses it only as a workdir identity and stale- +View guard. It does not inspect tool arguments, establish a filesystem sandbox, or prove that a tool +cannot access paths outside that directory. Tool-specific path authorization remains a separate +runtime responsibility. Any workdir change requires a new View before another tool dispatch. + +An expansion of a ceiling takes effect only in a later View. Permission, cancellation, admission, +Session deletion, and revocation remain live controls and may immediately tighten or relax according +to their existing host contracts. + +### Evaluation And Settlement + +Execution status and Handoff format status are independent axes: + +```text +executionStatus = completed | failed | cancelled | interrupted +evaluationKind = handoff_format +formatStatus = valid | invalid | indeterminate +``` + +A successfully generated but format-invalid answer remains `executionStatus=completed` and leaves +the delegation `idle`, so the parent may inspect the untrusted evidence and explicitly start a new +follow-up turn. `formatStatus=valid` never means that the delegated task succeeded or that the parent +accepted the child's conclusion. + +For each contract-bearing terminal turn, the settlement transaction must: + +1. append `contract/evaluated` to the parent Tape; +2. store the same canonical evaluation value and fact reference on the turn projection; +3. update turn and delegation execution state; +4. append the terminal mailbox event. + +The transaction uses the existing MainDatabase connection. `contract/*` has a reserved strict +writer that may participate in a host transaction. It must not reuse ExecutionJournalService, +whose external-effect facts intentionally reject host transactions. + +The evaluation idempotency identity includes the turn ID, TaskContract hash, candidate-result hash +or explicit absence marker, and evaluator version. An existing identity with different canonical +content is corruption, not a successful retry. + +Current writers emit evaluation content schema v2 with hash recipe v1 and evaluator +`handoff-format-v1`. Feature-branch databases may contain the earlier schema-v1 +`task-contract-v1` value. That value remains read-only: DeepChat verifies its original canonical +hash and keeps its evaluation ref and `contract/evaluated` Tape fact unchanged, then projects only +the production-reachable required-section evidence into the current parent-facing Handoff format +summary. A repeated terminal settlement may recognize the same legacy fact by turn, contract, +execution status, and complete candidate identity, but it must not replace the fact or append a new +mailbox event. Dormant legacy result-schema evaluations had no production producer and remain +unsupported rather than being reinterpreted as Handoff evidence. Evaluation ref schema v1 and the +`contract/evaluated` fact envelope/provenance version remain unchanged. + +### Parent Visibility + +The Tape fact is historical evidence, not a model-facing delivery mechanism. Existing +`wait`, `inspect`, and `read_result` projections expose: + +- `evaluationKind`; +- `formatStatus`; +- bounded reason codes and evidence references; +- `evaluationRef`. + +The existing child-result envelope carries these structured fields outside untrusted child text. +Parent-initiated `follow_up` creates a new turn and a new TaskContract that references the prior +evaluation. The predecessor reference must name the same parent Session as the new TaskContract; a +cross-Session reference is invalid provenance. A follow-up is not an automatic replay of the +previous attempt. + +## Write Disciplines + +| Fact/path | Failure policy | Transaction rule | +| --- | --- | --- | +| Interactive `view/assembled` | fail-open with bounded diagnostic | independent append before request | +| Contract-bearing `view/assembled` | fail-closed | durable before provider request | +| `execution/*` | fail-closed | independent commit across external-effect boundary | +| parent `contract/task_frozen` | fail-closed | same transaction as turn creation | +| child inherited `contract/task_frozen` | fail-closed | durable before child Handoff dispatch | +| `contract/evaluated` | fail-closed | same transaction as terminal projection/event | + +This table describes write disciplines, not a count of all Tape event families. + +## Compatibility + +- ViewManifest schemas 1 through 4 and their historical hash versions remain readable. +- Contract-bearing DeepChat child writes use ViewManifest schema 5 and manifest hash version 3. + Ordinary interactive chat and ACP compatibility use schema 4 and do not construct or enforce an + ExecutionContract. Contract-bearing DeepChat child requests never take that fallback. +- New live-delegation contract/evaluation columns are nullable for historical rows. +- Historical terminal turns remain readable with no evaluation; no facts are fabricated for them. +- Hash-valid schema-v1 `task-contract-v1` evaluations written by earlier feature heads remain + readable without rewriting their projection, reference, mailbox copy, or Tape fact. New writes + use evaluation content schema v2. +- A legacy active turn without a TaskContract must freeze a compatibility contract before it may + resume. That contract records `legacy_recovery` provenance and does not retroactively impose new + required sections on already-started work. +- A contract-bearing terminal turn without an evaluation is invalid and remains recoverable rather + than silently committing a terminal state. +- A reset parent or child Tape re-anchors the hash-verified runtime projection into the new + incarnation before the next strict contract boundary. +- A contract-bearing queued turn with a bound idle child and no `startedAt` may resend its Handoff + after restart: the existing write-ahead protocol records `startedAt` before crossing delivery, so + its absence proves that delivery did not begin. Legacy rows without a contract keep the existing + interruption behavior. +- Ordinary interactive chat keeps its current non-blocking ViewManifest failure behavior. + +## Security And Privacy + +- Contract manifests store hashes and bounded source references, not secrets, raw headers, or copied + prompt source files. +- Tool ceilings use stable tool target identity, not only a model-visible name. +- Runtime revalidates current permission, workdir identity, Session lineage, and tool authority + immediately before dispatch. +- Child output remains untrusted even when its Handoff format is valid. +- Error projections use bounded reason codes and sanitized messages. + +## Acceptance Criteria + +1. Every successfully assembled contract-bearing DeepChat child View has a schema-v5 manifest + containing a verifiable ExecutionContract built from the exact request inputs; ordinary + interactive chat and ACP compatibility retain their documented schema-v4 behavior without an + ExecutionContract. +2. Tool dispatch receives the exact View contract and rejects a tool outside its frozen ceiling even + when current runtime authority would otherwise permit it. +3. Current revocation still interrupts or rejects active child work before dispatch. +4. Interactive manifest persistence remains fail-open; contract-bearing child Views fail closed + before provider execution. +5. Every new live-delegation turn atomically stores a parent TaskContract fact and runtime projection. +6. Child execution cannot start until the same TaskContract is durably inherited into the child Tape. +7. Every terminal contract-bearing turn atomically stores evaluation fact, turn projection, state + transition, and mailbox event. +8. Handoff format failure does not rewrite successful provider execution as an execution failure. +9. Parent-facing orchestration results expose evaluation kind, format status, and evaluation identity. +10. Old manifests and historical delegation rows remain readable without fabricated evaluations. +11. Contract namespace conflicts, idempotency conflicts, dangling origin identity, and malformed + projections fail closed on automated-consumer paths. +12. Permission pause and continuation preserve the originating View contract; restart recovery + validates the durable binding against exactly one schema-v5 View before deferred dispatch. diff --git a/docs/architecture/tape-contract-lineage/tasks.md b/docs/architecture/tape-contract-lineage/tasks.md new file mode 100644 index 000000000..53e214dbe --- /dev/null +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -0,0 +1,86 @@ +# Tape Contract Lineage Tasks + +## SDD + +- [x] Record the two-level contract decision and three-field ExecutionContract model. +- [x] Define authority, transaction, inheritance, compatibility, and strictness boundaries. +- [x] Define P0/P1 scope and keep repair, retry, override, and deterministic replay out of V1. +- [x] Review and commit the SDD slice. + +## P0: Contract Domains And Prompt Provenance + +- [x] Add canonical contract schemas, builders, hash versions, and domain tests. +- [x] Return structured prompt sections without changing provider-visible prompt text. +- [x] Record AGENTS.md freshness/degradation and pinned-skill/tooling omissions. +- [x] Thread prompt provenance through turn and loop assembly. +- [x] Review and commit the prompt-provenance slice. +- [x] Review and commit the canonical ExecutionContract domain slice. + +## P0: ViewManifest V5 And Enforcement + +- [x] Embed ExecutionContract in contract-bearing child ViewManifest schema 5 and preserve v1-v4 + reads plus ordinary-chat and ACP schema-v4 writes. +- [x] Keep ordinary interactive manifest persistence fail-open and require contract-bearing DeepChat + child manifests. +- [x] Carry the exact View contract to tool dispatch without Session-global mutable state. +- [x] Enforce stable tool target, effect, exact View workdir binding, and nesting ceilings with + current authority. +- [x] Cover retries, tool rounds, revocation, expansion, and contract mismatch. +- [x] Review and commit the View/enforcement slice. + +## P1: Strict Contract Persistence + +- [x] Reserve `contract/*` and add a transaction-aware strict Tape capability. +- [x] Add complete Tape identity and canonical conflict validation. +- [x] Add nullable live-delegation contract/evaluation projection columns and migration coverage. +- [x] Atomically freeze parent TaskContract with initial and follow-up turn creation. +- [x] Re-anchor hash-verified runtime projections after parent Tape reset. +- [x] Review and commit the parent-freeze/storage foundation slice. + +## P1: Child Inheritance + +- [x] Strictly append the inherited TaskContract to child Tape before Handoff dispatch. +- [x] Persist child-local reference and expose active contract context through a narrow port. +- [x] Re-inherit the contract after child Tape reset before the next provider request. +- [x] Reconcile legacy active turns with an explicit compatibility contract. +- [x] Cover restart, repeated inheritance, reset/incarnation conflict, and missing child Tape. +- [x] Review and commit the child-inheritance slice. + +## P1: Evaluation And Parent Visibility + +- [x] Implement bounded required-section Handoff format evaluation. +- [x] Commit evaluation fact, projection, terminal state, and mailbox event atomically. +- [x] Ensure every contract-bearing terminal path produces evaluation or remains recoverable. +- [x] Surface evaluation through inspect, wait, read_result, and the untrusted result envelope. +- [x] Keep execution status independent from Handoff format status without asserting task success. +- [x] Cover no answer, missing/empty sections, cancellation, interruption, and settlement + retry/recovery. +- [x] Review and commit the evaluation/settlement slice. + +## Documentation And Final Validation + +- [x] Update retained Tape and proactive multi-Agent architecture references. +- [x] Run format, i18n, lint, Node/web typecheck, focused tests, and relevant main suites. +- [x] Review the complete merge-base-to-HEAD diff and fix findings by severity. +- [x] Confirm every task and acceptance criterion is represented in code or documented as deferred. +- [x] Keep the PR branch free of unrelated baseline test repairs. + +## Validation Record + +Completed on 2026-08-09: + +| Gate | Result | +| --- | --- | +| `pnpm run format:check` | Passed across 2,713 files | +| `pnpm run i18n` | Passed with no missing or invalid translations | +| `pnpm run lint` | Passed with no warnings or errors | +| `pnpm run typecheck` | Node and web typechecks passed | +| Full DeepChat Agent harness | 297 tests passed | +| Focused contract, View, ToolService, Tape, and orchestration suites | 226 tests passed | +| `pnpm run test:main` | Eleven unrelated failures reproduced from the `dev` baseline files | + +The reproduced baseline consists of eight stale MainDatabase API fixtures, one provider-config +snapshot in `schedulerService.test.ts`, and two missing-table fixtures in +`sessionDataMigrations.sqlite.test.ts`. This branch does not change their owner paths; the unrelated +repairs were removed from this PR. The final severity-ordered review found no unresolved merge +blockers in the completed focused suites. diff --git a/docs/architecture/tape-system.md b/docs/architecture/tape-system.md index 706ea6164..956ee5a03 100644 --- a/docs/architecture/tape-system.md +++ b/docs/architecture/tape-system.md @@ -1,14 +1,16 @@ # Tape 系统 -Tape 是 Session 同寿命的 append-only fact store,在同一个物理 entry 序列中承载两族语义隔离的事实: +Tape 是 Session 同寿命的 append-only fact store,在同一个物理 entry 序列中承载三族语义隔离的事实: - Context Tape 保存可回放消息事实、anchor、ViewManifest、provider attempt 和 Subagent lineage, 服务 context assembly、recall、replay 与审计; -- Execution Journal 保存 Run、工具副作用和终态的原生边界事实,服务失败分类与崩溃后对账。 +- Execution Journal 保存 Run、工具副作用和终态的原生边界事实,服务失败分类与崩溃后对账; +- Contract lineage 保存冻结的任务语义和验收裁决,服务 live delegation 的约束、交接、评价与审计。 message transcript 是活跃 message state 和 UI read model,也是当前 Context Tape message fact 的生产 来源;它不是 Execution Journal 的 authority。legacy transcript reconciliation 只可重建 Context Tape, -不得制造 `execution/*` 事实。 +不得制造 `execution/*` 或 `contract/*` 事实。live-delegation row 和 mailbox 是 Contract fact 的在线 +projection;Tape 保存历史事实,但不成为在线权限或编排状态的 authority。 ## 所有权和分层 @@ -16,7 +18,7 @@ message transcript 是活跃 message state 和 UI read model,也是当前 Cont | --- | --- | | entry/fact/ref、effective semantics、ViewManifest/replay 纯逻辑 | `src/main/tape/domain/` | | 消费方能力和 storage ports | `src/main/tape/ports/` | -| Fact、Execution Journal、Reconciler、Recall、Lineage、View/Replay、Fork services | `src/main/tape/application/` | +| Fact、Execution Journal、Contract、Reconciler、Recall、Lineage、View/Replay、Fork services | `src/main/tape/application/` | | `SessionTape` 兼容 facade | `src/main/tape/application/sessionTape.ts` | | append/read/query store | `src/main/tape/infrastructure/sqlite/tapeEntryStore.ts` | | search projection | `src/main/tape/infrastructure/sqlite/tapeSearchProjectionStore.ts` | @@ -48,6 +50,7 @@ owner,也不能通过 canonical module 的新增导出隐式扩大旧路径合 | DeepChat loop runner | `DeepChatLoopTapePort`(Context、provider attempt 与 Journal capabilities) | | DeepChat harness composition | `ExecutionJournalRecoveryReader` | | Deferred tool executor | `ExecutionJournalWriter` | +| Live delegation repository | `ParentTaskContractWriter`、`TaskContractWriter`、`TaskEvaluationWriter` | | Turn coordinator / ACP compatibility | `TapeReconciliationPort` | | Transcript | `TapeMessageFactWriter` | | Memory runtime | `TapeRawEntryReader`、`TapeAnchorWriter` | @@ -73,7 +76,12 @@ domain policy;外部方法的签名、同步/异步行为、异常和 fallback Session lifecycle(包含 fork Session cleanup),不属于运行中 Tape 语义。 - Execution Journal 使用同一个 SQLite connection 上的同步 transaction。事务内完成 prerequisite、 identity collision、payload equality 和 append 检查;同 identity 同 payload 返回既有 receipt,同 - identity 异 payload 报 corruption。strict commit 失败必须向调用方传播。 + identity 异 payload 报 corruption。它记录已越过外部副作用边界的事实,所以必须独立提交并拒绝加入 + 调用方事务;strict commit 失败必须向调用方传播。 +- `contract/*` namespace 由 strict Contract writer 独占,generic Tape append/query projection 不得伪造。 + Contract writer 校验 canonical payload、当前 Tape identity、因果引用和幂等冲突,并要求加入 + live-delegation 的宿主事务。每个 Contract fact 分别与它触发或证明的 runtime mutation 原子提交;不同 + lifecycle boundary 之间不共享一个长事务。 - transcript message mutation 与 replacement/retraction fact、summary compare-and-set 与 anchor append 使用同一个 SQLite connection 和调用方 transaction,拆层不能拆开其原子边界。 - `clearMessages` 在同一外层 transaction 中删除 pending input、transcript projection 并 reset Tape; @@ -95,6 +103,18 @@ domain policy;外部方法的签名、同步/异步行为、异常和 fallback - reset 物理删除当前 Session Tape 后重新 bootstrap;本阶段没有 archive-on-reset,不能把 reset 解释成 append-only 运行语义的一部分。 +写入事务取决于事实所描述的边界,不按 namespace 机械统一: + +| Fact/path | 失败策略 | 事务纪律 | +| --- | --- | --- | +| Context message/anchor | 沿既有交互 settlement policy | 与对应 transcript/projection mutation 同事务或按既有 fail-open 规则提交 | +| interactive `view/assembled` | fail-open,记录 bounded diagnostic | provider request 前独立 append | +| contract-bearing `view/assembled` | fail-closed | provider request 前独立、durable append | +| `execution/*` | fail-closed | 跨外部副作用边界独立提交,拒绝宿主事务 | +| parent `contract/task_frozen` | fail-closed | 与 delegation/turn 创建同一事务 | +| child `contract/task_frozen` inherited copy | fail-closed | 与 dispatch preparation projection 同一事务,先于 Handoff/provider dispatch | +| `contract/evaluated` | fail-closed | 与 terminal turn/delegation projection 和 mailbox event 同一事务 | + ## Execution Journal 每个 loop 或 deferred tool execution 都创建新的 UUID `runId`。一次工具 operation 使用结构化身份: @@ -147,15 +167,33 @@ Tape entries + anchors + linked child head ``` `ViewManifest` 记录 policy、version、context builder、selection reason、included/excluded entry、 -synthetic contribution、anchor 和 token budget provenance。正常 chat、resume、tool loop 和 context -pressure recovery 都必须记录自己的 view;不得依赖无法复现的隐式 context builder 状态。summary、 -reconstruction 和 Memory 生成的 synthetic user contribution 只记录 source entry ID 与 content hash, -不在 manifest 中复制原文。 - -新写入默认使用 `cache_aware_context_v1` / `cache-aware-v1` 和 schema version 3。旧 -`legacy_context_v1`、schema version 1/2 与 `legacy-v1` builder 只在 read/replay boundary 继续兼容, -不得原地重写。tool loop 和 context pressure 必须继承初始 projection 的 synthetic provenance,不能 -退化为仅按 message role 猜测来源。 +synthetic contribution、anchor、token budget provenance;contract-bearing DeepChat child 的 manifest +还记录该请求的 `ExecutionContract`。正常 chat、resume、tool loop 和 context pressure recovery 都必须 +记录自己的 view;不得依赖无法复现的隐式 context builder 状态。summary、reconstruction 和 Memory +生成的 synthetic user contribution 只记录 source entry ID 与 content hash,不在 manifest 中复制原文。 + +Contract-bearing DeepChat child 使用 `cache_aware_context_v1` / `cache-aware-v1`、schema version 5 和 +manifest hash version 3。普通 interactive chat 与 ACP compatibility 继续写 schema version 4,不构造或 +执行 ExecutionContract;schema version 1-4 与其历史 hash 语义继续兼容读取且不得原地重写。 +`legacy_context_v1` 与 `legacy-v1` builder 同样保留兼容路径。tool loop 和 context pressure 必须继承 +初始 projection 的 synthetic provenance,不能退化为仅按 message role 猜测来源。 + +每个 schema-v5 manifest 内嵌一个与 provider payload 同时构造的 immutable `ExecutionContract`,包含: + +- `ceilings`:稳定 tool target、effect、规范化 workdir binding 和 Subagent depth 上限; +- `dynamicControlSnapshot`:View 构造时的 permission、admission 和 cancellation 观测; +- `provenance`:结构化 prompt sections、provider/model、generation config、provider-visible tool + definitions、内部 execution policy、assembler version 和可选 TaskContract ref 的 hash/identity。 + +同一个 contract value 绑定 request、loop run、tool batch、dispatch guard 和 manifest writer;不得用 +Session-global latest-contract cache 代替。dispatch 以 typed meet 计算有效权限:集合取交集、数值上限取 +`min`、effect 取偏序中更保守的一侧;workdir 则要求当前 Session 的规范化值与 frozen View 精确一致, +变化后必须构造新 View。`workspace` 字段在 V1 只表达 workdir identity/stale-View guard,不检查 tool +arguments,也不替代各工具的路径授权或 filesystem sandbox。permission、删除、撤权和 cancellation 继续 +读取当前 runtime authority;frozen ceiling 只允许收缩,扩权必须等待新 View。 +暂停 action 保存 request identity 与 contract hash,进程内继续使用原 value;重启后只可从该 binding 指向 +且 hash 验证通过的唯一 schema-v5 manifest 恢复。contract-bearing child 的 binding 缺失、冲突或不可恢复 +时 fail closed;legacy interactive action 保留兼容行为。 每份确定的 provider payload 只写一个 `view/assembled`,以 request sequence 标识;context recovery 改变 payload 并写新 manifest,transient retry 复用原 manifest。每个真正启动的 physical attempt 另 @@ -177,6 +215,43 @@ DeepChat message trace 通过 nullable identity 列兼容旧行和 ACP trace。 physicalAttempt 最大的 trace,再按 createdAt 和 ID 稳定排序;attempt-local trace callback 必须捕获 不可变 identity。 +## Contract lineage 与评价 + +每个 live-delegation turn 在 parent Tape 冻结一个 `TaskContract`,内容由 `taskSchema`、`taskConfig`、 +`taskDescription` 和 `taskHarness` 四部分组成。v1 harness 只验证 required Markdown level-two Handoff +sections 是否存在非空正文;它不判断任务是否完成、内容是否正确或 parent 是否接受,不支持自动 repair、 +retry 或 override。follow-up 创建新 turn 和新 TaskContract,并引用同一 parent Session 的前一次 +`evaluationRef`,不是复用旧 turn 或重放旧 Run;跨 Session predecessor ref 不能通过 canonical contract +校验。 + +parent 在创建 turn 的事务内 append `contract/task_frozen`,同时把同一 canonical contract 和完整 ref 写入 +turn projection。child 在首次 provider dispatch 前把该 value strict append 到自己的 Tape,并以 +`originRef` 记录 parent Session、Tape incarnation、entry 和 contract hash。这个 inherited copy 只表示 +child 收到的最小任务状态,不复制 parent transcript,也不要求 child 热路径回读 parent Tape。parent 或 +child Tape reset 后,runtime 可用 row 中 hash-verified canonical value 在新 incarnation append +`projection_recovery` fact 并替换 projection ref;完成前不得跨下一个 strict boundary。 + +每个 contract-bearing terminal settlement 必须生成一个 `contract/evaluated`。执行状态和 Handoff 格式 +状态相互独立: + +```text +executionStatus = completed | failed | cancelled | interrupted +evaluationKind = handoff_format +formatStatus = valid | invalid | indeterminate +``` + +`formatStatus=valid` 只证明固定 Handoff 结构满足要求,child 内容仍是不可信 evidence。一个生成成功但格式 +无效的 child 仍是 `completed`,delegation 回到 `idle`,由 parent 显式 `follow_up` 决定是否继续。 +settlement 在同一 SQLite transaction 中提交 Tape fact、turn/delegation projection 与 terminal mailbox +event,三者使用同一 canonical evaluation;Tape 是历史证据,row/event 是 parent 在线消费的 projection, +不构成双重 authority。 + +TaskContract、ExecutionContract 与 evaluation 都有独立 schema/hash/evaluator version 和 UTF-8 上限。 +unknown legacy turn 不补造评价;contract-bearing turn 若无法原子写入评价则保持 recoverable,不得静默 +terminal。ReplaySlice 只从事实和 manifests 派生;当前 schema-v5 View 已携带 per-View execution +contract,后续若扩展 task contract、attempts、evaluations 与 lineage ref,也不得成为新的事实源或在线 +authority。 + ## Message projection 与 Context facts - user/assistant/reasoning/tool terminal result 在 projection 完成后写入对应 Context Tape fact; diff --git a/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts b/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts index e2241e02b..051b0cf93 100644 --- a/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts +++ b/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts @@ -392,6 +392,8 @@ function createDeepChatRuntimeServices(deps: DeepChatHarnessDependencies): DeepC attachmentRouter, sessionSettings, promptAssembly, + identity, + taskContractContext: deps.taskContractContext, commandShell, loopRunner, messageProjection, @@ -432,7 +434,8 @@ function createDeepChatRuntimeServices(deps: DeepChatHarnessDependencies): DeepC hookSink, turnCoordinator, continuationAdmission: deps.interactionContinuationAdmission, - interactionParking + interactionParking, + viewManifests: tapeService }) const transcriptMutation = new TranscriptMutationCoordinator({ registry: runtime, diff --git a/src/main/agent/deepchat/harness/runtimeServices.ts b/src/main/agent/deepchat/harness/runtimeServices.ts index 78f6cdab2..022361781 100644 --- a/src/main/agent/deepchat/harness/runtimeServices.ts +++ b/src/main/agent/deepchat/harness/runtimeServices.ts @@ -34,6 +34,7 @@ import type { DeepChatEventPublisher, DeepChatSessionUpdatePublisher } from '@/agent/deepchat/runtime/types' +import type { DeepChatTaskContractContextPort } from '@/agent/deepchat/loop/ports' export type DeepChatHarnessSkillPort = Pick< SkillServicePort, @@ -72,6 +73,7 @@ export interface DeepChatHarnessDependencies { promptSettings: Pick attachmentRouter: Pick interactionContinuationAdmission: InteractionContinuationAdmissionPort + taskContractContext: DeepChatTaskContractContextPort commandShell: Pick } diff --git a/src/main/agent/deepchat/instance/deepChatAgentInstance.ts b/src/main/agent/deepchat/instance/deepChatAgentInstance.ts index bc5da78bc..c988d5009 100644 --- a/src/main/agent/deepchat/instance/deepChatAgentInstance.ts +++ b/src/main/agent/deepchat/instance/deepChatAgentInstance.ts @@ -278,7 +278,8 @@ export class DeepChatAgentInstance { callOrder: [...state.callOrder], invokedCallIds: [...state.invokedCallIds], committedResultCallIds: [...state.committedResultCallIds], - pendingInteractionCallIds: [...state.pendingInteractionCallIds] + pendingInteractionCallIds: [...state.pendingInteractionCallIds], + ...(state.executionContract ? { executionContract: state.executionContract } : {}) } } @@ -289,7 +290,8 @@ export class DeepChatAgentInstance { callOrder: [...state.callOrder], invokedCallIds: [...state.invokedCallIds], committedResultCallIds: [...state.committedResultCallIds], - pendingInteractionCallIds: [...state.pendingInteractionCallIds] + pendingInteractionCallIds: [...state.pendingInteractionCallIds], + ...(state.executionContract ? { executionContract: state.executionContract } : {}) } : undefined } diff --git a/src/main/agent/deepchat/loop/contextCoordinator.ts b/src/main/agent/deepchat/loop/contextCoordinator.ts index 3b9c8dc09..88fc73e80 100644 --- a/src/main/agent/deepchat/loop/contextCoordinator.ts +++ b/src/main/agent/deepchat/loop/contextCoordinator.ts @@ -1,5 +1,5 @@ import type { LoopRun } from './loopRun' -import { advanceRequestSequence, enterPhysicalAttempt } from './loopRun' +import { advanceRequestSequence, bindActiveRequestContract, enterPhysicalAttempt } from './loopRun' import type { ChatMessage } from '@shared/types/core/chat-message' import { createStreamEvent, @@ -10,6 +10,7 @@ import { } from '@shared/types/core/llm-events' import type { MCPToolDefinition } from '@shared/types/core/mcp' import type { ModelConfig } from '@shared/types/provider' +import type { DeepChatExecutionContract } from '@shared/types/execution-contract' import type { DeepChatProviderAttemptIdentity, DeepChatProviderAttemptOrigin, @@ -141,6 +142,7 @@ export interface ProviderAttemptManifestInput { traceDebugEnabled: boolean contextBuilderVersion: 'legacy-v1' | 'cache-aware-v1' syntheticContributions?: DeepChatTapeViewSyntheticContribution[] + executionContract?: DeepChatExecutionContract } export interface ProviderAttemptManifestPort { @@ -154,6 +156,22 @@ export interface ProviderAttemptManifestPort { onAppendError(error: unknown): void } +export interface ProviderAttemptExecutionContractBuildInput { + requestSeq: number + messages: ChatMessage[] + modelId: string + modelConfig: ModelConfig + temperature: number + maxTokens: number + tools: MCPToolDefinition[] + contextBuilderVersion: 'legacy-v1' | 'cache-aware-v1' +} + +export interface ProviderAttemptExecutionContractPort { + build(input: ProviderAttemptExecutionContractBuildInput): DeepChatExecutionContract + onBuildError(error: unknown): void +} + export interface ProviderRateGatePort { beforeWait(): void wait(signal: AbortSignal): Promise @@ -170,6 +188,7 @@ export interface ProviderAttemptStreamInput { temperature: number maxTokens: number tools: MCPToolDefinition[] + executionContract: DeepChatExecutionContract | null signal: AbortSignal } @@ -432,6 +451,8 @@ export interface ProviderAttemptInput { budget: ProviderAttemptBudgetPort recovery: ProviderAttemptRecoveryPort manifest: ProviderAttemptManifestPort + executionContract?: ProviderAttemptExecutionContractPort + strictViewContract?: boolean rateGate: ProviderRateGatePort provider: ProviderAttemptStreamPort outcome: ProviderAttemptOutcomePort @@ -507,6 +528,7 @@ export class DeepChatContextCoordinator { providerMessages: ChatMessage[] providerMaxTokens: number requestSeq: number + executionContract: DeepChatExecutionContract | null }> => { let providerMessages = input.requestMessages let providerMaxTokens = input.maxTokens @@ -582,6 +604,7 @@ export class DeepChatContextCoordinator { throw new Error('Request was not sent because the prompt became empty.') } + input.run.abortController.signal.throwIfAborted() const requestSeq = advanceRequestSequence(input.run) const isInitialViewRequest = (options.requestOrigin === 'chat' || options.requestOrigin === 'resume') && @@ -593,6 +616,36 @@ export class DeepChatContextCoordinator { viewPolicy: input.viewContext?.policy, viewPolicyVersion: input.viewContext?.policyVersion }) + const contextBuilderVersion = input.viewContext?.contextBuilderVersion ?? 'legacy-v1' + let executionContract: DeepChatExecutionContract | null = null + if (input.strictViewContract && !input.executionContract) { + throw new Error('Strict provider View requires an ExecutionContract builder.') + } + if (input.executionContract) { + try { + executionContract = input.executionContract.build({ + requestSeq, + messages: providerMessages, + modelId: input.modelId, + modelConfig: input.modelConfig, + temperature: input.temperature, + maxTokens: providerMaxTokens, + tools: input.tools, + contextBuilderVersion + }) + } catch (error) { + try { + input.executionContract.onBuildError(error) + } catch {} + if (input.strictViewContract) throw error + } + } + + const reportManifestError = (error: unknown): void => { + try { + input.manifest.onAppendError(error) + } catch {} + } try { input.manifest.append({ requestSeq, @@ -616,14 +669,31 @@ export class DeepChatContextCoordinator { supportsVision: input.viewContext?.supportsVision ?? input.supportsVision, supportsAudioInput: input.viewContext?.supportsAudioInput ?? input.supportsAudioInput, traceDebugEnabled: input.viewContext?.traceDebugEnabled ?? input.traceDebugEnabled, - contextBuilderVersion: input.viewContext?.contextBuilderVersion ?? 'legacy-v1', - syntheticContributions: manifestSyntheticContributions + contextBuilderVersion, + syntheticContributions: manifestSyntheticContributions, + ...(executionContract ? { executionContract } : {}) }) } catch (error) { - input.manifest.onAppendError(error) + if (executionContract) { + if (input.strictViewContract) { + reportManifestError(error) + throw error + } + executionContract = null + const reason = error instanceof Error ? error.message : String(error) + reportManifestError( + new Error( + `ExecutionContract disabled for request ${requestSeq} because durable ViewManifest persistence could not be confirmed: ${reason}`, + { cause: error } + ) + ) + } else { + reportManifestError(error) + } } + bindActiveRequestContract(input.run, requestSeq, executionContract) - return { providerMessages, providerMaxTokens, requestSeq } + return { providerMessages, providerMaxTokens, requestSeq, executionContract } } const recoverProviderContextOverflow = async ( @@ -681,10 +751,11 @@ export class DeepChatContextCoordinator { const strictProviderOverflowRetry = strictProviderOverflowRetryPending strictProviderOverflowRetryPending = false const requestOrigin = nextRequestOrigin - const { providerMessages, providerMaxTokens, requestSeq } = await prepareProviderRequest({ - requestOrigin, - strictProviderOverflowRetry - }) + const { providerMessages, providerMaxTokens, requestSeq, executionContract } = + await prepareProviderRequest({ + requestOrigin, + strictProviderOverflowRetry + }) let pendingRetry: { retryNumber: number; delayMs: number } | null = null for (;;) { @@ -736,6 +807,7 @@ export class DeepChatContextCoordinator { temperature: input.temperature, maxTokens: providerMaxTokens, tools: input.tools, + executionContract, signal: input.run.abortController.signal }, observation, diff --git a/src/main/agent/deepchat/loop/loopRun.ts b/src/main/agent/deepchat/loop/loopRun.ts index 11086d031..11eb3708f 100644 --- a/src/main/agent/deepchat/loop/loopRun.ts +++ b/src/main/agent/deepchat/loop/loopRun.ts @@ -1,11 +1,14 @@ import type { AppSessionId } from '@/agent/shared/agentSessionIds' import type { ChatMessage } from '@shared/types/core/chat-message' import type { MCPToolDefinition } from '@shared/types/core/mcp' +import type { DeepChatPromptAssembly } from '@shared/types/prompt-assembly' +import type { DeepChatExecutionContract } from '@shared/types/execution-contract' import { ResolvedCommandShellSchema, type ResolvedCommandShell } from '@shared/commandShell' export interface LoopRunResources { toolDefinitions: MCPToolDefinition[] activeSkillNames: string[] + promptAssembly?: DeepChatPromptAssembly commandShell: ResolvedCommandShell } @@ -14,6 +17,11 @@ export interface LoopRunProviderRecovery { strictProviderOverflowRetryUsed: boolean } +export interface LoopRunRequestContractBinding { + readonly requestSeq: number + readonly executionContract: DeepChatExecutionContract | null +} + export interface LoopRun { readonly runId: string readonly sessionId: AppSessionId @@ -28,6 +36,7 @@ export interface LoopRun { readonly streamState: TStreamState resources: LoopRunResources providerRecovery: LoopRunProviderRecovery + activeRequestContract: LoopRunRequestContractBinding | null } export interface CreateLoopRunInput { @@ -40,6 +49,7 @@ export interface CreateLoopRunInput { resources: { toolDefinitions: readonly MCPToolDefinition[] activeSkillNames: readonly string[] + promptAssembly?: DeepChatPromptAssembly commandShell: ResolvedCommandShell } initialRequestSeq?: number @@ -75,12 +85,14 @@ export function createLoopRun( resources: { toolDefinitions: [...input.resources.toolDefinitions], activeSkillNames: [...input.resources.activeSkillNames], + ...(input.resources.promptAssembly ? { promptAssembly: input.resources.promptAssembly } : {}), commandShell }, providerRecovery: { contextOverflowHandoffAttempted: false, strictProviderOverflowRetryUsed: false - } + }, + activeRequestContract: null } } @@ -100,9 +112,32 @@ export function advanceRequestSequence(run: LoopRun): number { } run.requestSeq = nextRequestSeq run.physicalAttempt = 0 + run.activeRequestContract = null return nextRequestSeq } +export function bindActiveRequestContract( + run: LoopRun, + requestSeq: number, + executionContract: DeepChatExecutionContract | null +): LoopRunRequestContractBinding { + if (requestSeq !== run.requestSeq) { + throw new Error('Execution contract request sequence does not match the active request.') + } + if ( + executionContract && + (executionContract.request.sessionId !== run.sessionId || + executionContract.request.messageId !== run.messageId || + executionContract.request.runId !== run.runId || + executionContract.request.requestSeq !== requestSeq) + ) { + throw new Error('Execution contract identity does not match the active Loop Run.') + } + const binding = Object.freeze({ requestSeq, executionContract }) + run.activeRequestContract = binding + return binding +} + export function enterPhysicalAttempt(run: LoopRun): number { if (!Number.isSafeInteger(run.requestSeq) || run.requestSeq <= 0) { throw new Error('Provider request sequence must be started before a physical attempt.') diff --git a/src/main/agent/deepchat/loop/ports.ts b/src/main/agent/deepchat/loop/ports.ts index 1635d18d9..f7a42f91a 100644 --- a/src/main/agent/deepchat/loop/ports.ts +++ b/src/main/agent/deepchat/loop/ports.ts @@ -10,8 +10,11 @@ import type { } from '@shared/types/core/mcp' import type { ToolCallOptions, ToolPermissionPreCheckResult } from '@shared/types/tool' import type { ModelConfig } from '@shared/types/provider' +import type { DeepChatPromptAssembly } from '@shared/types/prompt-assembly' import type { MemorySessionHandle } from '@/agent/deepchat/memory/memoryPromptContributor' import type { ContextRuntimeContributions } from '@/agent/deepchat/runtime/contextContributions' +import type { DeepChatExecutionContract } from '@shared/types/execution-contract' +import type { DeepChatTaskContractContext } from '@shared/types/task-contract' import type { ResolvedCommandShell } from '@shared/commandShell' export interface ProviderRequest { @@ -37,6 +40,10 @@ export interface ToolCatalogPort { resolve(input?: { activeSkillNames?: string[] }): Promise } +export interface DeepChatTaskContractContextPort { + prepare(sessionId: string): DeepChatTaskContractContext | null +} + export type ToolExecutionOptions = Omit & { commitDispatch: ToolDispatchCommit commandShell: ResolvedCommandShell @@ -88,6 +95,7 @@ export interface PersistedToolBatchState { readonly invokedCallIds: readonly string[] readonly committedResultCallIds: readonly string[] readonly pendingInteractionCallIds: readonly string[] + readonly executionContract?: DeepChatExecutionContract } export type ToolBatchOutcome< @@ -202,6 +210,7 @@ export interface BasePromptAssemblyInput { export interface BasePromptAssembler { assemble(input: BasePromptAssemblyInput): Promise + assembleWithProvenance(input: BasePromptAssemblyInput): Promise } export interface PromptReconstructionAnchor { diff --git a/src/main/agent/deepchat/resources/promptAssembly.ts b/src/main/agent/deepchat/resources/promptAssembly.ts new file mode 100644 index 000000000..533536e82 --- /dev/null +++ b/src/main/agent/deepchat/resources/promptAssembly.ts @@ -0,0 +1,188 @@ +import { createHash } from 'node:crypto' +import type { + DeepChatPromptAssembly, + DeepChatPromptAssemblySection, + DeepChatPromptDegradationCode, + DeepChatPromptSectionKind, + DeepChatPromptSourceFreshness +} from '@shared/types/prompt-assembly' + +const MAX_PROMPT_SECTIONS = 64 +const MAX_SECTION_DEGRADATION_CODES = 16 +const promptAssemblySectionSnapshots = new WeakSet() + +function hashContent(content: string): string { + return createHash('sha256').update(content, 'utf8').digest('hex') +} + +function normalizeDegradationCodes( + codes: readonly DeepChatPromptDegradationCode[] | undefined +): readonly DeepChatPromptDegradationCode[] | undefined { + const normalized = [...new Set(codes ?? [])].sort().slice(0, MAX_SECTION_DEGRADATION_CODES) + return normalized.length > 0 ? Object.freeze(normalized) : undefined +} + +function snapshotPromptAssemblySection( + section: DeepChatPromptAssemblySection +): DeepChatPromptAssemblySection { + if (promptAssemblySectionSnapshots.has(section)) return section + return createPromptAssemblySection({ + kind: section.kind, + sourceRef: section.sourceRef, + content: section.content, + separatorBefore: section.separatorBefore, + freshness: section.freshness, + degradationCodes: section.degradationCodes, + normalize: 'none' + }) +} + +function snapshotPromptAssemblySections( + sections: readonly DeepChatPromptAssemblySection[] +): readonly DeepChatPromptAssemblySection[] { + return Object.freeze(sections.map(snapshotPromptAssemblySection)) +} + +function snapshotPromptAssembly(assembly: DeepChatPromptAssembly): DeepChatPromptAssembly { + if ( + Object.isFrozen(assembly) && + Object.isFrozen(assembly.sections) && + assembly.sections.every((section) => promptAssemblySectionSnapshots.has(section)) + ) { + return assembly + } + return Object.freeze({ + prompt: assembly.prompt, + sections: snapshotPromptAssemblySections(assembly.sections) + }) +} + +export function createPromptAssemblySection(input: { + kind: DeepChatPromptSectionKind + sourceRef: string + content: string + separatorBefore?: '\n' | '\n\n' + freshness?: DeepChatPromptSourceFreshness + degradationCodes?: readonly DeepChatPromptDegradationCode[] + normalize?: 'trim' | 'trim_end' | 'none' +}): DeepChatPromptAssemblySection { + const content = + input.normalize === 'none' + ? input.content + : input.normalize === 'trim_end' + ? input.content.trimEnd() + : input.content.trim() + const hasContent = content.trim().length > 0 + const degradationCodes = normalizeDegradationCodes(input.degradationCodes) + const inclusion = !hasContent ? 'omitted' : degradationCodes ? 'degraded' : 'included' + + const section = Object.freeze({ + kind: input.kind, + sourceRef: input.sourceRef, + inclusion, + ...(hasContent ? { contentHash: hashContent(content) } : {}), + ...(input.freshness ? { freshness: input.freshness } : {}), + ...(degradationCodes ? { degradationCodes } : {}), + content, + ...(input.separatorBefore ? { separatorBefore: input.separatorBefore } : {}) + }) + promptAssemblySectionSnapshots.add(section) + return section +} + +export function assemblePromptSections( + sections: readonly DeepChatPromptAssemblySection[] +): DeepChatPromptAssembly { + if (sections.length > MAX_PROMPT_SECTIONS) { + throw new RangeError(`System prompt has more than ${MAX_PROMPT_SECTIONS} provenance sections.`) + } + + const sectionSnapshots = snapshotPromptAssemblySections(sections) + let prompt = '' + for (const section of sectionSnapshots) { + if (!section.content.trim()) continue + if (!prompt) { + prompt = section.content + continue + } + prompt += `${section.separatorBefore ?? '\n\n'}${section.content}` + } + + return Object.freeze({ + prompt, + sections: sectionSnapshots + }) +} + +export function appendPromptAssemblySection( + assembly: DeepChatPromptAssembly, + section: DeepChatPromptAssemblySection +): DeepChatPromptAssembly { + const assemblySnapshot = snapshotPromptAssembly(assembly) + const sectionSnapshot = snapshotPromptAssemblySection(section) + if ( + assemblySnapshot.sections.some( + (candidate) => + candidate.kind === sectionSnapshot.kind && + candidate.sourceRef === sectionSnapshot.sourceRef && + candidate.contentHash === sectionSnapshot.contentHash + ) + ) { + return assemblySnapshot + } + if (assemblySnapshot.sections.length >= MAX_PROMPT_SECTIONS) { + throw new RangeError(`System prompt has more than ${MAX_PROMPT_SECTIONS} provenance sections.`) + } + const prompt = !sectionSnapshot.content.trim() + ? assemblySnapshot.prompt + : assemblySnapshot.prompt + ? `${assemblySnapshot.prompt}${sectionSnapshot.separatorBefore ?? '\n\n'}${sectionSnapshot.content}` + : sectionSnapshot.content + return Object.freeze({ + prompt, + sections: Object.freeze([...assemblySnapshot.sections, sectionSnapshot]) + }) +} + +export function recordPromptAssemblyObservation( + assembly: DeepChatPromptAssembly, + section: DeepChatPromptAssemblySection +): DeepChatPromptAssembly { + const assemblySnapshot = snapshotPromptAssembly(assembly) + const sectionSnapshot = snapshotPromptAssemblySection(section) + if (assemblySnapshot.sections.length >= MAX_PROMPT_SECTIONS) { + throw new RangeError(`System prompt has more than ${MAX_PROMPT_SECTIONS} provenance sections.`) + } + return Object.freeze({ + prompt: assemblySnapshot.prompt, + sections: Object.freeze([...assemblySnapshot.sections, sectionSnapshot]) + }) +} + +export function createOpaquePromptAssembly(prompt: string): DeepChatPromptAssembly { + return assemblePromptSections([ + createPromptAssemblySection({ + kind: 'effective_system_prompt', + sourceRef: 'runtime:provided-system-message', + content: prompt, + degradationCodes: ['legacy_prompt_provenance'], + normalize: 'none' + }) + ]) +} + +export function reconcilePromptAssembly( + assembly: DeepChatPromptAssembly, + effectiveSystemPrompt: string +): DeepChatPromptAssembly { + if (assembly.prompt === effectiveSystemPrompt) return assembly + return assemblePromptSections([ + createPromptAssemblySection({ + kind: 'effective_system_prompt', + sourceRef: 'runtime:effective-system-message', + content: effectiveSystemPrompt, + degradationCodes: ['prompt_projection_mismatch'], + normalize: 'none' + }) + ]) +} diff --git a/src/main/agent/deepchat/resources/systemEnvPromptBuilder.ts b/src/main/agent/deepchat/resources/systemEnvPromptBuilder.ts index 880a42d25..40d50b88b 100644 --- a/src/main/agent/deepchat/resources/systemEnvPromptBuilder.ts +++ b/src/main/agent/deepchat/resources/systemEnvPromptBuilder.ts @@ -1,8 +1,14 @@ import * as fs from 'node:fs' import path from 'node:path' import logger from '@shared/logger' +import type { + DeepChatPromptAssembly, + DeepChatPromptDegradationCode, + DeepChatPromptSourceFreshness +} from '@shared/types/prompt-assembly' import type { ResolvedCommandShell } from '@shared/commandShell' import type { ProviderCatalogPort } from '@/provider/ports' +import { assemblePromptSections, createPromptAssemblySection } from './promptAssembly' export interface BuildSystemEnvPromptOptions { providerId?: string @@ -26,10 +32,23 @@ const AGENTS_READ_BUDGET_MS = 200 const AGENTS_CACHE_TTL_MS = 30_000 const MAX_SHELL_DISPLAY_NAME_CHARS = 128 -type AgentsCacheEntry = { +type AgentsReadState = 'fresh' | 'missing' | 'read_error' + +type SettledAgentsRead = { + content: string + state: AgentsReadState +} + +type AgentsInstructionsResult = { content: string + freshness: DeepChatPromptSourceFreshness + degradationCodes?: readonly DeepChatPromptDegradationCode[] +} + +type AgentsCacheEntry = { + settled?: SettledAgentsRead refreshedAt: number - pending?: Promise + pending?: Promise } const agentsInstructionsCache = new Map() @@ -132,13 +151,16 @@ function isGitRepository(workdir: string): boolean { } } -async function readAgentsInstructionsFromDisk(sourcePath: string): Promise { +async function readAgentsInstructionsFromDisk(sourcePath: string): Promise { try { - return await fs.promises.readFile(sourcePath, 'utf8') + return { + content: await fs.promises.readFile(sourcePath, 'utf8'), + state: 'fresh' + } } catch (error) { const nodeError = error as NodeJS.ErrnoException if (nodeError.code === 'ENOENT' || nodeError.code === 'ENOTDIR') { - return '' + return { content: '', state: 'missing' } } logger.warn('[SystemEnvPromptBuilder] Failed to read AGENTS.md', { @@ -146,21 +168,21 @@ async function readAgentsInstructionsFromDisk(sourcePath: string): Promise { + const pending = readAgentsInstructionsFromDisk(sourcePath).then((settled) => { agentsInstructionsCache.set(sourcePath, { - content, + settled, refreshedAt: Date.now() }) - return content + return settled }) agentsInstructionsCache.set(sourcePath, { - content: fallback?.content ?? '', + ...(fallback?.settled ? { settled: fallback.settled } : {}), refreshedAt: fallback?.refreshedAt ?? 0, pending }) @@ -170,12 +192,12 @@ function refreshAgentsInstructions(sourcePath: string, fallback: AgentsCacheEntr async function waitForAgentsInstructions( sourcePath: string, - pending: Promise, - fallback: string -): Promise { + pending: Promise, + fallback: SettledAgentsRead | undefined +): Promise { let timeout: NodeJS.Timeout | undefined const result = await Promise.race([ - pending.then((content) => ({ content })), + pending.then((settled) => ({ settled })), new Promise<{ timedOut: true }>((resolve) => { timeout = setTimeout(() => resolve({ timedOut: true }), AGENTS_READ_BUDGET_MS) }) @@ -190,29 +212,60 @@ async function waitForAgentsInstructions( sourcePath, budgetMs: AGENTS_READ_BUDGET_MS }) - return fallback + return { + content: fallback?.content ?? '', + freshness: 'deferred', + degradationCodes: ['agents_file_deferred'] + } } - return result.content + return projectAgentsInstructions(result.settled, 'fresh') } -async function readAgentsInstructions(sourcePath: string): Promise { +function projectAgentsInstructions( + settled: SettledAgentsRead, + freshness: 'fresh' | 'cached' +): AgentsInstructionsResult { + if (settled.state === 'missing') { + return { + content: settled.content, + freshness: 'missing', + degradationCodes: ['agents_file_missing'] + } + } + if (settled.state === 'read_error') { + return { + content: settled.content, + freshness: 'read_error', + degradationCodes: ['agents_file_read_error'] + } + } + return { content: settled.content, freshness } +} + +async function readAgentsInstructions(sourcePath: string): Promise { const cached = agentsInstructionsCache.get(sourcePath) const now = Date.now() - if (cached && now - cached.refreshedAt < AGENTS_CACHE_TTL_MS) { - return cached.content + if (cached?.settled && now - cached.refreshedAt < AGENTS_CACHE_TTL_MS) { + return projectAgentsInstructions(cached.settled, 'cached') } if (cached?.pending) { - return cached.content + return cached.settled + ? projectAgentsInstructions(cached.settled, 'cached') + : { + content: '', + freshness: 'deferred', + degradationCodes: ['agents_file_deferred'] + } } const pending = refreshAgentsInstructions(sourcePath, cached) - if (cached) { - return cached.content + if (cached?.settled) { + return projectAgentsInstructions(cached.settled, 'cached') } - return waitForAgentsInstructions(sourcePath, pending, '') + return waitForAgentsInstructions(sourcePath, pending, undefined) } export function buildRuntimeCapabilitiesPrompt( @@ -246,7 +299,9 @@ export function buildRuntimeCapabilitiesPrompt( return lines.length > 1 ? lines.join('\n') : '' } -export async function buildSystemEnvPrompt(options: BuildSystemEnvPromptOptions): Promise { +export async function buildSystemEnvPromptAssembly( + options: BuildSystemEnvPromptOptions +): Promise { const now = options.now ?? new Date() const platform = options.platform ?? process.platform const workdir = resolveWorkdir(options.workdir) @@ -254,7 +309,7 @@ export async function buildSystemEnvPrompt(options: BuildSystemEnvPromptOptions) ? path.resolve(options.agentsFilePath) : path.join(workdir, 'AGENTS.md') let stepStartedAt = Date.now() - const agentsContent = await readAgentsInstructions(agentsFilePath) + const agentsInstructions = await readAgentsInstructions(agentsFilePath) logSlowSystemEnvStep('read-agents', stepStartedAt) stepStartedAt = Date.now() const { modelName, exactModelId } = resolveModelIdentity( @@ -267,7 +322,7 @@ export async function buildSystemEnvPrompt(options: BuildSystemEnvPromptOptions) const isGitRepo = isGitRepository(workdir) logSlowSystemEnvStep('git-detect', stepStartedAt) - const promptLines = [ + const environmentContent = [ `You are powered by the model named ${modelName}.`, `The exact model ID is ${exactModelId}`, `Here is some useful information about the environment you are running in:`, @@ -278,11 +333,31 @@ export async function buildSystemEnvPrompt(options: BuildSystemEnvPromptOptions) buildCommandShellPromptLine(options.commandShell), `Today's date: ${now.toDateString()}`, '' - ] + ].join('\n') + const agentsContent = agentsInstructions.content.trim() + ? `Instructions from: ${agentsFilePath}\n\n${agentsInstructions.content}` + : '' - if (agentsContent.trim().length > 0) { - promptLines.push(`Instructions from: ${agentsFilePath}\n`, agentsContent) - } + return assemblePromptSections([ + createPromptAssemblySection({ + kind: 'system_environment', + sourceRef: 'runtime:environment', + content: environmentContent + }), + createPromptAssemblySection({ + kind: 'agents_instructions', + sourceRef: 'workspace:AGENTS.md', + content: agentsContent, + separatorBefore: '\n', + freshness: agentsInstructions.freshness, + degradationCodes: agentsInstructions.degradationCodes, + normalize: 'trim_end' + }) + ]) +} - return promptLines.join('\n') +export async function buildSystemEnvPrompt( + options: BuildSystemEnvPromptOptions +): Promise { + return (await buildSystemEnvPromptAssembly(options)).prompt } diff --git a/src/main/agent/deepchat/resources/systemPromptBuilder.ts b/src/main/agent/deepchat/resources/systemPromptBuilder.ts index 32ff7daeb..36b9a87d8 100644 --- a/src/main/agent/deepchat/resources/systemPromptBuilder.ts +++ b/src/main/agent/deepchat/resources/systemPromptBuilder.ts @@ -1,14 +1,23 @@ import type { ProviderModelResolutionPort } from '@/provider/settings' -import fs from "fs"; -import path from "path"; -import type { SkillServicePort } from '@shared/types/skill'; -import type { MCPToolDefinition } from "@shared/types/core/mcp"; -import type { ToolServicePort } from "@shared/types/tool"; -import type { DeepChatAgentInstance } from "@/agent/deepchat/instance/deepChatAgentInstance"; +import fs from 'fs' +import path from 'path' +import type { + DeepChatPromptAssembly, + DeepChatPromptAssemblySection, + DeepChatPromptDegradationCode +} from '@shared/types/prompt-assembly' +import type { SkillServicePort } from '@shared/types/skill' +import type { MCPToolDefinition } from '@shared/types/core/mcp' +import type { ToolServicePort } from '@shared/types/tool' +import type { DeepChatAgentInstance } from '@/agent/deepchat/instance/deepChatAgentInstance' import type { ProviderCatalogPort } from '@/provider/ports' +import { + buildRuntimeCapabilitiesPrompt, + buildSystemEnvPromptAssembly +} from './systemEnvPromptBuilder' +import { assemblePromptSections, createPromptAssemblySection } from './promptAssembly' +import type { SkillSettingsPort } from '@/skill/settings' import { ResolvedCommandShellSchema, type ResolvedCommandShell } from '@shared/commandShell' -import { buildRuntimeCapabilitiesPrompt, buildSystemEnvPrompt } from "./systemEnvPromptBuilder"; -import type { SkillSettingsPort } from "@/skill/settings"; import { LIVE_DELEGATION_AGENT_TOOL_NAME } from '@shared/agentTools' import { UNTRUSTED_CHILD_OUTPUT_POLICY } from '@shared/orchestration/resultSafety' import { @@ -17,264 +26,344 @@ import { } from '@shared/orchestration/policy' export type AgentExtensionPolicy = { - enabledMcpServerIds?: string[] | null; -}; + enabledMcpServerIds?: string[] | null +} type SystemPromptSkillPort = Pick< SkillServicePort, - "getMetadataList" | "getActiveSkills" | "loadSkillContent" | "resolveSessionAgentId" ->; -type ToolPromptPort = Pick; + 'getMetadataList' | 'getActiveSkills' | 'loadSkillContent' | 'resolveSessionAgentId' +> +type ToolPromptPort = Pick export interface SystemPromptBuilderDependencies { - providerSettings: ProviderModelResolutionPort; - skillSettings: SkillSettingsPort; - skillService: SystemPromptSkillPort; - providerCatalogPort: Pick; - toolService: ToolPromptPort; - assertCurrent(sessionId: string, instance: DeepChatAgentInstance): void; - isAcpBackedSubagentSession(sessionId: string, providerId?: string): boolean; + providerSettings: ProviderModelResolutionPort + skillSettings: SkillSettingsPort + skillService: SystemPromptSkillPort + providerCatalogPort: Pick + toolService: ToolPromptPort + assertCurrent(sessionId: string, instance: DeepChatAgentInstance): void + isAcpBackedSubagentSession(sessionId: string, providerId?: string): boolean resolveProjectDir( sessionId: string, projectDir: string | null | undefined, - instance: DeepChatAgentInstance, - ): string | null; - logSlowStep(sessionId: string, step: string, startedAt: number): void; + instance: DeepChatAgentInstance + ): string | null + logSlowStep(sessionId: string, step: string, startedAt: number): void } export interface SystemPromptBuildInput { - sessionId: string; - basePrompt: string; - toolDefinitions: MCPToolDefinition[]; - activeSkillNamesOverride?: string[]; + sessionId: string + basePrompt: string + toolDefinitions: MCPToolDefinition[] + activeSkillNamesOverride?: string[] orchestrationPolicy?: OrchestrationPolicy + resourceInstance: DeepChatAgentInstance commandShell: ResolvedCommandShell - resourceInstance: DeepChatAgentInstance; } type PackageJsonManifest = { - name?: unknown; - scripts?: Record; -}; + name?: unknown + scripts?: Record +} function readPackageJsonManifest(workdir: string): PackageJsonManifest | null { try { - const packageJsonPath = path.join(workdir, "package.json"); + const packageJsonPath = path.join(workdir, 'package.json') if (!fs.existsSync(packageJsonPath)) { - return null; + return null } - const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; + const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null } - return parsed as PackageJsonManifest; + return parsed as PackageJsonManifest } catch { - return null; + return null } } function getVerificationScriptNames(manifest: PackageJsonManifest | null): string[] { - const scripts = manifest?.scripts; - if (!scripts || typeof scripts !== "object") { - return []; + const scripts = manifest?.scripts + if (!scripts || typeof scripts !== 'object') { + return [] } return Object.entries(scripts) .filter( - ([name, value]) => typeof name === "string" && typeof value === "string" && value.trim(), + ([name, value]) => typeof name === 'string' && typeof value === 'string' && value.trim() ) - .map(([name]) => name); + .map(([name]) => name) } -export async function buildSystemPromptWithSkills( +export async function buildSystemPromptAssemblyWithSkills( dependencies: SystemPromptBuilderDependencies, - input: SystemPromptBuildInput, -): Promise { + input: SystemPromptBuildInput +): Promise { const { sessionId, basePrompt, toolDefinitions, activeSkillNamesOverride, resourceInstance } = - input; + input const commandShell = ResolvedCommandShellSchema.parse(input.commandShell) - dependencies.assertCurrent(sessionId, resourceInstance); - const normalizedBase = basePrompt?.trim() ?? ""; - const state = resourceInstance.getRuntimeState(); - const providerId = state?.providerId?.trim() || "unknown-provider"; - const modelId = state?.modelId?.trim() || "unknown-model"; + dependencies.assertCurrent(sessionId, resourceInstance) + const normalizedBase = basePrompt?.trim() ?? '' + const state = resourceInstance.getRuntimeState() + const providerId = state?.providerId?.trim() || 'unknown-provider' + const modelId = state?.modelId?.trim() || 'unknown-model' if (dependencies.isAcpBackedSubagentSession(sessionId, providerId)) { - return normalizedBase; + return assemblePromptSections([ + createPromptAssemblySection({ + kind: 'configured_prompt', + sourceRef: 'session:generation-settings.system-prompt', + content: normalizedBase + }) + ]) } const workdir = resourceInstance.hasProjectDir() ? resourceInstance.getProjectDir() - : dependencies.resolveProjectDir(sessionId, undefined, resourceInstance); - const now = new Date(); - - const skillsEnabled = dependencies.skillSettings.isEnabled(); - const skillService = dependencies.skillService; - let sessionAgentId: string | null = null; + : dependencies.resolveProjectDir(sessionId, undefined, resourceInstance) + const now = new Date() + + const skillsEnabled = dependencies.skillSettings.isEnabled() + const skillService = dependencies.skillService + const skillsMetadataDegradations: DeepChatPromptDegradationCode[] = [] + const pinnedSkillsDegradations: DeepChatPromptDegradationCode[] = [] + let sessionAgentId: string | null = null if (skillsEnabled) { try { - sessionAgentId = await skillService.resolveSessionAgentId(sessionId); + sessionAgentId = await skillService.resolveSessionAgentId(sessionId) } catch (error) { console.warn( `[DeepChatAgent] Failed to resolve agent id for skills in session ${sessionId}:`, - error, - ); + error + ) + } + if (!sessionAgentId) { + skillsMetadataDegradations.push('skill_agent_unavailable') + pinnedSkillsDegradations.push('skill_agent_unavailable') } } const availableSkills: Array<{ - name: string; - description: string; - category?: string | null; - platforms?: string[]; - }> = []; - const activeSkillNames: string[] = activeSkillNamesOverride ? [...activeSkillNamesOverride] : []; - const skillDraftSuggestionsEnabled = dependencies.skillSettings.isDraftSuggestionsEnabled(); + name: string + description: string + category?: string | null + platforms?: string[] + }> = [] + let skillMetadataLookupFailed = false + const activeSkillNames: string[] = activeSkillNamesOverride ? [...activeSkillNamesOverride] : [] + const skillDraftSuggestionsEnabled = dependencies.skillSettings.isDraftSuggestionsEnabled() if (skillsEnabled) { - const metadataStartedAt = Date.now(); + const metadataStartedAt = Date.now() try { - const metadataList = sessionAgentId - ? await skillService.getMetadataList(sessionAgentId) - : []; + const metadataList = sessionAgentId ? await skillService.getMetadataList(sessionAgentId) : [] for (const metadata of metadataList) { - const skillName = metadata?.name?.trim(); + const skillName = metadata?.name?.trim() if (skillName) { availableSkills.push({ name: skillName, - description: metadata.description?.trim() || "", + description: metadata.description?.trim() || '', category: metadata.category ?? null, - platforms: metadata.platforms, - }); + platforms: metadata.platforms + }) } } } catch (error) { console.warn( `[DeepChatAgent] Failed to load skills metadata for session ${sessionId}:`, - error, - ); + error + ) + skillMetadataLookupFailed = true + skillsMetadataDegradations.push('skill_metadata_unavailable') } - dependencies.logSlowStep(sessionId, "system-prompt.skills-metadata-load", metadataStartedAt); + dependencies.logSlowStep(sessionId, 'system-prompt.skills-metadata-load', metadataStartedAt) if (!activeSkillNamesOverride) { - const activeSkillsStartedAt = Date.now(); + const activeSkillsStartedAt = Date.now() try { - const activeSkills = await skillService.getActiveSkills(sessionId); + const activeSkills = await skillService.getActiveSkills(sessionId) for (const skillName of activeSkills) { - const normalizedName = skillName?.trim(); + const normalizedName = skillName?.trim() if (normalizedName) { - activeSkillNames.push(normalizedName); + activeSkillNames.push(normalizedName) } } } catch (error) { console.warn( `[DeepChatAgent] Failed to load active skills for session ${sessionId}:`, - error, - ); + error + ) + pinnedSkillsDegradations.push('active_skills_unavailable') } - dependencies.logSlowStep( - sessionId, - "system-prompt.active-skills-load", - activeSkillsStartedAt, - ); + dependencies.logSlowStep(sessionId, 'system-prompt.active-skills-load', activeSkillsStartedAt) } } - let stepStartedAt = Date.now(); - const normalizedAvailableSkills = normalizeSkillMetadata(availableSkills); - const availableSkillNames = new Set(normalizedAvailableSkills.map((skill) => skill.name)); - const normalizedActiveSkills = normalizeStringList( - activeSkillNames.filter((skillName) => availableSkillNames.has(skillName)), - ); - const agentToolNames = getAgentToolNames(toolDefinitions); + let stepStartedAt = Date.now() + const normalizedAvailableSkills = normalizeSkillMetadata(availableSkills) + const availableSkillNames = new Set(normalizedAvailableSkills.map((skill) => skill.name)) + const requestedActiveSkills = normalizeStringList(activeSkillNames) + const normalizedActiveSkills = skillMetadataLookupFailed + ? requestedActiveSkills + : requestedActiveSkills.filter((skillName) => availableSkillNames.has(skillName)) + if ( + !skillMetadataLookupFailed && + normalizedActiveSkills.length !== requestedActiveSkills.length + ) { + pinnedSkillsDegradations.push('pinned_skill_unavailable') + } + const agentToolNames = getAgentToolNames(toolDefinitions) const runtimePrompt = buildRuntimeCapabilitiesPrompt({ hasYoBrowser: toolDefinitions.some( - (tool) => tool.source === "agent" && tool.server.name === "yobrowser", + (tool) => tool.source === 'agent' && tool.server.name === 'yobrowser' ), - hasExec: agentToolNames.has("exec"), - hasProcess: agentToolNames.has("process"), - }); + hasExec: agentToolNames.has('exec'), + hasProcess: agentToolNames.has('process') + }) const skillsMetadataPrompt = skillsEnabled ? buildSkillsMetadataPrompt( normalizedAvailableSkills, { - canListSkills: agentToolNames.has("skill_list"), - canViewSkills: agentToolNames.has("skill_view"), - canManageDraftSkills: agentToolNames.has("skill_manage"), - canRunSkillScripts: agentToolNames.has("skill_run"), + canListSkills: agentToolNames.has('skill_list'), + canViewSkills: agentToolNames.has('skill_view'), + canManageDraftSkills: agentToolNames.has('skill_manage'), + canRunSkillScripts: agentToolNames.has('skill_run') }, - skillDraftSuggestionsEnabled, + skillDraftSuggestionsEnabled ) - : ""; + : '' - let skillsPrompt = ""; + let skillsPrompt = '' if (skillsEnabled && normalizedActiveSkills.length > 0) { - stepStartedAt = Date.now(); - const skillSections: string[] = []; + stepStartedAt = Date.now() + const skillSections: string[] = [] for (const skillName of normalizedActiveSkills) { try { const skill = sessionAgentId ? await skillService.loadSkillContent(sessionAgentId, skillName) - : null; - const content = skill?.content?.trim(); + : null + const content = skill?.content?.trim() if (content) { - skillSections.push(`### ${skillName}\n${content}`); + skillSections.push(`### ${skillName}\n${content}`) + } else { + pinnedSkillsDegradations.push('pinned_skill_unavailable') } } catch (error) { console.warn( `[DeepChatAgent] Failed to load skill content for "${skillName}" in session ${sessionId}:`, - error, - ); + error + ) + pinnedSkillsDegradations.push('pinned_skill_load_failed') } } - skillsPrompt = buildPinnedSkillsPrompt(skillSections); - dependencies.logSlowStep(sessionId, "system-prompt.pinned-skills-load", stepStartedAt); + skillsPrompt = buildPinnedSkillsPrompt(skillSections) + dependencies.logSlowStep(sessionId, 'system-prompt.pinned-skills-load', stepStartedAt) } - let envPrompt = ""; + let envSections: readonly DeepChatPromptAssemblySection[] = [] try { - stepStartedAt = Date.now(); - envPrompt = await buildSystemEnvPrompt({ - providerId, - modelId, - workdir, - now, - commandShell, - modelLookup: dependencies.providerCatalogPort, - }); - dependencies.logSlowStep(sessionId, "system-prompt.env-prompt", stepStartedAt); + stepStartedAt = Date.now() + envSections = ( + await buildSystemEnvPromptAssembly({ + providerId, + modelId, + workdir, + now, + commandShell, + modelLookup: dependencies.providerCatalogPort + }) + ).sections + dependencies.logSlowStep(sessionId, 'system-prompt.env-prompt', stepStartedAt) } catch (error) { - console.warn(`[DeepChatAgent] Failed to build env prompt for session ${sessionId}:`, error); + console.warn(`[DeepChatAgent] Failed to build env prompt for session ${sessionId}:`, error) + envSections = [ + createPromptAssemblySection({ + kind: 'system_environment', + sourceRef: 'runtime:environment', + content: '', + degradationCodes: ['environment_build_failed'] + }), + createPromptAssemblySection({ + kind: 'agents_instructions', + sourceRef: 'workspace:AGENTS.md', + content: '', + degradationCodes: ['environment_build_failed'] + }) + ] } - let toolingPrompt = ""; + let toolingPrompt = '' + const toolingDegradations: DeepChatPromptDegradationCode[] = [] try { - stepStartedAt = Date.now(); + stepStartedAt = Date.now() toolingPrompt = dependencies.toolService.buildToolSystemPrompt({ conversationId: sessionId, - toolDefinitions, - }); - dependencies.logSlowStep(sessionId, "system-prompt.tooling-prompt", stepStartedAt); + toolDefinitions + }) + dependencies.logSlowStep(sessionId, 'system-prompt.tooling-prompt', stepStartedAt) } catch (error) { - console.warn(`[DeepChatAgent] Failed to build tooling prompt for session ${sessionId}:`, error); + console.warn(`[DeepChatAgent] Failed to build tooling prompt for session ${sessionId}:`, error) + toolingDegradations.push('tooling_build_failed') } - stepStartedAt = Date.now(); - const composedPrompt = composePromptSections([ - normalizedBase, - runtimePrompt, - envPrompt, - skillsMetadataPrompt, - skillsPrompt, - toolingPrompt, - buildOrchestrationPolicyPrompt(input.orchestrationPolicy, agentToolNames), - buildPermissionRulesPrompt(agentToolNames), - buildVerificationPolicyPrompt(workdir), - ]); - dependencies.logSlowStep(sessionId, "system-prompt.compose", stepStartedAt); - - dependencies.assertCurrent(sessionId, resourceInstance); - return composedPrompt; + stepStartedAt = Date.now() + const assembly = assemblePromptSections([ + createPromptAssemblySection({ + kind: 'configured_prompt', + sourceRef: 'session:generation-settings.system-prompt', + content: normalizedBase + }), + createPromptAssemblySection({ + kind: 'runtime_capabilities', + sourceRef: 'runtime:tool-capabilities', + content: runtimePrompt + }), + ...envSections, + createPromptAssemblySection({ + kind: 'skills_metadata', + sourceRef: 'skills:catalog', + content: skillsMetadataPrompt, + degradationCodes: skillsMetadataDegradations + }), + createPromptAssemblySection({ + kind: 'pinned_skills', + sourceRef: 'skills:active', + content: skillsPrompt, + degradationCodes: pinnedSkillsDegradations + }), + createPromptAssemblySection({ + kind: 'tooling', + sourceRef: 'runtime:tool-system-prompt', + content: toolingPrompt, + degradationCodes: toolingDegradations + }), + createPromptAssemblySection({ + kind: 'orchestration_policy', + sourceRef: 'session:orchestration-policy', + content: buildOrchestrationPolicyPrompt(input.orchestrationPolicy, agentToolNames) + }), + createPromptAssemblySection({ + kind: 'permission_rules', + sourceRef: 'runtime:tool-execution-policy', + content: buildPermissionRulesPrompt(agentToolNames) + }), + createPromptAssemblySection({ + kind: 'verification_policy', + sourceRef: 'runtime:workspace-verification-policy', + content: buildVerificationPolicyPrompt(workdir) + }) + ]) + dependencies.logSlowStep(sessionId, 'system-prompt.compose', stepStartedAt) + + dependencies.assertCurrent(sessionId, resourceInstance) + return assembly +} + +export async function buildSystemPromptWithSkills( + dependencies: SystemPromptBuilderDependencies, + input: SystemPromptBuildInput +): Promise { + return (await buildSystemPromptAssemblyWithSkills(dependencies, input)).prompt } function buildOrchestrationPolicyPrompt( @@ -313,91 +402,84 @@ function buildOrchestrationPolicyPrompt( return lines.join('\n') } -function composePromptSections(sections: string[]): string { - return sections - .map((section) => section.trim()) - .filter((section) => section.length > 0) - .join("\n\n"); -} - function buildPermissionRulesPrompt(agentToolNames: Set): string { - const readOnlyTools = ["read"].filter((toolName) => agentToolNames.has(toolName)); - const serializedTools = ["write", "edit", "exec", "process"].filter((toolName) => - agentToolNames.has(toolName), - ); + const readOnlyTools = ['read'].filter((toolName) => agentToolNames.has(toolName)) + const serializedTools = ['write', 'edit', 'exec', 'process'].filter((toolName) => + agentToolNames.has(toolName) + ) if (readOnlyTools.length === 0 && serializedTools.length === 0) { - return ""; + return '' } - const lines = ["## Permission Rules"]; + const lines = ['## Permission Rules'] if (readOnlyTools.length > 0) { lines.push( `Read-only Agent tools may be batched in parallel when useful: ${readOnlyTools .map((toolName) => `\`${toolName}\``) - .join(", ")}.`, - ); + .join(', ')}.` + ) } if (serializedTools.length > 0) { lines.push( `Mutating and runtime tools stay serialized or permission-gated: ${serializedTools .map((toolName) => `\`${toolName}\``) - .join(", ")}.`, - ); + .join(', ')}.` + ) } - lines.push("Do not assume approval for file writes or commands when the session asks for it."); + lines.push('Do not assume approval for file writes or commands when the session asks for it.') - return lines.join("\n"); + return lines.join('\n') } function buildVerificationPolicyPrompt(workdir: string | null): string { const lines = [ - "## Verification Policy", - "After changing code, configuration, tests, docs that affect behavior, or generated assets, check verification status before the final response.", - "If verification was not run, state the reason explicitly in the final response.", - ]; + '## Verification Policy', + 'After changing code, configuration, tests, docs that affect behavior, or generated assets, check verification status before the final response.', + 'If verification was not run, state the reason explicitly in the final response.' + ] - const normalizedWorkdir = workdir?.trim(); + const normalizedWorkdir = workdir?.trim() if (!normalizedWorkdir) { - return lines.join("\n"); + return lines.join('\n') } - const manifest = readPackageJsonManifest(normalizedWorkdir); - const verificationScripts = getVerificationScriptNames(manifest); + const manifest = readPackageJsonManifest(normalizedWorkdir) + const verificationScripts = getVerificationScriptNames(manifest) const isDeepChatWorkspace = - String(manifest?.name ?? "").toLowerCase() === "deepchat" || - ["format", "i18n", "lint"].every((scriptName) => verificationScripts.includes(scriptName)); + String(manifest?.name ?? '').toLowerCase() === 'deepchat' || + ['format', 'i18n', 'lint'].every((scriptName) => verificationScripts.includes(scriptName)) if (isDeepChatWorkspace) { lines.push( - "In the DeepChat repository, prioritize `pnpm run format`, `pnpm run i18n`, and `pnpm run lint` after feature work.", - ); + 'In the DeepChat repository, prioritize `pnpm run format`, `pnpm run i18n`, and `pnpm run lint` after feature work.' + ) } else if (verificationScripts.length > 0) { const suggestedScripts = verificationScripts .slice(0, 4) - .map((scriptName) => `\`${scriptName}\``); + .map((scriptName) => `\`${scriptName}\``) lines.push( - `When relevant, prefer project-local verification scripts such as ${suggestedScripts.join(", ")}.`, - ); + `When relevant, prefer project-local verification scripts such as ${suggestedScripts.join(', ')}.` + ) } - return lines.join("\n"); + return lines.join('\n') } function buildSkillsMetadataPrompt( availableSkills: Array<{ - name: string; - description: string; - category?: string | null; - platforms?: string[]; + name: string + description: string + category?: string | null + platforms?: string[] }>, capabilities: { - canListSkills: boolean; - canViewSkills: boolean; - canManageDraftSkills: boolean; - canRunSkillScripts: boolean; + canListSkills: boolean + canViewSkills: boolean + canManageDraftSkills: boolean + canRunSkillScripts: boolean }, - skillDraftSuggestionsEnabled: boolean, + skillDraftSuggestionsEnabled: boolean ): string { if ( !capabilities.canListSkills && @@ -405,128 +487,128 @@ function buildSkillsMetadataPrompt( !capabilities.canManageDraftSkills && !capabilities.canRunSkillScripts ) { - return ""; + return '' } - const lines = ["## Skills"]; - let hasContent = false; + const lines = ['## Skills'] + let hasContent = false if (capabilities.canListSkills || capabilities.canViewSkills) { lines.push( - "Before replying, always scan available skills. If any skill plausibly matches the task, call `skill_view` first.", - ); + 'Before replying, always scan available skills. If any skill plausibly matches the task, call `skill_view` first.' + ) lines.push( - "Viewing a skill root `SKILL.md` activates that skill for the current message/tool loop; it does not pin the skill to the conversation. Viewing linked skill files is read-only and does not activate the skill.", - ); - hasContent = true; + 'Viewing a skill root `SKILL.md` activates that skill for the current message/tool loop; it does not pin the skill to the conversation. Viewing linked skill files is read-only and does not activate the skill.' + ) + hasContent = true } if (capabilities.canRunSkillScripts) { lines.push( - "Use `skill_run` only for skills that are active in the current message/tool loop, including manually pinned skills and skills activated by `skill_view`.", - ); - hasContent = true; + 'Use `skill_run` only for skills that are active in the current message/tool loop, including manually pinned skills and skills activated by `skill_view`.' + ) + hasContent = true } if (capabilities.canManageDraftSkills && skillDraftSuggestionsEnabled) { lines.push( - "After completing a complex task, solving a tricky bug, or discovering a non-trivial workflow, you may draft a reusable skill with `skill_manage`.", - ); + 'After completing a complex task, solving a tricky bug, or discovering a non-trivial workflow, you may draft a reusable skill with `skill_manage`.' + ) lines.push( - "Only propose one draft per task, do it after the main answer is complete, and use `deepchat_question` to ask whether the user wants to keep the draft.", - ); + 'Only propose one draft per task, do it after the main answer is complete, and use `deepchat_question` to ask whether the user wants to keep the draft.' + ) lines.push( - "Do not modify installed skills with `skill_manage`; it is draft-only in this version.", - ); - hasContent = true; + 'Do not modify installed skills with `skill_manage`; it is draft-only in this version.' + ) + hasContent = true } if (availableSkills.length > 0) { - lines.push(""); + lines.push('') lines.push( ...availableSkills.map((skill) => { - const details: string[] = []; + const details: string[] = [] if (skill.category) { - details.push(`category=${skill.category}`); + details.push(`category=${skill.category}`) } if (skill.platforms?.length) { - details.push(`platforms=${skill.platforms.join(",")}`); + details.push(`platforms=${skill.platforms.join(',')}`) } - const suffix = details.length > 0 ? ` [${details.join("; ")}]` : ""; - return `- ${skill.name}: ${skill.description}${suffix}`; - }), - ); - lines.push(""); - hasContent = true; + const suffix = details.length > 0 ? ` [${details.join('; ')}]` : '' + return `- ${skill.name}: ${skill.description}${suffix}` + }) + ) + lines.push('') + hasContent = true } else if (hasContent) { - lines.push(""); - lines.push("(none)"); - lines.push(""); + lines.push('') + lines.push('(none)') + lines.push('') } - return hasContent ? lines.join("\n") : ""; + return hasContent ? lines.join('\n') : '' } function buildPinnedSkillsPrompt(skillSections: string[]): string { if (skillSections.length === 0) { - return ""; + return '' } return [ - "## Active Skills", - "These skills are active for the current message context. Some may be manually pinned for the conversation; others may have been activated by `skill_view` for this message/tool loop only. Follow them when relevant.", - "", - skillSections.join("\n\n"), - ].join("\n"); + '## Active Skills', + 'These skills are active for the current message context. Some may be manually pinned for the conversation; others may have been activated by `skill_view` for this message/tool loop only. Follow them when relevant.', + '', + skillSections.join('\n\n') + ].join('\n') } export function resolveEffectiveActiveSkillNames( sessionActiveSkillNames: string[], - instance: DeepChatAgentInstance, + instance: DeepChatAgentInstance ): string[] { - return normalizeStringList([...sessionActiveSkillNames, ...instance.getRuntimeActivatedSkills()]); + return normalizeStringList([...sessionActiveSkillNames, ...instance.getRuntimeActivatedSkills()]) } export function normalizeStringList(values: string[]): string[] { return Array.from( - new Set(values.map((value) => value.trim()).filter((value) => value.length > 0)), - ).sort((a, b) => a.localeCompare(b)); + new Set(values.map((value) => value.trim()).filter((value) => value.length > 0)) + ).sort((a, b) => a.localeCompare(b)) } function normalizeSkillMetadata( skills: Array<{ - name: string; - description: string; - category?: string | null; - platforms?: string[]; - }>, + name: string + description: string + category?: string | null + platforms?: string[] + }> ): Array<{ - name: string; - description: string; - category?: string | null; - platforms?: string[]; + name: string + description: string + category?: string | null + platforms?: string[] }> { - const deduped = new Map(); + const deduped = new Map() for (const skill of skills) { - const name = skill.name.trim(); + const name = skill.name.trim() if (!name || deduped.has(name)) { - continue; + continue } deduped.set(name, { ...skill, name, description: skill.description.trim(), category: skill.category?.trim() || null, - platforms: skill.platforms?.map((platform) => platform.trim()).filter(Boolean), - }); + platforms: skill.platforms?.map((platform) => platform.trim()).filter(Boolean) + }) } return Array.from(deduped.values()).sort((left, right) => { return ( - (left.category ?? "").localeCompare(right.category ?? "") || + (left.category ?? '').localeCompare(right.category ?? '') || left.name.localeCompare(right.name) - ); - }); + ) + }) } function getAgentToolNames(toolDefinitions: MCPToolDefinition[]): Set { return new Set( - toolDefinitions.filter((tool) => tool.source === "agent").map((tool) => tool.function.name), - ); + toolDefinitions.filter((tool) => tool.source === 'agent').map((tool) => tool.function.name) + ) } diff --git a/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts b/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts index 7b020a55b..eb9f876b4 100644 --- a/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts +++ b/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts @@ -7,6 +7,9 @@ import type { } from '@shared/types/core/chat-message' import type { LLMCoreStreamEvent } from '@shared/types/core/llm-events' import type { MCPToolDefinition } from '@shared/types/core/mcp' +import type { DeepChatPromptAssembly } from '@shared/types/prompt-assembly' +import type { DeepChatExecutionContract } from '@shared/types/execution-contract' +import type { DeepChatTaskContractContext } from '@shared/types/task-contract' import type { ProviderExecutionPort, ModelConfig, @@ -30,6 +33,10 @@ import type { SessionPendingInputs } from '@/session/data/pendingInputs' import { resolveEffectiveActiveSkillNames } from '@/agent/deepchat/resources/systemPromptBuilder' +import { + createOpaquePromptAssembly, + reconcilePromptAssembly +} from '@/agent/deepchat/resources/promptAssembly' import type { SessionPermissionPort } from '@/session/contracts' import { awaitWithAbort } from '@/lib/awaitWithAbort' import { @@ -62,6 +69,7 @@ import { type TapeViewContextSelection } from '@/tape/domain/viewManifest' import type { DeepChatLoopTapePort } from '@/tape/ports/capabilities' +import { buildExecutionContract } from '@/tape/domain/executionContract' import { ExecutionJournalCorruptionError, ExecutionJournalError, @@ -107,6 +115,10 @@ import { throwIfAbortRequested } from './abortErrors' import type { RunLifecycleCoordinator } from './runLifecycleCoordinator' import type { SessionScopeRegistry } from '@/agent/deepchat/instance/deepChatAgentRuntime' import type { CompactionRuntimeCoordinator } from './compactionRuntimeCoordinator' +import { + meetTaskContractToolDefinitions, + resolveExecutionContractSubagentDepth +} from './taskContractCapability' import type { PromptAssemblyService } from './promptAssemblyService' import type { SessionIdentityService } from './sessionIdentityService' import type { SessionSettingsCoordinator } from './sessionSettingsCoordinator' @@ -180,9 +192,11 @@ export type DeepChatLoopRunInput = { projectDir: string | null resourceInstance?: DeepChatAgentInstance providerModelFacts?: ProviderModelRuntimeFacts + taskContractContext: DeepChatTaskContractContext | null tools?: MCPToolDefinition[] commandShell: ResolvedCommandShell baseSystemPrompt?: string + basePromptAssembly?: DeepChatPromptAssembly contextContributions?: ContextRuntimeContributions initialBlocks?: AssistantMessageBlock[] initialAccounting?: MessageMetadata @@ -194,7 +208,7 @@ export type DeepChatLoopRunInput = { refreshSystemPrompt?: ( activeSkillNames: string[] | undefined, toolDefinitions: MCPToolDefinition[] - ) => Promise + ) => Promise maxProviderRounds?: number onBeforeProviderStream?: () => void onRunRegistered?: (runId: string) => void @@ -220,6 +234,7 @@ export interface AppendTapeViewManifestInput { traceDebugEnabled: boolean contextBuilderVersion: 'legacy-v1' | 'cache-aware-v1' syntheticContributions?: DeepChatTapeViewSyntheticContribution[] + executionContract?: DeepChatExecutionContract } export interface DeepChatLoopRunnerPorts { @@ -245,7 +260,10 @@ export interface DeepChatLoopRunnerPorts { sessionSettings: Pick promptAssembly: Pick runLifecycle: LoopRunLifecyclePort - identity: Pick + identity: Pick< + SessionIdentityService, + 'getAgentId' | 'getSessionKind' | 'isAcpBackedSubagentSession' + > sessionPermissionPort: SessionPermissionPort reviewToolPermission: ToolPermissionReviewer hookSink: Pick @@ -352,9 +370,11 @@ export class DeepChatLoopRunner { projectDir, resourceInstance: providedResourceInstance, providerModelFacts: providedProviderModelFacts, + taskContractContext, tools: providedTools, commandShell, baseSystemPrompt, + basePromptAssembly, contextContributions, initialBlocks, initialAccounting, @@ -388,6 +408,13 @@ export class DeepChatLoopRunner { if (messages.length === 0) { throw new Error('Request was not sent because the prompt is empty.') } + const sessionKind = this.ports.identity.getSessionKind(sessionId) + const strictViewContract = + sessionKind === 'subagent' && + !this.ports.identity.isAcpBackedSubagentSession(sessionId, state.providerId) + if (strictViewContract && !taskContractContext) { + throw new Error('Contract-bearing child run requires a TaskContract context.') + } const providerModelFacts = providedProviderModelFacts ?? @@ -453,6 +480,17 @@ export class DeepChatLoopRunner { ) const temperature = generationSettings.temperature const maxTokens = capAgentRequestMaxTokens(generationSettings.maxTokens, contextBudgetLength) + const effectiveSystemPrompt = + messages[0]?.role === 'system' && typeof messages[0].content === 'string' + ? messages[0].content + : '' + const declaredPromptAssembly = + basePromptAssembly ?? + createOpaquePromptAssembly(baseSystemPrompt ?? effectiveSystemPrompt) + const initialPromptAssembly = reconcilePromptAssembly( + declaredPromptAssembly, + effectiveSystemPrompt + ) const streamSessionActiveSkillNames = await awaitWithAbort( this.ports.toolResolver.resolveActiveSkillNamesForToolProfile(sessionId), @@ -466,11 +504,17 @@ export class DeepChatLoopRunner { resourceScope.assertCurrent() const getEffectiveRuntimeSkillNames = (baseSkillNames = streamSessionActiveSkillNames) => resolveEffectiveActiveSkillNames(baseSkillNames, resourceInstance) - const toolCatalog = this.ports.toolResolver.createSessionToolCatalogPort( + const unconstrainedToolCatalog = this.ports.toolResolver.createSessionToolCatalogPort( sessionId, projectDir, resourceInstance ) + const toolCatalog = { + resolve: async (request?: { activeSkillNames?: string[] }) => { + const resolved = await unconstrainedToolCatalog.resolve(request) + return meetTaskContractToolDefinitions(sessionId, resolved, taskContractContext) + } + } const tools = providedTools ?? (await awaitWithAbort( @@ -496,6 +540,7 @@ export class DeepChatLoopRunner { resources: { toolDefinitions: tools, activeSkillNames: getEffectiveRuntimeSkillNames(), + promptAssembly: initialPromptAssembly, commandShell }, initialRequestSeq @@ -591,18 +636,23 @@ export class DeepChatLoopRunner { toolCatalog, refreshSystemPrompt: async (activeSkillNames, refreshedTools) => { if (refreshSystemPrompt) { - return await refreshSystemPrompt( + const refreshed = await refreshSystemPrompt( getEffectiveRuntimeSkillNames(activeSkillNames), refreshedTools ) + return typeof refreshed === 'string' + ? createOpaquePromptAssembly(refreshed) + : refreshed } - return await this.ports.promptAssembly.createBasePromptAssembler(resourceInstance).assemble({ - sessionId: toAppSessionId(sessionId), - configuredPrompt: generationSettings.systemPrompt, - toolDefinitions: refreshedTools, - activeSkillNames: getEffectiveRuntimeSkillNames(activeSkillNames), - commandShell: loopRun.resources.commandShell - }) + return await this.ports.promptAssembly + .createBasePromptAssembler(resourceInstance) + .assembleWithProvenance({ + sessionId: toAppSessionId(sessionId), + configuredPrompt: generationSettings.systemPrompt, + toolDefinitions: refreshedTools, + activeSkillNames: getEffectiveRuntimeSkillNames(activeSkillNames), + commandShell: loopRun.resources.commandShell + }) }, toolExecution: this.ports.toolExecutionPort, toolResults: this.ports.toolResultPort, @@ -685,6 +735,66 @@ export class DeepChatLoopRunner { expectedInstance: resourceInstance }) }, + executionContract: strictViewContract + ? { + build: ({ + requestSeq, + messages: providerMessages, + modelId: contractModelId, + modelConfig: contractModelConfig, + temperature: contractTemperature, + maxTokens: contractMaxTokens, + tools: contractTools, + contextBuilderVersion + }) => { + const effectiveSystemPrompt = + providerMessages[0]?.role === 'system' && + typeof providerMessages[0].content === 'string' + ? providerMessages[0].content + : '' + const promptAssembly = reconcilePromptAssembly( + loopRun.resources.promptAssembly ?? + createOpaquePromptAssembly(effectiveSystemPrompt), + effectiveSystemPrompt + ) + const cancellationRequested = abortSignal.aborted + return buildExecutionContract({ + request: { + sessionId, + messageId, + runId: loopRun.runId, + requestSeq + }, + promptAssembly, + providerMessages, + tools: contractTools, + providerId: state.providerId, + modelId: contractModelId, + modelConfig: contractModelConfig, + temperature: contractTemperature, + maxTokens: contractMaxTokens, + workspace: projectDir + ? { kind: 'path', path: projectDir } + : { kind: 'runtime_default' }, + maxSubagentDepth: resolveExecutionContractSubagentDepth(contractTools), + dynamicControlSnapshot: { + permissionMode: state.permissionMode, + requestAdmitted: !cancellationRequested, + cancellationRequested + }, + assemblerVersion: contextBuilderVersion, + taskContractContext + }) + }, + onBuildError: (error) => + logger.warn( + `[DeepChatAgent] Failed to construct execution contract: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } + : undefined, + strictViewContract, manifest: { resolvePolicy: resolveTapeViewManifestPolicy, append: (manifest) => @@ -738,8 +848,17 @@ export class DeepChatLoopRunner { temperature, maxTokens, tools, + executionContract, signal }) => { + const activeRequestContract = loopRun.activeRequestContract + if ( + !activeRequestContract || + activeRequestContract.requestSeq !== identity.requestSeq || + activeRequestContract.executionContract !== executionContract + ) { + throw new Error('Provider request lost its active ExecutionContract binding.') + } const attemptModelConfig = traceEnabled ? (Object.assign({}, modelConfig, { requestTraceContext: { @@ -982,7 +1101,8 @@ export class DeepChatLoopRunner { summaryCursorOrderSeq: params.summaryCursorOrderSeq, supportsVision: params.supportsVision, supportsAudioInput: params.supportsAudioInput, - traceDebugEnabled: params.traceDebugEnabled + traceDebugEnabled: params.traceDebugEnabled, + ...(params.executionContract ? { executionContract: params.executionContract } : {}) }) this.ports.tape.appendViewManifest(manifest) } diff --git a/src/main/agent/deepchat/runtime/deferredExecutionContract.ts b/src/main/agent/deepchat/runtime/deferredExecutionContract.ts new file mode 100644 index 000000000..6cebf195f --- /dev/null +++ b/src/main/agent/deepchat/runtime/deferredExecutionContract.ts @@ -0,0 +1,92 @@ +import type { + DeepChatExecutionContract +} from '@shared/types/execution-contract' +import type { TapeViewManifestReader } from '@/tape/ports/capabilities' +import { + ExecutionContractDispatchError, + executionContractMatchesBinding, + parseExecutionContractBinding, + restoreExecutionContract +} from '@/tape/domain/executionContract' +import { verifyTapeViewManifestHash } from '@/tape/domain/viewManifest' + +export interface DeferredExecutionContractResolutionInput { + sessionId: string + messageId: string + rawBinding: unknown + runtimeContract?: DeepChatExecutionContract + viewManifests: Pick +} + +export function resolveDeferredExecutionContract( + input: DeferredExecutionContractResolutionInput +): DeepChatExecutionContract | undefined { + const { sessionId, messageId, rawBinding, runtimeContract, viewManifests } = input + if (rawBinding === undefined) { + if (runtimeContract) { + throw new ExecutionContractDispatchError( + 'Paused tool dispatch is missing its durable ExecutionContract binding.', + 'invalid_contract' + ) + } + return undefined + } + const binding = parseExecutionContractBinding(rawBinding) + if (!binding) { + throw new ExecutionContractDispatchError( + 'Paused tool dispatch has an invalid ExecutionContract binding.', + 'invalid_contract' + ) + } + + if (binding.request.sessionId !== sessionId || binding.request.messageId !== messageId) { + throw new ExecutionContractDispatchError( + 'Paused tool dispatch does not match its provider View identity.', + 'identity_mismatch' + ) + } + if (runtimeContract) { + if (!executionContractMatchesBinding(runtimeContract, binding)) { + throw new ExecutionContractDispatchError( + 'Paused tool dispatch does not match its runtime ExecutionContract projection.', + 'invalid_contract' + ) + } + return runtimeContract + } + + let records + try { + records = viewManifests + .listViewManifestsByMessage(sessionId, messageId) + .filter((record) => record.requestSeq === binding.request.requestSeq) + } catch (error) { + throw new ExecutionContractDispatchError( + 'Paused tool dispatch could not recover its ExecutionContract View.', + 'invalid_contract', + { cause: error } + ) + } + if (records.length !== 1) { + throw new ExecutionContractDispatchError( + 'Paused tool dispatch requires exactly one matching ExecutionContract View.', + 'invalid_contract' + ) + } + + const manifest = records[0].manifest + if (manifest.schemaVersion !== 5 || verifyTapeViewManifestHash(manifest) !== 'valid') { + throw new ExecutionContractDispatchError( + 'Paused tool dispatch ExecutionContract View failed integrity validation.', + 'invalid_contract' + ) + } + const recoveredContract = restoreExecutionContract(manifest.executionContract) + if (!recoveredContract || !executionContractMatchesBinding(recoveredContract, binding)) { + throw new ExecutionContractDispatchError( + 'Paused tool dispatch ExecutionContract does not match its durable View binding.', + 'invalid_contract' + ) + } + return recoveredContract +} diff --git a/src/main/agent/deepchat/runtime/deferredToolExecutor.ts b/src/main/agent/deepchat/runtime/deferredToolExecutor.ts index b99517f93..4fbf98319 100644 --- a/src/main/agent/deepchat/runtime/deferredToolExecutor.ts +++ b/src/main/agent/deepchat/runtime/deferredToolExecutor.ts @@ -29,6 +29,7 @@ import type { SessionSettingsCoordinator } from './sessionSettingsCoordinator' import type { SessionStateResolver } from './sessionStateResolver' import { toolContentToText } from './toolAdapters' import { isUserConfigurableAgentTool } from '@shared/agentTools' +import type { DeepChatExecutionContract } from '@shared/types/execution-contract' import { CommandShellProfileSchema, type CommandShellProfile } from '@shared/commandShell' import type { CommandShellService } from '@/agent/shared/process/commandShellService' @@ -102,6 +103,7 @@ export class DeferredToolExecutor { messageId: string, toolCall: NonNullable, onToolCallStarted?: () => void, + executionContract?: DeepChatExecutionContract, commandShellProfile?: CommandShellProfile, oneShotCommandGrantId?: string ): Promise { @@ -401,6 +403,14 @@ export class DeferredToolExecutor { invoked = true onToolCallStarted?.() const result = await this.dependencies.toolExecutionPort.execute(request, { + ...(executionContract + ? { + runId: executionContract.request.runId, + messageId, + requestSeq: executionContract.request.requestSeq, + executionContract + } + : {}), agentId: this.dependencies.identity.getAgentId(sessionId) ?? 'deepchat', permissionMode: sessionState.permissionMode, activeSkillNames: deferredActiveSkillNames, diff --git a/src/main/agent/deepchat/runtime/dispatch.ts b/src/main/agent/deepchat/runtime/dispatch.ts index d31509d90..e126aa434 100644 --- a/src/main/agent/deepchat/runtime/dispatch.ts +++ b/src/main/agent/deepchat/runtime/dispatch.ts @@ -12,6 +12,8 @@ import type { SearchResult } from '@shared/types/core/search' import type { AgentToolProgressUpdate } from '@shared/types/tool' import type { AssistantMessageBlock, PermissionMode } from '@shared/types/agent-interface' import type { AgentPlanSnapshot, AgentPlanTerminalReason } from '@shared/types/agent-plan' +import type { DeepChatExecutionContract } from '@shared/types/execution-contract' +import { buildExecutionContractBinding } from '@/tape/domain/executionContract' import { parseQuestionToolArgs, QUESTION_TOOL_NAME @@ -175,6 +177,7 @@ type MutableToolBatchState = { callOrder: string[] invokedCallIds: Set committedResultCallIds: Set + executionContract?: DeepChatExecutionContract } const USER_CANCELED_GENERATION_ERROR = 'common.error.userCanceledGeneration' @@ -185,11 +188,15 @@ export type ToolBatchDisposition = | { kind: 'execute' } | { kind: 'reject'; reason: 'output_truncated' } -function createToolBatchState(toolCalls: readonly ToolCallResult[]): MutableToolBatchState { +function createToolBatchState( + toolCalls: readonly ToolCallResult[], + executionContract?: DeepChatExecutionContract | null +): MutableToolBatchState { return { callOrder: toolCalls.map((toolCall) => toolCall.id), invokedCallIds: new Set(), - committedResultCallIds: new Set() + committedResultCallIds: new Set(), + ...(executionContract ? { executionContract } : {}) } } @@ -300,7 +307,8 @@ function snapshotToolBatchState( callOrder: [...state.callOrder], invokedCallIds: [...state.invokedCallIds], committedResultCallIds: [...state.committedResultCallIds], - pendingInteractionCallIds: interactions.map((interaction) => interaction.toolCallId) + pendingInteractionCallIds: interactions.map((interaction) => interaction.toolCallId), + ...(state.executionContract ? { executionContract: state.executionContract } : {}) } } @@ -1462,7 +1470,8 @@ function appendPermissionActionBlock( }, permission: NonNullable, origin: Extract, - order: number + order: number, + executionContract?: DeepChatExecutionContract | null ): ToolBatchInteraction { state.blocks.push({ type: 'action', @@ -1487,6 +1496,13 @@ function appendPermissionActionBlock( ...(permission.requestId ? { permissionRequestId: permission.requestId } : {}), ...(permission.commandInfo ? { commandInfo: JSON.stringify(permission.commandInfo) } : {}), permissionRequest: JSON.stringify(permission), + ...(executionContract + ? { + executionContractBinding: JSON.stringify( + buildExecutionContractBinding(executionContract) + ) + } + : {}), ...(permission.rememberable === false ? { rememberable: false } : {}) } }) @@ -1653,6 +1669,7 @@ async function runToolCall(params: { onToolCallStarted?: (toolCallId: string) => void executionJournal: Pick operationScope: Pick + executionContract?: DeepChatExecutionContract | null commandShell: ResolvedCommandShell oneShotCommandGrantId?: string }): Promise { @@ -1671,6 +1688,7 @@ async function runToolCall(params: { onToolCallStarted, executionJournal, operationScope, + executionContract, commandShell, oneShotCommandGrantId } = params @@ -1797,6 +1815,9 @@ async function runToolCall(params: { const enabledMcpServerIds = controls?.getEnabledMcpServerIds?.() const result = await toolExecution.execute(toolCall, { runId: io.requestId, + messageId: io.messageId, + requestSeq: operationScope.requestSeq, + ...(executionContract ? { executionContract } : {}), onProgress: applyProgressUpdate, signal: io.abortSignal, permissionMode: toolPermissionMode, @@ -2048,6 +2069,7 @@ export interface SettleToolBatchParams { providerId?: string executionJournal: Pick operationScope: Pick + executionContract?: DeepChatExecutionContract | null commandShell: ResolvedCommandShell } @@ -2075,6 +2097,7 @@ export async function settleToolBatch( providerId, executionJournal, operationScope, + executionContract, commandShell } = params const { notificationObserver, controls, diagnostics, onToolCallStarted } = collaborators ?? {} @@ -2086,7 +2109,7 @@ export async function settleToolBatch( const batchToolCallBlocks = state.blocks .slice(prevBlockCount) .filter((block) => block.type === 'tool_call') - const batchState = createToolBatchState(toolCalls) + const batchState = createToolBatchState(toolCalls, executionContract) let nextInteractionOrder = 0 const takeInteractionOrder = () => nextInteractionOrder++ @@ -2258,6 +2281,7 @@ export async function settleToolBatch( onToolCallStarted, executionJournal, operationScope, + executionContract, commandShell, oneShotCommandGrantId }) @@ -2306,7 +2330,8 @@ export async function settleToolBatch( outcome.toolContext, outcome.permission, 'post-call-permission', - takeInteractionOrder() + takeInteractionOrder(), + executionContract ) pendingInteractions.push(interaction) updateToolCallBlock(batchToolCallBlocks, outcome.toolContext.id, '', false) @@ -2463,7 +2488,8 @@ export async function settleToolBatch( toolContext, preCheckedPermission, 'pre-check-permission', - takeInteractionOrder() + takeInteractionOrder(), + executionContract ) pendingInteractions.push(interaction) updateToolCallBlock(batchToolCallBlocks, tc.id, '', false) @@ -2504,7 +2530,8 @@ export async function settleToolBatch( toolContext, reviewPermission, 'pre-check-permission', - takeInteractionOrder() + takeInteractionOrder(), + executionContract ) pendingInteractions.push(interaction) updateToolCallBlock(batchToolCallBlocks, tc.id, '', false) @@ -2539,6 +2566,7 @@ export async function settleToolBatch( onToolCallStarted, executionJournal, operationScope, + executionContract, commandShell, oneShotCommandGrantId }) @@ -2564,7 +2592,8 @@ export async function settleToolBatch( toolContext, outcome.permission, 'post-call-permission', - takeInteractionOrder() + takeInteractionOrder(), + executionContract ) pendingInteractions.push(interaction) updateToolCallBlock(batchToolCallBlocks, tc.id, '', false) diff --git a/src/main/agent/deepchat/runtime/interactionCoordinator.ts b/src/main/agent/deepchat/runtime/interactionCoordinator.ts index 58e785ce3..c47e8c817 100644 --- a/src/main/agent/deepchat/runtime/interactionCoordinator.ts +++ b/src/main/agent/deepchat/runtime/interactionCoordinator.ts @@ -54,6 +54,8 @@ import type { RunLifecycleCoordinator } from './runLifecycleCoordinator' import type { RuntimeHookScope, RuntimeHookSink } from './runtimeHookSink' import { ExecutionJournalError, isExecutionJournalError } from '@/tape/domain/executionJournal' import type { InteractionParkingRegistry } from './interactionParkingRegistry' +import type { TapeViewManifestReader } from '@/tape/ports/capabilities' +import { resolveDeferredExecutionContract } from './deferredExecutionContract' import { CommandShellProfileSchema } from '@shared/commandShell' import { isCommandSignatureForProfile } from '@/tool/permission' @@ -100,6 +102,7 @@ export interface InteractionCoordinatorPorts { continuationAdmission: InteractionContinuationAdmissionPort publishEvent: DeepChatEventPublisher interactionParking: Pick + viewManifests: Pick } export interface InteractionContinuationAdmissionPort { @@ -270,6 +273,13 @@ export class InteractionCoordinator { let shouldDispatchResolvedToolHook = false if (response.granted) { + const executionContract = resolveDeferredExecutionContract({ + sessionId, + messageId, + rawBinding: actionBlock.extra?.executionContractBinding, + runtimeContract: instance.getPendingToolBatchState()?.executionContract, + viewManifests: this.ports.viewManifests + }) await resumeWaitingAdmission() let permissionGrant: DeferredPermissionGrant | null = null let execution: DeferredToolExecutionResult @@ -312,6 +322,7 @@ export class InteractionCoordinator { ? toolCall : { ...toolCall, server_name: permissionGrant.serverName }, markDeferredToolCallStarted, + executionContract, permissionPayload?.shellProfile, permissionGrant.command?.oneShotGrantId ) diff --git a/src/main/agent/deepchat/runtime/process.ts b/src/main/agent/deepchat/runtime/process.ts index 00440cf4d..f8db825b3 100644 --- a/src/main/agent/deepchat/runtime/process.ts +++ b/src/main/agent/deepchat/runtime/process.ts @@ -2,6 +2,7 @@ import logger from '@shared/logger' import type { AssistantMessageBlock } from '@shared/types/agent-interface' import type { ChatMessage } from '@shared/types/core/chat-message' import type { PermissionRequestPayload } from '@shared/types/core/llm-events' +import type { DeepChatExecutionContract } from '@shared/types/execution-contract' import type { IoParams, PendingToolInteraction, @@ -35,6 +36,10 @@ import { import { emitDeepChatLoopNotification } from '@/agent/deepchat/loop/notificationObserver' import type { OutputSink } from '@/agent/deepchat/loop/ports' import { buildTapeToolFactInputs } from '@/tape/application/factPersistence' +import { + createOpaquePromptAssembly, + reconcilePromptAssembly +} from '@/agent/deepchat/resources/promptAssembly' import { CommandShellProfileSchema } from '@shared/commandShell' const UNKNOWN_CONTEXT_LIMIT = Number.MAX_SAFE_INTEGER @@ -53,6 +58,8 @@ type ToolRoundBatch = { toolCalls: ToolCallResult[] disposition: ToolBatchDisposition nextAction: 'continue' | 'terminal' + requestSeq: number + executionContract: DeepChatExecutionContract | null } function getLatestErrorMessage(state: StreamState): string | null { @@ -1079,6 +1086,13 @@ export async function processStream(params: ProcessParams): Promise { + return await buildSystemPromptAssemblyWithSkills(this.builderDependencies, { + sessionId, + basePrompt, + toolDefinitions, + activeSkillNamesOverride, + orchestrationPolicy: this.deps.orchestrationPolicy.resolveOrchestrationPolicy(sessionId), + commandShell, + resourceInstance + }) + } + createBasePromptAssembler(expectedInstance: DeepChatAgentInstance): BasePromptAssembler { return { assemble: async (input) => @@ -86,6 +107,15 @@ export class PromptAssemblyService { input.commandShell, [...input.activeSkillNames], expectedInstance + ), + assembleWithProvenance: async (input) => + await this.buildWithProvenance( + input.sessionId, + input.configuredPrompt, + [...input.toolDefinitions], + input.commandShell, + [...input.activeSkillNames], + expectedInstance ) } } diff --git a/src/main/agent/deepchat/runtime/sessionIdentityService.ts b/src/main/agent/deepchat/runtime/sessionIdentityService.ts index ada0f215e..5d3ea7bc4 100644 --- a/src/main/agent/deepchat/runtime/sessionIdentityService.ts +++ b/src/main/agent/deepchat/runtime/sessionIdentityService.ts @@ -1,6 +1,7 @@ import { toAppSessionId } from '@/agent/shared/agentSessionIds' import type { SessionScopeRegistry } from '@/agent/deepchat/instance/deepChatAgentRuntime' import type { SessionDatabase } from '@/session/data/database' +import type { SessionKind } from '@shared/types/agent-interface' export interface SessionIdentityServiceDependencies { registry: SessionScopeRegistry @@ -26,6 +27,10 @@ export class SessionIdentityService { return undefined } + getSessionKind(sessionId: string): SessionKind | null { + return this.deps.database.newSessionsTable?.get(sessionId)?.session_kind ?? null + } + isAcpBackedSubagentSession(sessionId: string, providerId?: string): boolean { const sessionRow = this.deps.database.newSessionsTable?.get(sessionId) if (!sessionRow || sessionRow.session_kind !== 'subagent') { diff --git a/src/main/agent/deepchat/runtime/taskContractCapability.ts b/src/main/agent/deepchat/runtime/taskContractCapability.ts new file mode 100644 index 000000000..21459370c --- /dev/null +++ b/src/main/agent/deepchat/runtime/taskContractCapability.ts @@ -0,0 +1,47 @@ +import { LIVE_DELEGATION_AGENT_TOOL_NAME } from '@shared/agentTools' +import type { MCPToolDefinition } from '@shared/types/core/mcp' +import type { DeepChatTaskContractContext } from '@shared/types/task-contract' +import { + ExecutionContractError, + isToolEffectWithinCeiling +} from '@/tape/domain/executionContract' +import { + isDeepChatTaskContract, + isDeepChatTaskContractRef +} from '@/tape/domain/taskContract' + +function requestedSubagentDepth(tool: MCPToolDefinition): number { + return tool.source === 'agent' && tool.function.name === LIVE_DELEGATION_AGENT_TOOL_NAME ? 1 : 0 +} + +export function meetTaskContractToolDefinitions( + sessionId: string, + tools: readonly MCPToolDefinition[], + context: DeepChatTaskContractContext | null +): MCPToolDefinition[] { + if (context === null) return [...tools] + if ( + !isDeepChatTaskContract(context.contract) || + !isDeepChatTaskContractRef(context.localRef) || + context.localRef.sessionId !== sessionId || + context.localRef.contractHash !== context.contract.contractHash + ) { + throw new ExecutionContractError( + 'TaskContract context does not belong to the tool catalog Session.', + 'invalid_input' + ) + } + + const ceilings = context.contract.taskHarness.ceilings + return tools.filter( + (tool) => + isToolEffectWithinCeiling(tool.execution.effect, ceilings.maxToolEffect) && + requestedSubagentDepth(tool) <= ceilings.maxSubagentDepth + ) +} + +export function resolveExecutionContractSubagentDepth( + tools: readonly MCPToolDefinition[] +): number { + return tools.some((tool) => requestedSubagentDepth(tool) > 0) ? 1 : 0 +} diff --git a/src/main/agent/deepchat/runtime/turnCoordinator.ts b/src/main/agent/deepchat/runtime/turnCoordinator.ts index 37054b63a..0446a8242 100644 --- a/src/main/agent/deepchat/runtime/turnCoordinator.ts +++ b/src/main/agent/deepchat/runtime/turnCoordinator.ts @@ -12,6 +12,7 @@ import type { } from '@shared/types/agent-interface' import type { ChatMessage } from '@shared/types/core/chat-message' import type { MCPToolDefinition } from '@shared/types/core/mcp' +import type { DeepChatPromptAssembly } from '@shared/types/prompt-assembly' import type { ToolServicePort } from '@shared/types/tool' import { toAppSessionId } from '@/agent/shared/agentSessionIds' import type { DeepChatAgentInstance } from '@/agent/deepchat/instance/deepChatAgentInstance' @@ -30,6 +31,11 @@ import type { DeepChatContextCoordinator } from '@/agent/deepchat/loop/contextCo import type { InputPreparationCoordinator } from '@/agent/deepchat/loop/inputPreparationCoordinator' import type { PostCompactionPromptAssembler } from '@/agent/deepchat/loop/ports' import { resolveEffectiveActiveSkillNames } from '@/agent/deepchat/resources/systemPromptBuilder' +import { + appendPromptAssemblySection, + createPromptAssemblySection, + recordPromptAssemblyObservation +} from '@/agent/deepchat/resources/promptAssembly' import { awaitWithAbort } from '@/lib/awaitWithAbort' import { capAgentRequestMaxTokens, @@ -82,6 +88,9 @@ import { import { PENDING_INPUT_ABORT_REASON, throwIfAbortRequested } from './abortErrors' import type { RunLifecycleCoordinator } from './runLifecycleCoordinator' import type { RuntimeHookSink } from './runtimeHookSink' +import type { DeepChatTaskContractContextPort } from '@/agent/deepchat/loop/ports' +import type { SessionIdentityService } from './sessionIdentityService' +import { meetTaskContractToolDefinitions } from './taskContractCapability' import type { ClaimedPendingInputHandle, TurnCompletion @@ -146,6 +155,8 @@ export interface TurnCoordinatorPorts { 'resolveProjectDir' | 'getEffectiveGenerationSettings' > promptAssembly: Pick + identity: Pick + taskContractContext: DeepChatTaskContractContextPort commandShell: Pick loopRunner: Pick messageProjection: Pick @@ -231,7 +242,7 @@ export class TurnCoordinator { ) this.ports.runLifecycle.assertCurrentInstance(sessionId, instance) const activeSkillNames = resolveEffectiveActiveSkillNames(sessionActiveSkillNames, instance) - const tools = await this.runPreStreamStep( + const resolvedTools = await this.runPreStreamStep( { sessionId, messageId, step: 'tool-definitions', signal }, () => awaitWithAbort( @@ -244,6 +255,13 @@ export class TurnCoordinator { signal ) ) + const strictDeepChatChild = + this.ports.identity.getSessionKind(sessionId) === 'subagent' && + !this.ports.identity.isAcpBackedSubagentSession(sessionId, state.providerId) + const taskContractContext = strictDeepChatChild + ? this.ports.taskContractContext.prepare(sessionId) + : null + const tools = meetTaskContractToolDefinitions(sessionId, resolvedTools, taskContractContext) const toolReserveTokens = estimateToolReserveTokens(tools) throwIfAbortRequested(signal) const commandShell = await this.runPreStreamStep( @@ -252,11 +270,11 @@ export class TurnCoordinator { ) throwIfAbortRequested(signal) const basePromptAssembler = this.ports.promptAssembly.createBasePromptAssembler(instance) - const baseSystemPrompt = await this.runPreStreamStep( + const basePromptAssembly = await this.runPreStreamStep( { sessionId, messageId, step: 'system-prompt', signal }, () => awaitWithAbort( - basePromptAssembler.assemble({ + basePromptAssembler.assembleWithProvenance({ sessionId: toAppSessionId(sessionId), configuredPrompt: generationSettings.systemPrompt, toolDefinitions: tools, @@ -275,11 +293,13 @@ export class TurnCoordinator { contextBudgetLength, maxTokens, activeSkillNames, + taskContractContext, tools, toolReserveTokens, commandShell, basePromptAssembler, - baseSystemPrompt + basePromptAssembly, + baseSystemPrompt: basePromptAssembly.prompt } } @@ -507,11 +527,12 @@ export class TurnCoordinator { contextBudgetLength, maxTokens, activeSkillNames: effectiveActiveSkillNames, + taskContractContext, tools, toolReserveTokens, commandShell, basePromptAssembler, - baseSystemPrompt: unguardedBaseSystemPrompt + basePromptAssembly: unguardedBasePromptAssembly } = await this.prepareTurnResources({ sessionId, messageId: userMessageId, @@ -525,9 +546,10 @@ export class TurnCoordinator { // history/compaction preparation so those stages observe the replacement transcript. context?.beforeHistoryPreparation?.() let shouldGuardAttachmentText = content.files?.some(hasUntrustedAttachmentText) - let baseSystemPrompt = shouldGuardAttachmentText - ? appendAttachmentTextSafetyRule(unguardedBaseSystemPrompt) - : unguardedBaseSystemPrompt + let basePromptAssembly = shouldGuardAttachmentText + ? appendAttachmentTextSafetySection(unguardedBasePromptAssembly) + : unguardedBasePromptAssembly + let baseSystemPrompt = basePromptAssembly.prompt const userContent: UserMessageContent = { text: content.text, files: content.files || [], @@ -550,7 +572,8 @@ export class TurnCoordinator { const prepareCompactionIntent = async (historyRecords: ChatMessageRecord[]) => { if (!shouldGuardAttachmentText && historyContainsUntrustedAttachmentText(historyRecords)) { shouldGuardAttachmentText = true - baseSystemPrompt = appendAttachmentTextSafetyRule(unguardedBaseSystemPrompt) + basePromptAssembly = appendAttachmentTextSafetySection(unguardedBasePromptAssembly) + baseSystemPrompt = basePromptAssembly.prompt } if (!useContextBudget) { return null @@ -831,14 +854,16 @@ export class TurnCoordinator { tools, commandShell, baseSystemPrompt, + basePromptAssembly, contextContributions, resourceInstance: instance, providerModelFacts, + taskContractContext, providerReplayProjector, abortController: preStreamAbortController, maxProviderRounds: context?.maxProviderRounds, refreshSystemPrompt: async (activeSkillNames, refreshedTools) => { - const refreshedBasePrompt = await basePromptAssembler.assemble({ + const refreshedBasePrompt = await basePromptAssembler.assembleWithProvenance({ sessionId: toAppSessionId(sessionId), configuredPrompt: generationSettings.systemPrompt, toolDefinitions: refreshedTools, @@ -846,7 +871,7 @@ export class TurnCoordinator { commandShell }) return shouldGuardAttachmentText - ? appendAttachmentTextSafetyRule(refreshedBasePrompt) + ? appendAttachmentTextSafetySection(refreshedBasePrompt) : refreshedBasePrompt }, interleavedReasoning, @@ -1231,11 +1256,12 @@ export class TurnCoordinator { contextBudgetLength, maxTokens, activeSkillNames: effectiveActiveSkillNames, + taskContractContext, tools, toolReserveTokens, commandShell, basePromptAssembler, - baseSystemPrompt: unguardedBaseSystemPrompt + basePromptAssembly: unguardedBasePromptAssembly } = await this.prepareTurnResources({ sessionId, messageId, @@ -1244,7 +1270,8 @@ export class TurnCoordinator { projectDir, providerModelFacts }) - let baseSystemPrompt = unguardedBaseSystemPrompt + let basePromptAssembly = unguardedBasePromptAssembly + let baseSystemPrompt = basePromptAssembly.prompt let shouldGuardAttachmentText = false let resumeTargetOrderSeq: number | undefined const preparedInput = await this.ports.inputPreparationCoordinator.prepareExisting({ @@ -1273,7 +1300,8 @@ export class TurnCoordinator { prepareIntent: async (historyRecords) => { if (historyContainsUntrustedAttachmentText(historyRecords)) { shouldGuardAttachmentText = true - baseSystemPrompt = appendAttachmentTextSafetyRule(unguardedBaseSystemPrompt) + basePromptAssembly = appendAttachmentTextSafetySection(unguardedBasePromptAssembly) + baseSystemPrompt = basePromptAssembly.prompt } resumeTargetOrderSeq = historyRecords.find((record) => record.id === messageId)?.orderSeq ?? @@ -1488,10 +1516,12 @@ export class TurnCoordinator { projectDir, resourceInstance: instance, providerModelFacts, + taskContractContext, abortController: preStreamAbortController, tools, commandShell, baseSystemPrompt, + basePromptAssembly, contextContributions, initialBlocks, initialAccounting: resumeAccounting, @@ -1499,7 +1529,7 @@ export class TurnCoordinator { maxProviderRounds: resumeAccounting.maxProviderRounds, search, refreshSystemPrompt: async (activeSkillNames, refreshedTools) => { - const refreshedBasePrompt = await basePromptAssembler.assemble({ + const refreshedBasePrompt = await basePromptAssembler.assembleWithProvenance({ sessionId: toAppSessionId(sessionId), configuredPrompt: generationSettings.systemPrompt, toolDefinitions: refreshedTools, @@ -1507,7 +1537,7 @@ export class TurnCoordinator { commandShell }) return shouldGuardAttachmentText - ? appendAttachmentTextSafetyRule(refreshedBasePrompt) + ? appendAttachmentTextSafetySection(refreshedBasePrompt) : refreshedBasePrompt }, interleavedReasoning, @@ -1728,12 +1758,24 @@ function resolveAssistantTurnSearchIntent( return user?.role === 'user' && extractUserMessageInput(user.content).search === true } -function appendAttachmentTextSafetyRule(prompt: string): string { - if (prompt.includes(ATTACHMENT_TEXT_SAFETY_RULE)) return prompt - const trimmedPrompt = prompt.trimEnd() - return trimmedPrompt - ? `${trimmedPrompt}\n\n${ATTACHMENT_TEXT_SAFETY_RULE}` - : ATTACHMENT_TEXT_SAFETY_RULE +function appendAttachmentTextSafetySection( + assembly: DeepChatPromptAssembly +): DeepChatPromptAssembly { + const section = createPromptAssemblySection({ + kind: 'attachment_safety', + sourceRef: 'runtime:attachment-text-safety', + content: ATTACHMENT_TEXT_SAFETY_RULE + }) + const alreadyRecorded = assembly.sections.some( + (candidate) => + candidate.kind === section.kind && + candidate.sourceRef === section.sourceRef && + candidate.contentHash === section.contentHash + ) + if (alreadyRecorded) return assembly + return assembly.prompt.includes(ATTACHMENT_TEXT_SAFETY_RULE) + ? recordPromptAssemblyObservation(assembly, section) + : appendPromptAssemblySection(assembly, section) } function historyContainsUntrustedAttachmentText( diff --git a/src/main/agent/deepchat/runtime/types.ts b/src/main/agent/deepchat/runtime/types.ts index 081f2de05..49094b410 100644 --- a/src/main/agent/deepchat/runtime/types.ts +++ b/src/main/agent/deepchat/runtime/types.ts @@ -16,6 +16,7 @@ import type { } from '@shared/types/core/chat-message' import type { MCPToolDefinition } from '@shared/types/core/mcp' import type { ModelConfig } from '@shared/types/provider' +import type { DeepChatPromptAssembly } from '@shared/types/prompt-assembly' import type { DeepchatEventName } from '@shared/contracts/events' import type { DeepChatInternalSessionUpdate } from './sessionUpdates' import type { SessionTranscript } from '@/session/data/transcript' @@ -233,7 +234,7 @@ export interface ProcessParams { refreshSystemPrompt?: ( activeSkillNames: string[] | undefined, toolDefinitions: MCPToolDefinition[] - ) => Promise + ) => Promise toolExecution: ToolExecutionPort toolResults: ToolResultPort coreStream: ( diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index faa407c98..96b235e2e 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -168,6 +168,8 @@ import { LiveDelegationRepository } from '@/orchestration/liveDelegationReposito import { LiveDelegationService } from '@/orchestration/liveDelegationService' import { LiveDelegationSafetyCoordinator } from '@/orchestration/liveDelegationSafety' import { LiveDelegationConsentAuthority } from '@/orchestration/liveDelegationConsent' +import { TaskContractService } from '@/tape/application/taskContractService' +import { TaskEvaluationService } from '@/tape/application/taskEvaluationService' import { createProjectRoutes } from '../project/routes' import { RemoteService } from '../remote' import type { RemoteServiceLike } from '../remote/ports' @@ -188,6 +190,7 @@ import { normalizeDeepChatSubagentSlots, resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' +import { composeSubagentAuthority } from '@/session/subagentAuthority' import type { AcpAsLlmProviderPermissionPort, AcpAsLlmProviderSessionControlPort, @@ -304,9 +307,6 @@ export async function createMainProcessControl(dependencies: { const databaseSecurityService = dependencies.databaseSecurityService const startupWorkloadCoordinator = dependencies.startupWorkloadCoordinator const mainDatabase = dependencies.database - const liveDelegationRepository = new LiveDelegationRepository( - new LiveDelegationDatabase(mainDatabase) - ) const fileWatcherService = new FileWatcherService() let windowPresenter: IWindowPresenter let providerSettings: ProviderSettings @@ -564,6 +564,17 @@ export async function createMainProcessControl(dependencies: { }) } ) + const taskContractService = new TaskContractService( + () => sessionData.database.deepchatContractStore + ) + const taskEvaluationService = new TaskEvaluationService( + () => sessionData.database.deepchatContractStore + ) + const liveDelegationRepository = new LiveDelegationRepository( + new LiveDelegationDatabase(mainDatabase), + taskContractService, + taskEvaluationService + ) const sessionRuntimeEvents = new SessionRuntimeEvents() const projectDatabase = new ProjectDatabase(mainDatabase) const agentDatabase = dependencies.agentDatabase @@ -1103,6 +1114,55 @@ export async function createMainProcessControl(dependencies: { return null }, + resolveConversationExecutionAuthority: async (conversationId) => { + const session = await sessionQuery.getSession(conversationId) + if (!session) { + return null + } + + const [agentType, persistedDisabledAgentTools, agentConfig] = await Promise.all([ + agentSettings.getAgentType(session.agentId), + sessionAssignment.getSessionDisabledAgentTools(session.id), + agentSettings.resolveDeepChatAgentConfig(session.agentId) + ]) + let authority = composeSubagentAuthority({ + disabledAgentTools: persistedDisabledAgentTools, + enabledMcpServerIds: agentConfig.enabledMcpServerIds + }) + if (session.sessionKind === 'subagent') { + const parentSessionId = session.parentSessionId?.trim() + const parent = parentSessionId ? await sessionQuery.getSession(parentSessionId) : null + if (!parent || parent.sessionKind !== 'regular') { + throw new Error(`Subagent Session ${session.id} has no resolvable parent tool policy.`) + } + const [parentDisabledAgentTools, parentConfig] = await Promise.all([ + sessionAssignment.getSessionDisabledAgentTools(parent.id), + agentSettings.resolveDeepChatAgentConfig(parent.agentId) + ]) + authority = composeSubagentAuthority( + { disabledAgentTools: persistedDisabledAgentTools }, + { disabledAgentTools: parentDisabledAgentTools }, + parentConfig, + agentConfig + ) + } + const subagentCapability = resolveDeepChatSubagentCapability({ + agentType, + sessionKind: session.sessionKind, + agentPolicyEnabled: agentConfig.subagentEnabled === true, + slots: normalizeDeepChatSubagentSlots(agentConfig.subagents) + }) + + return { + sessionId: session.id, + agentId: session.agentId, + projectDir: session.projectDir ?? null, + sessionKind: session.sessionKind, + disabledAgentTools: authority.disabledAgentTools, + enabledMcpServerIds: authority.enabledMcpServerIds, + subagentCapability + } + }, resolveConversationSessionInfo: async (conversationId) => { const session = await sessionQuery.getSession(conversationId) if (!session) { @@ -1414,6 +1474,9 @@ export async function createMainProcessControl(dependencies: { resume: async (sessionId, signal) => await liveDelegationService.beforeInteractionContinuation(sessionId, signal), suspend: (sessionId) => liveDelegationService.suspendInteractionContinuation(sessionId) + }, + taskContractContext: { + prepare: (sessionId) => liveDelegationService.prepareTaskContractContext(sessionId) } }) const sessionTranscriptMutations = new SessionTranscriptMutations({ diff --git a/src/main/data/schemaCatalog.ts b/src/main/data/schemaCatalog.ts index 99accde38..b48df2b72 100644 --- a/src/main/data/schemaCatalog.ts +++ b/src/main/data/schemaCatalog.ts @@ -39,9 +39,18 @@ import { SettingsActivityTable } from '@/settings/data/tables/settingsActivity' import { CronJobsTable } from '@/scheduler/data/tables/cronJobs' import { CronJobRunsTable } from '@/scheduler/data/tables/cronJobRuns' import { CronJobDeliveriesTable } from '@/scheduler/data/tables/cronJobDeliveries' -import { LiveDelegationEventsTable } from '@/orchestration/data/tables/liveDelegationEvents' +import { + LIVE_DELEGATION_EVENT_EVALUATION_ADD_COLUMN_SQL, + LIVE_DELEGATION_EVENT_EVALUATION_REF_ADD_COLUMN_SQL, + LiveDelegationEventsTable +} from '@/orchestration/data/tables/liveDelegationEvents' import { LiveDelegationsTable } from '@/orchestration/data/tables/liveDelegations' import { + LIVE_DELEGATION_TURN_CONTRACT_ADD_COLUMN_SQL, + LIVE_DELEGATION_TURN_CONTRACT_REF_ADD_COLUMN_SQL, + LIVE_DELEGATION_TURN_EVALUATION_ADD_COLUMN_SQL, + LIVE_DELEGATION_TURN_EVALUATION_REF_ADD_COLUMN_SQL, + LIVE_DELEGATION_TURN_INHERITED_CONTRACT_REF_ADD_COLUMN_SQL, LIVE_DELEGATION_TURN_RESULT_REF_ADD_COLUMN_SQL, LiveDelegationTurnsTable } from '@/orchestration/data/tables/liveDelegationTurns' @@ -340,13 +349,22 @@ const CATALOG_DEFINITIONS: CatalogDefinition[] = [ name: 'live_delegation_turns', createTable: (db) => new LiveDelegationTurnsTable(db), repairableColumns: { - result_ref_json: `${LIVE_DELEGATION_TURN_RESULT_REF_ADD_COLUMN_SQL};` + result_ref_json: `${LIVE_DELEGATION_TURN_RESULT_REF_ADD_COLUMN_SQL};`, + task_contract_json: `${LIVE_DELEGATION_TURN_CONTRACT_ADD_COLUMN_SQL};`, + task_contract_ref_json: `${LIVE_DELEGATION_TURN_CONTRACT_REF_ADD_COLUMN_SQL};`, + inherited_task_contract_ref_json: `${LIVE_DELEGATION_TURN_INHERITED_CONTRACT_REF_ADD_COLUMN_SQL};`, + evaluation_json: `${LIVE_DELEGATION_TURN_EVALUATION_ADD_COLUMN_SQL};`, + evaluation_ref_json: `${LIVE_DELEGATION_TURN_EVALUATION_REF_ADD_COLUMN_SQL};` }, typeCheckedColumns: ['seq', 'created_at', 'updated_at'] }, { name: 'live_delegation_events', createTable: (db) => new LiveDelegationEventsTable(db), + repairableColumns: { + evaluation_json: `${LIVE_DELEGATION_EVENT_EVALUATION_ADD_COLUMN_SQL};`, + evaluation_ref_json: `${LIVE_DELEGATION_EVENT_EVALUATION_REF_ADD_COLUMN_SQL};` + }, typeCheckedColumns: ['event_id', 'created_at'] } ] diff --git a/src/main/orchestration/data/tables/liveDelegationEvents.ts b/src/main/orchestration/data/tables/liveDelegationEvents.ts index eca1a13c2..4da7fe0cb 100644 --- a/src/main/orchestration/data/tables/liveDelegationEvents.ts +++ b/src/main/orchestration/data/tables/liveDelegationEvents.ts @@ -5,9 +5,13 @@ import type { LiveDelegationEventKind } from '@shared/orchestration/liveDelegation' import { - LIVE_DELEGATION_DATABASE_SCHEMA_VERSION, + LIVE_DELEGATION_EVALUATION_DATABASE_SCHEMA_VERSION, LIVE_DELEGATION_INITIAL_DATABASE_SCHEMA_VERSION } from './liveDelegations' +import { + MAX_TASK_EVALUATION_BYTES, + MAX_TASK_EVALUATION_REF_BYTES +} from '@shared/types/task-contract' export interface LiveDelegationEventRow { event_id: number @@ -18,10 +22,34 @@ export interface LiveDelegationEventRow { content: string related_turn_id: string | null consumed_by_turn_id: string | null + evaluation_json: string | null + evaluation_ref_json: string | null created_at: number } -const LIVE_DELEGATION_EVENTS_SCHEMA_SQL = ` +const LIVE_DELEGATION_EVENT_EVALUATION_COLUMNS_SQL = ` + evaluation_json TEXT CHECK ( + evaluation_json IS NULL + OR ( + json_valid(evaluation_json) + AND json_type(evaluation_json) = 'object' + AND length(CAST(evaluation_json AS BLOB)) <= ${MAX_TASK_EVALUATION_BYTES} + ) + ), + evaluation_ref_json TEXT CHECK ( + (evaluation_json IS NULL) = (evaluation_ref_json IS NULL) + AND ( + evaluation_ref_json IS NULL + OR ( + json_valid(evaluation_ref_json) + AND json_type(evaluation_ref_json) = 'object' + AND length(CAST(evaluation_ref_json AS BLOB)) <= ${MAX_TASK_EVALUATION_REF_BYTES} + ) + ) + ), +` + +const createLiveDelegationEventsSchemaSql = (includeEvaluation: boolean): string => ` CREATE TABLE IF NOT EXISTS live_delegation_events ( event_id INTEGER PRIMARY KEY AUTOINCREMENT, delegation_id TEXT NOT NULL CHECK (length(delegation_id) BETWEEN 1 AND 256), @@ -37,6 +65,7 @@ const LIVE_DELEGATION_EVENTS_SCHEMA_SQL = ` ), related_turn_id TEXT, consumed_by_turn_id TEXT, +${includeEvaluation ? LIVE_DELEGATION_EVENT_EVALUATION_COLUMNS_SQL : ''} created_at INTEGER NOT NULL CHECK (created_at >= 0), FOREIGN KEY (delegation_id) REFERENCES live_delegations(delegation_id) ON DELETE CASCADE, FOREIGN KEY (related_turn_id) REFERENCES live_delegation_turns(turn_id) ON DELETE SET NULL, @@ -49,6 +78,36 @@ const LIVE_DELEGATION_EVENTS_SCHEMA_SQL = ` WHERE direction = 'parent_to_child' AND consumed_by_turn_id IS NULL; ` +const LIVE_DELEGATION_EVENTS_SCHEMA_SQL = createLiveDelegationEventsSchemaSql(true) +const LIVE_DELEGATION_EVENTS_V60_SCHEMA_SQL = createLiveDelegationEventsSchemaSql(false) + +export const LIVE_DELEGATION_EVENT_EVALUATION_ADD_COLUMN_SQL = ` + ALTER TABLE live_delegation_events + ADD COLUMN evaluation_json TEXT CHECK ( + evaluation_json IS NULL + OR ( + json_valid(evaluation_json) + AND json_type(evaluation_json) = 'object' + AND length(CAST(evaluation_json AS BLOB)) <= ${MAX_TASK_EVALUATION_BYTES} + ) + ) +` + +export const LIVE_DELEGATION_EVENT_EVALUATION_REF_ADD_COLUMN_SQL = ` + ALTER TABLE live_delegation_events + ADD COLUMN evaluation_ref_json TEXT CHECK ( + (evaluation_json IS NULL) = (evaluation_ref_json IS NULL) + AND ( + evaluation_ref_json IS NULL + OR ( + json_valid(evaluation_ref_json) + AND json_type(evaluation_ref_json) = 'object' + AND length(CAST(evaluation_ref_json AS BLOB)) <= ${MAX_TASK_EVALUATION_REF_BYTES} + ) + ) + ) +` + const LIVE_DELEGATION_EVENTS_TRIGGER_SQL = ` CREATE TRIGGER IF NOT EXISTS trg_live_delegation_events_parent_insert BEFORE INSERT ON live_delegation_events @@ -85,18 +144,39 @@ export class LiveDelegationEventsTable extends BaseTable { } override createTable(): void { - super.createTable() + if (!this.tableExists()) { + const recordedVersion = this.getRecordedSchemaVersion() + this.db.exec( + recordedVersion > 0 && recordedVersion < LIVE_DELEGATION_EVALUATION_DATABASE_SCHEMA_VERSION + ? LIVE_DELEGATION_EVENTS_V60_SCHEMA_SQL + : LIVE_DELEGATION_EVENTS_SCHEMA_SQL + ) + } this.db.exec(LIVE_DELEGATION_EVENTS_TRIGGER_SQL) } getMigrationSQL(version: number): string | null { - return version === LIVE_DELEGATION_INITIAL_DATABASE_SCHEMA_VERSION - ? LIVE_DELEGATION_EVENTS_SCHEMA_SQL - : null + if (version === LIVE_DELEGATION_INITIAL_DATABASE_SCHEMA_VERSION) { + return LIVE_DELEGATION_EVENTS_V60_SCHEMA_SQL + } + if (version === LIVE_DELEGATION_EVALUATION_DATABASE_SCHEMA_VERSION) { + const statements = [ + ...(this.hasColumn('evaluation_json') + ? [] + : [LIVE_DELEGATION_EVENT_EVALUATION_ADD_COLUMN_SQL]), + ...(this.hasColumn('evaluation_ref_json') + ? [] + : [LIVE_DELEGATION_EVENT_EVALUATION_REF_ADD_COLUMN_SQL]) + ] + return statements.length > 0 + ? statements.map((statement) => `${statement.trimEnd()};`).join('\n') + : 'SELECT 1 /* live delegation event evaluation schema already present */;' + } + return null } getLatestVersion(): number { - return LIVE_DELEGATION_DATABASE_SCHEMA_VERSION + return LIVE_DELEGATION_EVALUATION_DATABASE_SCHEMA_VERSION } finalizeMigration(version: number): void { diff --git a/src/main/orchestration/data/tables/liveDelegationTurns.ts b/src/main/orchestration/data/tables/liveDelegationTurns.ts index 8005f1a34..fe2ba64c9 100644 --- a/src/main/orchestration/data/tables/liveDelegationTurns.ts +++ b/src/main/orchestration/data/tables/liveDelegationTurns.ts @@ -7,6 +7,13 @@ import { } from '@shared/orchestration/liveDelegation' import type { OrchestrationEffectState } from '@shared/orchestration/toolEffect' import { + MAX_TASK_CONTRACT_BYTES, + MAX_TASK_CONTRACT_REF_BYTES, + MAX_TASK_EVALUATION_BYTES, + MAX_TASK_EVALUATION_REF_BYTES +} from '@shared/types/task-contract' +import { + LIVE_DELEGATION_CONTRACT_DATABASE_SCHEMA_VERSION, LIVE_DELEGATION_DATABASE_SCHEMA_VERSION, LIVE_DELEGATION_EFFECT_DATABASE_SCHEMA_VERSION, LIVE_DELEGATION_INITIAL_DATABASE_SCHEMA_VERSION @@ -23,6 +30,11 @@ export interface LiveDelegationTurnRow { error: string | null tape_receipt_json: string | null result_ref_json: string | null + task_contract_json: string | null + task_contract_ref_json: string | null + inherited_task_contract_ref_json: string | null + evaluation_json: string | null + evaluation_ref_json: string | null effect_state: OrchestrationEffectState effect_evidence_json: string | null created_at: number @@ -57,9 +69,61 @@ const LIVE_DELEGATION_TURN_RESULT_REF_COLUMN_SQL = ` ), ` +const LIVE_DELEGATION_TURN_CONTRACT_COLUMNS_SQL = ` + task_contract_json TEXT CHECK ( + task_contract_json IS NULL + OR ( + json_valid(task_contract_json) + AND json_type(task_contract_json) = 'object' + AND length(CAST(task_contract_json AS BLOB)) <= ${MAX_TASK_CONTRACT_BYTES} + ) + ), + task_contract_ref_json TEXT CHECK ( + (task_contract_json IS NULL) = (task_contract_ref_json IS NULL) + AND ( + task_contract_ref_json IS NULL + OR ( + json_valid(task_contract_ref_json) + AND json_type(task_contract_ref_json) = 'object' + AND length(CAST(task_contract_ref_json AS BLOB)) <= ${MAX_TASK_CONTRACT_REF_BYTES} + ) + ) + ), + inherited_task_contract_ref_json TEXT CHECK ( + inherited_task_contract_ref_json IS NULL + OR ( + task_contract_json IS NOT NULL + AND json_valid(inherited_task_contract_ref_json) + AND json_type(inherited_task_contract_ref_json) = 'object' + AND length(CAST(inherited_task_contract_ref_json AS BLOB)) <= ${MAX_TASK_CONTRACT_REF_BYTES} + ) + ), + evaluation_json TEXT CHECK ( + evaluation_json IS NULL + OR ( + task_contract_json IS NOT NULL + AND json_valid(evaluation_json) + AND json_type(evaluation_json) = 'object' + AND length(CAST(evaluation_json AS BLOB)) <= ${MAX_TASK_EVALUATION_BYTES} + ) + ), + evaluation_ref_json TEXT CHECK ( + (evaluation_json IS NULL) = (evaluation_ref_json IS NULL) + AND ( + evaluation_ref_json IS NULL + OR ( + json_valid(evaluation_ref_json) + AND json_type(evaluation_ref_json) = 'object' + AND length(CAST(evaluation_ref_json AS BLOB)) <= ${MAX_TASK_EVALUATION_REF_BYTES} + ) + ) + ), +` + const createLiveDelegationTurnsSchemaSql = ( includeEffectEvidence: boolean, - includeResultRef: boolean + includeResultRef: boolean, + includeContractProjection: boolean ): string => ` CREATE TABLE IF NOT EXISTS live_delegation_turns ( turn_id TEXT PRIMARY KEY CHECK (length(turn_id) BETWEEN 1 AND 256), @@ -86,6 +150,7 @@ const createLiveDelegationTurnsSchemaSql = ( ), ${includeResultRef ? LIVE_DELEGATION_TURN_RESULT_REF_COLUMN_SQL : ''} ${includeEffectEvidence ? LIVE_DELEGATION_TURN_EFFECT_COLUMNS_SQL : ''} +${includeContractProjection ? LIVE_DELEGATION_TURN_CONTRACT_COLUMNS_SQL : ''} created_at INTEGER NOT NULL CHECK (created_at >= 0), started_at INTEGER CHECK (started_at IS NULL OR started_at >= 0), updated_at INTEGER NOT NULL CHECK (updated_at >= 0), @@ -100,9 +165,10 @@ ${includeEffectEvidence ? LIVE_DELEGATION_TURN_EFFECT_COLUMNS_SQL : ''} WHERE status IN ('queued', 'running', 'waiting_permission', 'waiting_question'); ` -const LIVE_DELEGATION_TURNS_SCHEMA_SQL = createLiveDelegationTurnsSchemaSql(true, true) -const LIVE_DELEGATION_TURNS_V61_SCHEMA_SQL = createLiveDelegationTurnsSchemaSql(true, false) -const LIVE_DELEGATION_TURNS_V60_SCHEMA_SQL = createLiveDelegationTurnsSchemaSql(false, false) +const LIVE_DELEGATION_TURNS_SCHEMA_SQL = createLiveDelegationTurnsSchemaSql(true, true, true) +const LIVE_DELEGATION_TURNS_V62_SCHEMA_SQL = createLiveDelegationTurnsSchemaSql(true, true, false) +const LIVE_DELEGATION_TURNS_V61_SCHEMA_SQL = createLiveDelegationTurnsSchemaSql(true, false, false) +const LIVE_DELEGATION_TURNS_V60_SCHEMA_SQL = createLiveDelegationTurnsSchemaSql(false, false, false) const LIVE_DELEGATION_TURN_EFFECT_STATE_ADD_COLUMN_SQL = ` ALTER TABLE live_delegation_turns @@ -135,6 +201,74 @@ export const LIVE_DELEGATION_TURN_RESULT_REF_ADD_COLUMN_SQL = ` ) ` +export const LIVE_DELEGATION_TURN_CONTRACT_ADD_COLUMN_SQL = ` + ALTER TABLE live_delegation_turns + ADD COLUMN task_contract_json TEXT CHECK ( + task_contract_json IS NULL + OR ( + json_valid(task_contract_json) + AND json_type(task_contract_json) = 'object' + AND length(CAST(task_contract_json AS BLOB)) <= ${MAX_TASK_CONTRACT_BYTES} + ) + ) +` + +export const LIVE_DELEGATION_TURN_CONTRACT_REF_ADD_COLUMN_SQL = ` + ALTER TABLE live_delegation_turns + ADD COLUMN task_contract_ref_json TEXT CHECK ( + (task_contract_json IS NULL) = (task_contract_ref_json IS NULL) + AND ( + task_contract_ref_json IS NULL + OR ( + json_valid(task_contract_ref_json) + AND json_type(task_contract_ref_json) = 'object' + AND length(CAST(task_contract_ref_json AS BLOB)) <= ${MAX_TASK_CONTRACT_REF_BYTES} + ) + ) + ) +` + +export const LIVE_DELEGATION_TURN_INHERITED_CONTRACT_REF_ADD_COLUMN_SQL = ` + ALTER TABLE live_delegation_turns + ADD COLUMN inherited_task_contract_ref_json TEXT CHECK ( + inherited_task_contract_ref_json IS NULL + OR ( + task_contract_json IS NOT NULL + AND json_valid(inherited_task_contract_ref_json) + AND json_type(inherited_task_contract_ref_json) = 'object' + AND length(CAST(inherited_task_contract_ref_json AS BLOB)) <= ${MAX_TASK_CONTRACT_REF_BYTES} + ) + ) +` + +export const LIVE_DELEGATION_TURN_EVALUATION_ADD_COLUMN_SQL = ` + ALTER TABLE live_delegation_turns + ADD COLUMN evaluation_json TEXT CHECK ( + evaluation_json IS NULL + OR ( + task_contract_json IS NOT NULL + AND json_valid(evaluation_json) + AND json_type(evaluation_json) = 'object' + AND length(CAST(evaluation_json AS BLOB)) <= ${MAX_TASK_EVALUATION_BYTES} + ) + ) +` + +export const LIVE_DELEGATION_TURN_EVALUATION_REF_ADD_COLUMN_SQL = ` + ALTER TABLE live_delegation_turns + ADD COLUMN evaluation_ref_json TEXT CHECK ( + (evaluation_json IS NULL) = (evaluation_ref_json IS NULL) + AND ( + evaluation_ref_json IS NULL + OR ( + json_valid(evaluation_ref_json) + AND json_type(evaluation_ref_json) = 'object' + AND length(CAST(evaluation_ref_json AS BLOB)) <= ${MAX_TASK_EVALUATION_REF_BYTES} + ) + ) + ) +` + const LIVE_DELEGATION_TURNS_TRIGGER_SQL = ` CREATE TRIGGER IF NOT EXISTS trg_live_delegation_turns_parent_insert BEFORE INSERT ON live_delegation_turns @@ -163,7 +297,10 @@ export class LiveDelegationTurnsTable extends BaseTable { ? LIVE_DELEGATION_TURNS_V60_SCHEMA_SQL : recordedVersion > 0 && recordedVersion < LIVE_DELEGATION_DATABASE_SCHEMA_VERSION ? LIVE_DELEGATION_TURNS_V61_SCHEMA_SQL - : LIVE_DELEGATION_TURNS_SCHEMA_SQL + : recordedVersion > 0 && + recordedVersion < LIVE_DELEGATION_CONTRACT_DATABASE_SCHEMA_VERSION + ? LIVE_DELEGATION_TURNS_V62_SCHEMA_SQL + : LIVE_DELEGATION_TURNS_SCHEMA_SQL this.db.exec(schemaSql) } this.db.exec(LIVE_DELEGATION_TURNS_TRIGGER_SQL) @@ -191,11 +328,33 @@ export class LiveDelegationTurnsTable extends BaseTable { ? 'SELECT 1 /* live delegation result reference already present */;' : `${LIVE_DELEGATION_TURN_RESULT_REF_ADD_COLUMN_SQL};` } + if (version === LIVE_DELEGATION_CONTRACT_DATABASE_SCHEMA_VERSION) { + const statements = [ + ...(this.hasColumn('task_contract_json') + ? [] + : [LIVE_DELEGATION_TURN_CONTRACT_ADD_COLUMN_SQL]), + ...(this.hasColumn('task_contract_ref_json') + ? [] + : [LIVE_DELEGATION_TURN_CONTRACT_REF_ADD_COLUMN_SQL]), + ...(this.hasColumn('inherited_task_contract_ref_json') + ? [] + : [LIVE_DELEGATION_TURN_INHERITED_CONTRACT_REF_ADD_COLUMN_SQL]), + ...(this.hasColumn('evaluation_json') + ? [] + : [LIVE_DELEGATION_TURN_EVALUATION_ADD_COLUMN_SQL]), + ...(this.hasColumn('evaluation_ref_json') + ? [] + : [LIVE_DELEGATION_TURN_EVALUATION_REF_ADD_COLUMN_SQL]) + ] + return statements.length > 0 + ? statements.map((statement) => `${statement.trimEnd()};`).join('\n') + : 'SELECT 1 /* live delegation contract schema already present */;' + } return null } getLatestVersion(): number { - return LIVE_DELEGATION_DATABASE_SCHEMA_VERSION + return LIVE_DELEGATION_CONTRACT_DATABASE_SCHEMA_VERSION } finalizeMigration(version: number): void { diff --git a/src/main/orchestration/data/tables/liveDelegations.ts b/src/main/orchestration/data/tables/liveDelegations.ts index 18df22fb3..ff846c379 100644 --- a/src/main/orchestration/data/tables/liveDelegations.ts +++ b/src/main/orchestration/data/tables/liveDelegations.ts @@ -6,6 +6,8 @@ export const LIVE_DELEGATION_INITIAL_DATABASE_SCHEMA_VERSION = 60 export const LIVE_DELEGATION_EFFECT_DATABASE_SCHEMA_VERSION = 61 export const LIVE_DELEGATION_DATABASE_SCHEMA_VERSION = 62 export const ORCHESTRATION_DATABASE_SCHEMA_VERSION = 64 +export const LIVE_DELEGATION_CONTRACT_DATABASE_SCHEMA_VERSION = 65 +export const LIVE_DELEGATION_EVALUATION_DATABASE_SCHEMA_VERSION = 66 export interface LiveDelegationRow { delegation_id: string diff --git a/src/main/orchestration/liveDelegationRepository.ts b/src/main/orchestration/liveDelegationRepository.ts index 2b32227e9..0b253bf8b 100644 --- a/src/main/orchestration/liveDelegationRepository.ts +++ b/src/main/orchestration/liveDelegationRepository.ts @@ -1,6 +1,12 @@ import { Buffer } from 'node:buffer' import { z } from 'zod' import type { SubagentTapeLinkReceipt } from '@shared/types/agent-interface' +import type { + DeepChatEvaluationRef, + DeepChatStoredTaskEvaluation, + DeepChatTaskContractContext, + DeepChatTaskEvaluation +} from '@shared/types/task-contract' import { LIVE_DELEGATION_MAX_EFFECT_EVIDENCE_BYTES, LIVE_DELEGATION_MAX_ACTIVE_PER_PARENT, @@ -32,6 +38,26 @@ import type { LiveDelegationDatabase } from './data/database' import type { LiveDelegationEventRow } from './data/tables/liveDelegationEvents' import type { LiveDelegationRow } from './data/tables/liveDelegations' import type { LiveDelegationTurnRow } from './data/tables/liveDelegationTurns' +import type { TaskContractWriter } from '@/tape/application/taskContractService' +import type { TaskEvaluationWriter } from '@/tape/application/taskEvaluationService' +import { + buildTaskContract, + restoreTaskContract, + restoreTaskContractRef, + serializeTaskContract, + serializeTaskContractRef +} from '@/tape/domain/taskContract' +import { + buildTaskEvaluation, + restoreEvaluationRef, + restoreStoredTaskEvaluation, + serializeEvaluationRef, + serializeTaskEvaluation +} from '@/tape/domain/taskEvaluation' +import type { + LegacyLiveDelegationTaskContractInput, + LiveDelegationTaskContractInput +} from './liveDelegationTaskContract' const MAX_RETAINED_CONSUMED_MESSAGES_PER_PARENT = 500 const MAX_LIST_LIMIT = 100 @@ -62,6 +88,7 @@ export interface CreateLiveDelegationInput { targetAgentId: string title: string prompt: string + taskContract: LiveDelegationTaskContractInput now?: number } @@ -72,8 +99,26 @@ export interface LiveDelegationWithTurn { export interface ActiveLiveDelegationTurn extends LiveDelegationWithTurn {} +export interface ActiveLiveDelegationTurnIdentity { + delegationId: string + parentSessionId: string + childSessionId: string | null + turnId: string +} + +export class LiveDelegationTaskContractError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'LiveDelegationTaskContractError' + } +} + export class LiveDelegationRepository { - constructor(private readonly database: LiveDelegationDatabase) {} + constructor( + private readonly database: LiveDelegationDatabase, + private readonly taskContracts: TaskContractWriter, + private readonly taskEvaluations: TaskEvaluationWriter + ) {} create(input: CreateLiveDelegationInput, beforeMutation?: () => void): LiveDelegationWithTurn { const id = StoredIdSchema.parse(input.id) @@ -84,6 +129,19 @@ export class LiveDelegationRepository { const title = validateText(input.title, 160, 'Live delegation title') const prompt = validateBytes(input.prompt, LIVE_DELEGATION_MAX_PROMPT_BYTES, 'Delegated task') const now = validateTimestamp(input.now ?? Date.now()) + const taskContract = buildTaskContract({ + ...input.taskContract, + delegationId: id, + turnId: initialTurnId, + turnSeq: 1, + turnKind: 'initial', + parentSessionId, + slotId, + targetAgentId, + title, + prompt + }) + const taskContractJson = serializeTaskContract(taskContract) const db = this.database.getDatabase() const parentExists = db.prepare('SELECT 1 FROM new_sessions WHERE id = ?').get(parentSessionId) if (!parentExists) throw new Error('live delegation parent session does not exist') @@ -97,12 +155,19 @@ export class LiveDelegationRepository { status, last_turn_seq, last_summary, last_error, created_at, updated_at, revision ) VALUES (?, ?, NULL, ?, ?, ?, 'queued', 1, NULL, NULL, ?, ?, 0)` ).run(id, parentSessionId, slotId, targetAgentId, title, now, now) + const frozen = this.taskContracts.freezeParentTaskContract({ + parentSessionId, + contract: taskContract, + createdAt: now + }) + const taskContractRefJson = serializeTaskContractRef(frozen.ref) db.prepare( `INSERT INTO live_delegation_turns ( turn_id, delegation_id, seq, kind, prompt, status, result_summary, error, - tape_receipt_json, created_at, started_at, updated_at, completed_at - ) VALUES (?, ?, 1, 'initial', ?, 'queued', NULL, NULL, NULL, ?, NULL, ?, NULL)` - ).run(initialTurnId, id, prompt, now, now) + tape_receipt_json, task_contract_json, task_contract_ref_json, + created_at, started_at, updated_at, completed_at + ) VALUES (?, ?, 1, 'initial', ?, 'queued', NULL, NULL, NULL, ?, ?, ?, NULL, ?, NULL)` + ).run(initialTurnId, id, prompt, taskContractJson, taskContractRefJson, now, now) return { delegation: this.require(id), turn: this.requireTurn(initialTurnId) @@ -218,6 +283,31 @@ export class LiveDelegationRepository { })) } + listActiveTurnIdentities(): ActiveLiveDelegationTurnIdentity[] { + const rows = this.database + .getDatabase() + .prepare( + `SELECT d.delegation_id, d.parent_session_id, d.child_session_id, t.turn_id + FROM live_delegation_turns AS t + INNER JOIN live_delegations AS d ON d.delegation_id = t.delegation_id + WHERE t.status IN ('queued', 'running', 'waiting_permission', 'waiting_question') + ORDER BY t.updated_at ASC, t.turn_id ASC` + ) + .all() as Array<{ + delegation_id: string + parent_session_id: string + child_session_id: string | null + turn_id: string + }> + + return rows.map((row) => ({ + delegationId: StoredIdSchema.parse(row.delegation_id), + parentSessionId: StoredIdSchema.parse(row.parent_session_id), + childSessionId: row.child_session_id ? StoredIdSchema.parse(row.child_session_id) : null, + turnId: StoredIdSchema.parse(row.turn_id) + })) + } + countActiveByParent(parentSessionId: string): number { return this.readActiveCount(StoredIdSchema.parse(parentSessionId)) } @@ -240,6 +330,184 @@ export class LiveDelegationRepository { return this.require(id) } + freezeLegacyTaskContract( + turnId: string, + input: LegacyLiveDelegationTaskContractInput, + now = Date.now() + ): LiveDelegationWithTurn { + const normalizedTurnId = StoredIdSchema.parse(turnId) + const timestamp = validateTimestamp(now) + const db = this.database.getDatabase() + + try { + return db.transaction(() => { + const turn = this.requireTurn(normalizedTurnId) + const delegation = this.require(turn.delegationId) + if (!isActiveTurnStatus(turn.status)) { + throw new LiveDelegationTaskContractError( + `Live delegation turn ${turn.id} is already terminal.` + ) + } + if (turn.taskContract !== null) return { delegation, turn } + if (turn.taskContractRef !== null || turn.inheritedTaskContractRef !== null) { + throw new LiveDelegationTaskContractError( + `Live delegation turn ${turn.id} has an incomplete TaskContract projection.` + ) + } + + const contract = buildTaskContract({ + ...input, + delegationId: delegation.id, + turnId: turn.id, + turnSeq: turn.seq, + turnKind: turn.kind, + parentSessionId: delegation.parentSessionId, + slotId: delegation.slotId, + targetAgentId: delegation.targetAgentId, + title: delegation.title, + prompt: turn.prompt + }) + const frozen = this.taskContracts.freezeParentTaskContract({ + parentSessionId: delegation.parentSessionId, + contract, + createdAt: timestamp + }) + const updated = db + .prepare( + `UPDATE live_delegation_turns + SET task_contract_json = ?, task_contract_ref_json = ? + WHERE turn_id = ? AND task_contract_json IS NULL AND task_contract_ref_json IS NULL` + ) + .run(serializeTaskContract(contract), serializeTaskContractRef(frozen.ref), turn.id) + if (updated.changes !== 1) { + throw new LiveDelegationTaskContractError( + `Legacy TaskContract projection changed while freezing turn ${turn.id}.` + ) + } + return { delegation: this.require(delegation.id), turn: this.requireTurn(turn.id) } + })() + } catch (error) { + if (error instanceof LiveDelegationTaskContractError) throw error + throw new LiveDelegationTaskContractError( + `Failed to freeze the compatibility TaskContract for turn ${normalizedTurnId}.`, + { cause: error } + ) + } + } + + ensureInheritedTaskContract( + turnId: string, + childSessionId: string, + now = Date.now() + ): DeepChatTaskContractContext { + const normalizedTurnId = StoredIdSchema.parse(turnId) + const normalizedChildSessionId = StoredIdSchema.parse(childSessionId) + const timestamp = validateTimestamp(now) + const db = this.database.getDatabase() + + try { + return db.transaction(() => { + const turn = this.requireTurn(normalizedTurnId) + const delegation = this.require(turn.delegationId) + if (!isActiveTurnStatus(turn.status)) { + throw new LiveDelegationTaskContractError( + `Live delegation turn ${turn.id} is already terminal.` + ) + } + if (delegation.childSessionId !== normalizedChildSessionId) { + throw new LiveDelegationTaskContractError( + `Live delegation ${delegation.id} is not bound to child Session ${normalizedChildSessionId}.` + ) + } + if (!turn.taskContract || !turn.taskContractRef) { + throw new LiveDelegationTaskContractError( + `Live delegation turn ${turn.id} has no active TaskContract.` + ) + } + const description = turn.taskContract.taskDescription + if ( + description.parentSessionId !== delegation.parentSessionId || + description.delegationId !== delegation.id || + description.slotId !== delegation.slotId || + description.targetAgentId !== delegation.targetAgentId || + description.title !== delegation.title + ) { + throw new LiveDelegationTaskContractError( + `Live delegation turn ${turn.id} has a misbound TaskContract identity.` + ) + } + + const parent = this.taskContracts.ensureParentTaskContract({ + parentSessionId: delegation.parentSessionId, + contract: turn.taskContract, + currentRef: turn.taskContractRef, + createdAt: timestamp + }) + const child = this.taskContracts.ensureChildTaskContract({ + childSessionId: normalizedChildSessionId, + contract: turn.taskContract, + originRef: parent.ref, + currentRef: turn.inheritedTaskContractRef, + createdAt: timestamp + }) + const parentRefJson = serializeTaskContractRef(parent.ref) + const childRefJson = serializeTaskContractRef(child.ref) + const referencesChanged = + parentRefJson !== serializeTaskContractRef(turn.taskContractRef) || + turn.inheritedTaskContractRef === null || + childRefJson !== serializeTaskContractRef(turn.inheritedTaskContractRef) + if (referencesChanged) { + const updated = db + .prepare( + `UPDATE live_delegation_turns + SET task_contract_ref_json = ?, inherited_task_contract_ref_json = ? + WHERE turn_id = ?` + ) + .run(parentRefJson, childRefJson, turn.id) + if (updated.changes !== 1) { + throw new LiveDelegationTaskContractError( + `TaskContract references could not be projected for turn ${turn.id}.` + ) + } + } + + return Object.freeze({ contract: turn.taskContract, localRef: child.ref }) + })() + } catch (error) { + if (error instanceof LiveDelegationTaskContractError) throw error + throw new LiveDelegationTaskContractError( + `Failed to inherit the TaskContract for turn ${normalizedTurnId}.`, + { cause: error } + ) + } + } + + prepareActiveTaskContractContext( + childSessionId: string, + now = Date.now() + ): DeepChatTaskContractContext | null { + const normalizedChildSessionId = StoredIdSchema.parse(childSessionId) + const rows = this.database + .getDatabase() + .prepare( + `SELECT t.turn_id + FROM live_delegation_turns AS t + INNER JOIN live_delegations AS d ON d.delegation_id = t.delegation_id + WHERE d.child_session_id = ? + AND t.status IN ('queued', 'running', 'waiting_permission', 'waiting_question') + ORDER BY t.seq DESC + LIMIT 2` + ) + .all(normalizedChildSessionId) as Array<{ turn_id: string }> + if (rows.length === 0) return null + if (rows.length > 1) { + throw new LiveDelegationTaskContractError( + `Child Session ${normalizedChildSessionId} has multiple active live-delegation turns.` + ) + } + return this.ensureInheritedTaskContract(rows[0]!.turn_id, normalizedChildSessionId, now) + } + createMessage( parentSessionId: string, delegationId: string, @@ -297,6 +565,7 @@ export class LiveDelegationRepository { delegationId: string, turnId: string, task: string, + taskContractInput: LiveDelegationTaskContractInput, now = Date.now(), beforeMutation?: () => void ): LiveDelegationWithTurn { @@ -332,14 +601,45 @@ export class LiveDelegationRepository { ) validateBytes(prompt, LIVE_DELEGATION_MAX_PROMPT_BYTES, 'Follow-up task with messages') const nextSeq = delegation.lastTurnSeq + 1 + const predecessorEvaluationRef = this.listTurns(delegation.id, 1)[0]?.evaluationRef ?? null + const taskContract = buildTaskContract({ + ...taskContractInput, + delegationId: delegation.id, + turnId: normalizedTurnId, + turnSeq: nextSeq, + turnKind: 'follow_up', + parentSessionId: delegation.parentSessionId, + slotId: delegation.slotId, + targetAgentId: delegation.targetAgentId, + title: delegation.title, + prompt, + predecessorEvaluationRef + }) + const taskContractJson = serializeTaskContract(taskContract) beforeMutation?.() return db.transaction(() => { + const frozen = this.taskContracts.freezeParentTaskContract({ + parentSessionId: delegation.parentSessionId, + contract: taskContract, + createdAt: timestamp + }) + const taskContractRefJson = serializeTaskContractRef(frozen.ref) db.prepare( `INSERT INTO live_delegation_turns ( turn_id, delegation_id, seq, kind, prompt, status, result_summary, error, - tape_receipt_json, created_at, started_at, updated_at, completed_at - ) VALUES (?, ?, ?, 'follow_up', ?, 'queued', NULL, NULL, NULL, ?, NULL, ?, NULL)` - ).run(normalizedTurnId, delegation.id, nextSeq, prompt, timestamp, timestamp) + tape_receipt_json, task_contract_json, task_contract_ref_json, + created_at, started_at, updated_at, completed_at + ) VALUES (?, ?, ?, 'follow_up', ?, 'queued', NULL, NULL, NULL, ?, ?, ?, NULL, ?, NULL)` + ).run( + normalizedTurnId, + delegation.id, + nextSeq, + prompt, + taskContractJson, + taskContractRefJson, + timestamp, + timestamp + ) if (messageRows.length > 0) { const placeholders = messageRows.map(() => '?').join(', ') db.prepare( @@ -455,11 +755,34 @@ export class LiveDelegationRepository { error?: string | null resultRef?: LiveDelegationResultRef | null tapeReceipt?: SubagentTapeLinkReceipt | null + candidateResult?: string | null now?: number beforeMutation?: () => void }): LiveDelegationWithTurn { const turn = this.requireTurn(input.turnId) + const candidateResult = input.candidateResult?.trim() || null + const evaluation = turn.taskContract + ? buildTaskEvaluation({ + contract: turn.taskContract, + executionStatus: input.status, + candidateResult + }) + : null if (!ACTIVE_TURN_STATUSES.includes(turn.status as (typeof ACTIVE_TURN_STATUSES)[number])) { + if (turn.taskContract && !turn.evaluation) { + throw new LiveDelegationTaskContractError( + `Terminal contract-bearing turn ${turn.id} has no Task evaluation.` + ) + } + if ( + evaluation && + turn.evaluation && + !matchesTerminalEvaluationRetry(turn.evaluation, evaluation) + ) { + throw new LiveDelegationTaskContractError( + `Terminal evaluation retry conflicts with turn ${turn.id}.` + ) + } return { delegation: this.require(turn.delegationId), turn } } const summary = normalizeOptionalBytes( @@ -503,21 +826,74 @@ export class LiveDelegationRepository { ) { throw new Error('Live delegation result reference exceeds its storage limit.') } + if ( + resultRef && + evaluation && + (evaluation.candidate.kind !== 'answer' || + evaluation.candidate.sha256 !== resultRef.answerSha256 || + evaluation.candidate.utf8Bytes !== resultRef.answerBytes) + ) { + throw new LiveDelegationTaskContractError( + 'Live delegation result reference does not match its Task evaluation candidate.' + ) + } const db = this.database.getDatabase() input.beforeMutation?.() db.transaction(() => { + let taskContractRef = turn.taskContractRef + let evaluationRef: DeepChatEvaluationRef | null = null + if (turn.taskContract) { + if (!taskContractRef || !evaluation) { + throw new LiveDelegationTaskContractError( + `Live delegation turn ${turn.id} has no complete TaskContract evaluation input.` + ) + } + const parent = this.taskContracts.ensureParentTaskContract({ + parentSessionId: delegation.parentSessionId, + contract: turn.taskContract, + currentRef: taskContractRef, + createdAt: now + }) + taskContractRef = parent.ref + evaluationRef = this.taskEvaluations.commitTaskEvaluation({ + parentSessionId: delegation.parentSessionId, + turnSeq: turn.seq, + evaluation, + taskContractRef, + createdAt: now + }).ref + } + const evaluationJson = evaluation ? serializeTaskEvaluation(evaluation) : null + const evaluationRefJson = evaluationRef ? serializeEvaluationRef(evaluationRef) : null const result = db .prepare( `UPDATE live_delegation_turns SET status = ?, result_summary = ?, error = ?, tape_receipt_json = ?, - result_ref_json = ?, + result_ref_json = ?, task_contract_ref_json = ?, evaluation_json = ?, + evaluation_ref_json = ?, updated_at = ?, completed_at = ? WHERE turn_id = ? AND status IN ('queued', 'running', 'waiting_permission', 'waiting_question')` ) - .run(input.status, summary, error, tapeReceiptJson, resultRefJson, now, now, turn.id) - if (result.changes === 0) return + .run( + input.status, + summary, + error, + tapeReceiptJson, + resultRefJson, + taskContractRef ? serializeTaskContractRef(taskContractRef) : null, + evaluationJson, + evaluationRefJson, + now, + now, + turn.id + ) + if (result.changes !== 1) { + throw new LiveDelegationTaskContractError( + `Live delegation turn ${turn.id} changed during terminal settlement.` + ) + } db.prepare( `UPDATE live_delegations SET status = ?, last_summary = ?, last_error = ?, updated_at = ?, revision = revision + 1 @@ -530,11 +906,13 @@ export class LiveDelegationRepository { kind: eventKind, content: eventContent, relatedTurnId: turn.id, + evaluation, + evaluationRef, now }) + this.pruneEvents(delegation.parentSessionId) })() const settledDelegation = this.require(turn.delegationId) - this.pruneEvents(settledDelegation.parentSessionId) return { delegation: settledDelegation, turn: this.requireTurn(turn.id) } } @@ -629,15 +1007,26 @@ export class LiveDelegationRepository { kind: LiveDelegationEventKind content: string relatedTurnId: string | null + evaluation?: DeepChatTaskEvaluation | null + evaluationRef?: DeepChatEvaluationRef | null now: number }): number { + const evaluationJson = input.evaluation ? serializeTaskEvaluation(input.evaluation) : null + const evaluationRefJson = input.evaluationRef + ? serializeEvaluationRef(input.evaluationRef) + : null + if ((evaluationJson === null) !== (evaluationRefJson === null)) { + throw new LiveDelegationTaskContractError( + 'Live delegation mailbox evaluation projection is incomplete.' + ) + } const result = this.database .getDatabase() .prepare( `INSERT INTO live_delegation_events ( delegation_id, parent_session_id, direction, kind, content, related_turn_id, - consumed_by_turn_id, created_at - ) VALUES (?, ?, ?, ?, ?, ?, NULL, ?)` + consumed_by_turn_id, evaluation_json, evaluation_ref_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)` ) .run( input.delegationId, @@ -646,6 +1035,8 @@ export class LiveDelegationRepository { input.kind, input.content, input.relatedTurnId, + evaluationJson, + evaluationRefJson, input.now ) return Number(result.lastInsertRowid) @@ -692,7 +1083,58 @@ function toDelegation(row: LiveDelegationRow): LiveDelegation { } function toTurn(row: LiveDelegationTurnRow): LiveDelegationTurn { - return LiveDelegationTurnSchema.parse({ + const taskContract = parseTaskContract(row.task_contract_json) + const taskContractRef = parseTaskContractRef(row.task_contract_ref_json) + const inheritedTaskContractRef = parseTaskContractRef(row.inherited_task_contract_ref_json) + const evaluation = parseTaskEvaluation(row.evaluation_json) + const evaluationRef = parseEvaluationRef(row.evaluation_ref_json) + if ((taskContract === null) !== (taskContractRef === null)) { + throw new LiveDelegationTaskContractError( + `Live delegation turn ${row.turn_id} has an incomplete TaskContract projection.` + ) + } + if (taskContract && taskContractRef?.contractHash !== taskContract.contractHash) { + throw new LiveDelegationTaskContractError( + `Live delegation turn ${row.turn_id} has a conflicting TaskContract reference.` + ) + } + if (taskContract) { + const description = taskContract.taskDescription + if ( + description.delegationId !== row.delegation_id || + description.turnId !== row.turn_id || + description.turnSeq !== row.seq || + description.turnKind !== row.kind || + description.prompt !== row.prompt || + taskContractRef?.sessionId !== description.parentSessionId || + (inheritedTaskContractRef !== null && + inheritedTaskContractRef.contractHash !== taskContract.contractHash) + ) { + throw new LiveDelegationTaskContractError( + `Live delegation turn ${row.turn_id} has a misbound TaskContract projection.` + ) + } + } + if ((evaluation === null) !== (evaluationRef === null)) { + throw new LiveDelegationTaskContractError( + `Live delegation turn ${row.turn_id} has an incomplete evaluation projection.` + ) + } + if ( + evaluation && + (!taskContract || + evaluation.turnId !== row.turn_id || + evaluation.taskContractHash !== taskContract.contractHash || + evaluation.executionStatus !== row.status || + evaluationRef?.sessionId !== taskContract.taskDescription.parentSessionId || + evaluationRef.tapeIdentity !== taskContractRef?.tapeIdentity || + evaluationRef.evaluationHash !== evaluation.evaluationHash) + ) { + throw new LiveDelegationTaskContractError( + `Live delegation turn ${row.turn_id} has a misbound evaluation projection.` + ) + } + const parsed = LiveDelegationTurnSchema.parse({ id: row.turn_id, delegationId: row.delegation_id, seq: row.seq, @@ -703,6 +1145,11 @@ function toTurn(row: LiveDelegationTurnRow): LiveDelegationTurn { error: row.error, resultRef: parseResultRef(row.result_ref_json), tapeReceipt: parseTapeReceipt(row.tape_receipt_json), + taskContract, + taskContractRef, + inheritedTaskContractRef, + evaluation, + evaluationRef, effectState: row.effect_state, effectEvidence: parseEffectEvidence(row.effect_evidence_json), createdAt: row.created_at, @@ -710,9 +1157,19 @@ function toTurn(row: LiveDelegationTurnRow): LiveDelegationTurn { updatedAt: row.updated_at, completedAt: row.completed_at }) + return { + ...parsed, + taskContract, + taskContractRef, + inheritedTaskContractRef, + evaluation, + evaluationRef + } } function toEvent(row: LiveDelegationEventRow): LiveDelegationEvent { + const evaluation = parseTaskEvaluation(row.evaluation_json) + const evaluationRef = parseEvaluationRef(row.evaluation_ref_json) return LiveDelegationEventSchema.parse({ id: row.event_id, delegationId: row.delegation_id, @@ -722,6 +1179,8 @@ function toEvent(row: LiveDelegationEventRow): LiveDelegationEvent { content: row.content, relatedTurnId: row.related_turn_id, consumedByTurnId: row.consumed_by_turn_id, + evaluation, + evaluationRef, createdAt: row.created_at }) } @@ -837,6 +1296,92 @@ function parseEffectEvidence(value: string | null): OrchestrationEffectEvidence return value ? OrchestrationEffectEvidenceSchema.parse(JSON.parse(value)) : null } +function parseStoredContractProjectionJson(value: string, label: string): unknown { + try { + return JSON.parse(value) + } catch (error) { + throw new LiveDelegationTaskContractError(`Stored live delegation ${label} is malformed.`, { + cause: error + }) + } +} + +function parseTaskContract(value: string | null) { + if (!value) return null + const contract = restoreTaskContract(parseStoredContractProjectionJson(value, 'TaskContract')) + if (!contract) { + throw new LiveDelegationTaskContractError('Stored live delegation TaskContract is malformed.') + } + return contract +} + +function parseTaskContractRef(value: string | null) { + if (!value) return null + const ref = restoreTaskContractRef( + parseStoredContractProjectionJson(value, 'TaskContract reference') + ) + if (!ref) { + throw new LiveDelegationTaskContractError( + 'Stored live delegation TaskContract reference is malformed.' + ) + } + return ref +} + +function parseTaskEvaluation(value: string | null) { + if (!value) return null + const evaluation = restoreStoredTaskEvaluation( + parseStoredContractProjectionJson(value, 'Task evaluation') + ) + if (!evaluation) { + throw new LiveDelegationTaskContractError( + 'Stored live delegation Task evaluation is malformed.' + ) + } + return evaluation +} + +function matchesTerminalEvaluationRetry( + stored: DeepChatStoredTaskEvaluation, + evaluation: DeepChatTaskEvaluation +): boolean { + if (stored.schemaVersion === evaluation.schemaVersion) { + return stored.evaluationHash === evaluation.evaluationHash + } + // Legacy and current schemas hash different content. Preserve the immutable legacy fact only + // when the complete terminal run identity is unchanged. + return ( + stored.turnId === evaluation.turnId && + stored.taskContractHash === evaluation.taskContractHash && + stored.executionStatus === evaluation.executionStatus && + candidatesMatch(stored.candidate, evaluation.candidate) + ) +} + +function candidatesMatch( + left: DeepChatStoredTaskEvaluation['candidate'], + right: DeepChatTaskEvaluation['candidate'] +): boolean { + if (left.kind !== right.kind) return false + return ( + left.kind === 'absent' || + (right.kind === 'answer' && left.sha256 === right.sha256 && left.utf8Bytes === right.utf8Bytes) + ) +} + +function parseEvaluationRef(value: string | null) { + if (!value) return null + const ref = restoreEvaluationRef( + parseStoredContractProjectionJson(value, 'Task evaluation reference') + ) + if (!ref) { + throw new LiveDelegationTaskContractError( + 'Stored live delegation Task evaluation reference is malformed.' + ) + } + return ref +} + function isActiveTurnStatus(status: LiveDelegationTurnStatus): boolean { return ACTIVE_TURN_STATUSES.includes(status as (typeof ACTIVE_TURN_STATUSES)[number]) } diff --git a/src/main/orchestration/liveDelegationService.ts b/src/main/orchestration/liveDelegationService.ts index c541695cb..d5eaf7610 100644 --- a/src/main/orchestration/liveDelegationService.ts +++ b/src/main/orchestration/liveDelegationService.ts @@ -38,12 +38,19 @@ import type { PermissionMode, SubagentTapeLinkReceipt } from '@shared/types/agent-interface' +import type { DeepChatTaskContractContext } from '@shared/types/task-contract' +import { projectTaskEvaluationSummary } from '@/tape/domain/taskEvaluation' import type { SessionRuntimeUpdate } from '@/session/runtimeEvents' import type { SessionDeletionGatePort } from '@/session/deletionGate' import { classifyToolEffect } from '@/tool/effectClassification' import type { ToolEffectObservation } from '@/tool/effectObserver' import { resolveToolPermissionMode } from '@/tool/permission/permissionMode' -import type { ActiveLiveDelegationTurn, LiveDelegationRepository } from './liveDelegationRepository' +import { + LiveDelegationTaskContractError, + type ActiveLiveDelegationTurn, + type ActiveLiveDelegationTurnIdentity, + type LiveDelegationRepository +} from './liveDelegationRepository' import type { LiveDelegationSafetyPort, LiveDelegationTurnExecutionSnapshot @@ -53,13 +60,22 @@ import type { LiveDelegationConsentReceipt, LiveDelegationConsentVerifier } from './liveDelegationConsent' +import { + createLegacyLiveDelegationTaskContractInput, + createLiveDelegationTaskContractInput, + LIVE_DELEGATION_REQUIRED_HANDOFF_SECTIONS +} from './liveDelegationTaskContract' +import { extractMarkdownLevelTwoSection } from '@shared/orchestration/liveDelegationMarkdown' const MAX_WAITERS = 32 const DEFAULT_WAIT_TIMEOUT_MS = 30_000 const MAX_WAIT_TIMEOUT_MS = 60_000 const MAX_MODEL_PREVIEW_BYTES = 2 * 1024 const MAX_MODEL_EVENT_BYTES = 16 * 1024 -const MAX_MODEL_WAIT_BYTES = 32 * 1024 +const MAX_MODEL_WAIT_CONTENT_BYTES = 32 * 1024 +const MAX_MODEL_WAIT_RESPONSE_BYTES = 64 * 1024 +const MAX_MODEL_EVENT_EVALUATION_EVIDENCE = 2 +const MAX_MODEL_TURN_EVALUATION_EVIDENCE = 4 const LIVE_DELEGATION_OWNER_LIMIT = 5 const HANDOFF_TRUNCATION_NOTICE = '[Handoff truncated. Use deepchat_subagents read_result for the complete child answer.]' @@ -160,6 +176,8 @@ type CapableParent = ConversationSessionInfo & { export class LiveDelegationService { private readonly activeTurns = new Map() private readonly childToTurn = new Map() + private readonly quarantinedTurns = new Set() + private readonly quarantineCancellations = new Set>() private readonly childSafetyTails = new Map>() private readonly waiters = new Set() private unsubscribeRuntime: (() => void) | null = null @@ -170,10 +188,12 @@ export class LiveDelegationService { start(): void { if (this.started) return - const activeRecords = this.options.repository.listActiveTurns() + const activeRecords = this.options.repository.listActiveTurnIdentities() + this.childToTurn.clear() + this.quarantinedTurns.clear() for (const record of activeRecords) { - if (record.delegation.childSessionId) { - this.childToTurn.set(record.delegation.childSessionId, record.turn.id) + if (record.childSessionId) { + this.childToTurn.set(record.childSessionId, record.turnId) } } this.started = true @@ -189,6 +209,24 @@ export class LiveDelegationService { }) } + prepareTaskContractContext(childSessionId: string): DeepChatTaskContractContext | null { + const admittedTurnId = this.childToTurn.get(childSessionId) + if (admittedTurnId && this.quarantinedTurns.has(admittedTurnId)) { + throw new LiveDelegationTaskContractError( + `Child Session ${childSessionId} is quarantined after TaskContract reconciliation failed.` + ) + } + const context = this.options.repository.prepareActiveTaskContractContext(childSessionId) + if (context === null) { + if (admittedTurnId === undefined) return null + } else if (admittedTurnId === context.contract.taskDescription.turnId) { + return context + } + throw new LiveDelegationTaskContractError( + `Child Session ${childSessionId} has no admitted live-delegation runtime matching its TaskContract.` + ) + } + async stop(): Promise { if (!this.started) return this.started = false @@ -209,7 +247,11 @@ export class LiveDelegationService { const cancellationWork = active .filter((turn) => turn.childSessionId) .map((turn) => this.cancelActiveChild(turn, 'service stop')) - await Promise.allSettled([...cancellationWork, ...pendingChildWork]) + await Promise.allSettled([ + ...cancellationWork, + ...pendingChildWork, + ...this.quarantineCancellations + ]) await Promise.allSettled( active.map((turn) => this.settle(turn, { @@ -219,7 +261,9 @@ export class LiveDelegationService { ) ) this.activeTurns.clear() - this.childToTurn.clear() + for (const [childSessionId, turnId] of this.childToTurn) { + if (!this.quarantinedTurns.has(turnId)) this.childToTurn.delete(childSessionId) + } this.childSafetyTails.clear() for (const waiter of this.waiters) waiter.resolve() this.waiters.clear() @@ -256,6 +300,7 @@ export class LiveDelegationService { const delegationId = nanoid() const turnId = nanoid() const executionSnapshot = createTurnExecutionSnapshot(parent) + const projectDir = await this.resolveParentProjectDir(parent) const created = this.runAuthorizedStartMutation(parent, 'spawn', authorization, () => this.options.repository.create( { @@ -265,7 +310,8 @@ export class LiveDelegationService { slotId: slot.id, targetAgentId, title: input.title, - prompt: input.prompt + prompt: input.prompt, + taskContract: createLiveDelegationTaskContractInput(projectDir) }, beforeMutation ) @@ -335,7 +381,7 @@ export class LiveDelegationService { return await this.options.deletionGate.runWithSessionOperation( discoveredChild.sessionId, async () => { - await this.resolveCurrentSafety(delegation) + const currentSafety = await this.resolveCurrentSafety(delegation) const child = await this.options.sessions.resolveConversationSessionInfo( discoveredChild.sessionId ) @@ -350,17 +396,17 @@ export class LiveDelegationService { `Cannot continue delegation ${delegation.id} while child session is ${child.status}.` ) } - const currentParent = await this.requireCapableParent(parent.sessionId) const created = this.runAuthorizedStartMutation( - currentParent, + currentSafety.parent, 'follow_up', authorization, () => this.options.repository.createFollowUp( - currentParent.sessionId, + currentSafety.parent.sessionId, delegation.id, nanoid(), task, + createLiveDelegationTaskContractInput(currentSafety.projectDir), undefined, beforeMutation ) @@ -484,6 +530,10 @@ export class LiveDelegationService { answerSha256, answerBytes, answerEstimatedTokens: resultRef.answerEstimatedTokens, + evaluation: + turn.evaluation && turn.evaluationRef + ? projectTaskEvaluationSummary(turn.evaluation, turn.evaluationRef) + : null, text: page.text, nextCursor: page.nextOffset === null @@ -510,12 +560,12 @@ export class LiveDelegationService { if (!this.started) return const delegationIds = new Set() - for (const record of this.options.repository.listActiveTurns()) { + for (const record of this.options.repository.listActiveTurnIdentities()) { if ( - record.delegation.parentSessionId === normalizedSessionId || - record.delegation.childSessionId === normalizedSessionId + record.parentSessionId === normalizedSessionId || + record.childSessionId === normalizedSessionId ) { - delegationIds.add(record.delegation.id) + delegationIds.add(record.delegationId) } } for (const active of this.activeTurns.values()) { @@ -737,6 +787,7 @@ export class LiveDelegationService { }) this.childToTurn.delete(childSessionId) } + this.quarantinedTurns.delete(turn.id) } return this.inspect(delegation.parentSessionId, delegation.id) } @@ -775,6 +826,10 @@ export class LiveDelegationService { await active.admissionLease.resume() await task() } catch (error) { + if (error instanceof LiveDelegationTaskContractError) { + this.parkTaskContractBoundary(active, error) + return + } if ( active.admissionLease.state === 'suspended' && !active.controller.signal.aborted && @@ -789,6 +844,18 @@ export class LiveDelegationService { } } + private parkTaskContractBoundary(active: ActiveTurn, error: unknown): void { + console.error('[LiveDelegationService] TaskContract boundary remains recoverable:', { + delegationId: active.delegationId, + turnId: active.turnId, + error + }) + active.admissionLease.release() + if (active.childSessionId) this.childToTurn.delete(active.childSessionId) + this.activeTurns.delete(active.turnId) + active.completion.resolve() + } + private createActiveTurn(delegation: LiveDelegation, turn: LiveDelegationTurn): ActiveTurn { const existing = this.activeTurns.get(turn.id) if (existing) return existing @@ -840,6 +907,7 @@ export class LiveDelegationService { bound, turn.kind === 'follow_up' ? executionSnapshot : null ) + this.options.repository.ensureInheritedTaskContract(turn.id, child.sessionId) this.childToTurn.set(child.sessionId, active.turnId) active.controller.signal.throwIfAborted() @@ -1099,12 +1167,22 @@ export class LiveDelegationService { ): Promise { let turnId = this.childToTurn.get(childSessionId) if (!turnId) return null + if (this.quarantinedTurns.has(turnId)) { + throw new LiveDelegationTaskContractError( + `Child Session ${childSessionId} is quarantined after TaskContract reconciliation failed.` + ) + } let active = this.activeTurns.get(turnId) if (!active && this.reconcilePromise) { await awaitWithAbort(this.reconcilePromise, signal) turnId = this.childToTurn.get(childSessionId) active = turnId ? this.activeTurns.get(turnId) : undefined } + if (turnId && this.quarantinedTurns.has(turnId)) { + throw new LiveDelegationTaskContractError( + `Child Session ${childSessionId} is quarantined after TaskContract reconciliation failed.` + ) + } if (!active || active.settling) return null signal?.throwIfAborted() active.controller.signal.throwIfAborted() @@ -1171,7 +1249,8 @@ export class LiveDelegationService { summary: summary || null, error, resultRef, - tapeReceipt + tapeReceipt, + candidateResult: persistedResult?.answerMarkdown.trim() || null }) this.publishChanged(settled.delegation) this.notifyMailbox(settled.delegation.parentSessionId, settled.delegation.id) @@ -1183,6 +1262,17 @@ export class LiveDelegationService { }) try { const turn = this.options.repository.getTurn(active.turnId) + if (turn?.taskContract) { + console.error( + '[LiveDelegationService] Contract-bearing settlement remains recoverable:', + { + delegationId: active.delegationId, + turnId: active.turnId, + error + } + ) + return + } if (turn && isActiveTurnStatus(turn.status)) { const settled = this.options.repository.finishTurn({ turnId: active.turnId, @@ -1273,11 +1363,14 @@ export class LiveDelegationService { } } - private async reconcileActiveTurns(records: ActiveLiveDelegationTurn[]): Promise { + private async reconcileActiveTurns(records: ActiveLiveDelegationTurnIdentity[]): Promise { for (const record of records) { if (!this.started) return try { - await this.reconcileActiveTurn(record) + await this.reconcileActiveTurn({ + delegation: this.options.repository.require(record.delegationId), + turn: this.options.repository.requireTurn(record.turnId) + }) } catch (error) { this.failReconciliation(record, error) } @@ -1292,7 +1385,7 @@ export class LiveDelegationService { record.delegation.id ) if (!this.started) return - const turn = this.options.repository.getTurn(record.turn.id) + let turn = this.options.repository.getTurn(record.turn.id) if (!turn || !isActiveTurnStatus(turn.status)) { if (record.delegation.childSessionId) { this.childToTurn.delete(record.delegation.childSessionId) @@ -1315,6 +1408,20 @@ export class LiveDelegationService { return } + if (child.status === 'generating' || turn.startedAt !== null) { + if (!turn.taskContract) { + const projectDir = await this.options.sessions.resolveConversationWorkdir( + delegation.parentSessionId + ) + if (!this.started) return + turn = this.options.repository.freezeLegacyTaskContract( + turn.id, + createLegacyLiveDelegationTaskContractInput(projectDir) + ).turn + } + this.options.repository.ensureInheritedTaskContract(turn.id, child.sessionId) + } + const active = this.createActiveTurn(delegation, turn) active.childSessionId = child.sessionId active.started = turn.startedAt !== null || child.status === 'generating' @@ -1329,6 +1436,11 @@ export class LiveDelegationService { return } if (turn.startedAt === null) { + if (turn.taskContract) { + active.runtimeStatus = null + this.scheduleTurn(delegation, turn, createTurnExecutionSnapshot(child)) + return + } const settled = this.options.repository.finishTurn({ turnId: turn.id, status: 'interrupted', @@ -1347,18 +1459,54 @@ export class LiveDelegationService { }) } - private failReconciliation(record: ActiveLiveDelegationTurn, error: unknown): void { + private failReconciliation(record: ActiveLiveDelegationTurnIdentity, error: unknown): void { console.error('[LiveDelegationService] Failed to reconcile child turn:', { - delegationId: record.delegation.id, - turnId: record.turn.id, + delegationId: record.delegationId, + turnId: record.turnId, error }) if (!this.started) return + if (error instanceof LiveDelegationTaskContractError) { + let turnId = record.turnId + let childSessionId = record.childSessionId + try { + const current = this.options.repository.getTurn(record.turnId) + if (!current || !isActiveTurnStatus(current.status)) { + if (childSessionId) this.childToTurn.delete(childSessionId) + return + } + turnId = current.id + childSessionId = + this.options.repository.get(record.delegationId)?.childSessionId ?? childSessionId + } catch (lookupError) { + console.error('[LiveDelegationService] Failed to resolve reconciliation quarantine:', { + delegationId: record.delegationId, + turnId, + error: lookupError + }) + } + this.quarantinedTurns.add(turnId) + if (childSessionId) { + this.childToTurn.set(childSessionId, turnId) + const cancellation = this.options.sessions + .cancelConversation(childSessionId) + .catch((cancelError) => { + console.warn('[LiveDelegationService] Failed to cancel quarantined child session:', { + childSessionId, + turnId, + error: cancelError + }) + }) + .finally(() => this.quarantineCancellations.delete(cancellation)) + this.quarantineCancellations.add(cancellation) + } + return + } try { - const current = this.options.repository.getTurn(record.turn.id) + const current = this.options.repository.getTurn(record.turnId) if (!current || !isActiveTurnStatus(current.status)) { - if (record.delegation.childSessionId) { - this.childToTurn.delete(record.delegation.childSessionId) + if (record.childSessionId) { + this.childToTurn.delete(record.childSessionId) } return } @@ -1370,15 +1518,15 @@ export class LiveDelegationService { LIVE_DELEGATION_MAX_HANDOFF_BYTES ) }) - if (record.delegation.childSessionId) { - this.childToTurn.delete(record.delegation.childSessionId) + if (record.childSessionId) { + this.childToTurn.delete(record.childSessionId) } this.publishChanged(settled.delegation) this.notifyMailbox(settled.delegation.parentSessionId, settled.delegation.id) } catch (settleError) { console.error('[LiveDelegationService] Failed to persist reconciliation error:', { - delegationId: record.delegation.id, - turnId: record.turn.id, + delegationId: record.delegationId, + turnId: record.turnId, error: settleError }) } @@ -1398,8 +1546,8 @@ export class LiveDelegationService { throw new Error(`Subagent slot target changed for delegation ${delegation.id}.`) } const projectDir = - (await this.options.sessions.resolveConversationWorkdir(parent.sessionId))?.trim() || - parent.projectDir?.trim() || + (await this.options.sessions.resolveConversationWorkdir(parent.sessionId)) || + parent.projectDir || null return { parent, projectDir } } @@ -1477,6 +1625,11 @@ export class LiveDelegationService { return parent as CapableParent } + private async resolveParentProjectDir(parent: CapableParent): Promise { + const runtimeWorkdir = await this.options.sessions.resolveConversationWorkdir(parent.sessionId) + return runtimeWorkdir || parent.projectDir || null + } + private publishChanged(delegation: LiveDelegation): void { try { this.options.onChanged?.(delegation.parentSessionId, delegation.id) @@ -1502,6 +1655,7 @@ export class LiveDelegationService { } function buildTurnHandoff(delegation: LiveDelegation, turn: LiveDelegationTurn): string { + const [handoffSection, ...remainingSections] = LIVE_DELEGATION_REQUIRED_HANDOFF_SECTIONS return [ '# DeepChat Live Delegation', '', @@ -1512,14 +1666,10 @@ function buildTurnHandoff(delegation: LiveDelegation, turn: LiveDelegationTurn): turn.prompt, '', 'Return markdown with these sections in this order:', - '## Handoff', + `## ${handoffSection}`, 'A self-contained conclusion for the parent Agent, limited to about 2,000 tokens. Include the', 'decision, critical evidence, changed files, validation, and unresolved risks needed next.', - '## Result', - '## Evidence', - '## Changed Files', - '## Validation', - '## Unresolved', + ...remainingSections.map((section) => `## ${section}`), 'Use `None` when a section has no entries.', '', 'Rules:', @@ -1585,8 +1735,10 @@ function buildResultHandoff( truncated: boolean } { const normalized = sanitizeDelegationText(answer.trim()) - const handoffSection = extractMarkdownSection(normalized, 'Handoff') - const resultSection = handoffSection ? null : extractMarkdownSection(normalized, 'Result') + const handoff = extractMarkdownLevelTwoSection(normalized, 'Handoff') + const handoffSection = handoff?.body ? handoff.markdown : null + const result = handoffSection ? null : extractMarkdownLevelTwoSection(normalized, 'Result') + const resultSection = handoffSection ? null : result?.body ? result.markdown : null const source: LiveDelegationResultRef['handoffSource'] = handoffSection ? 'handoff_section' : resultSection @@ -1602,52 +1754,6 @@ function buildResultHandoff( return { text: bounded.text, source, truncated: bounded.truncated } } -function extractMarkdownSection(markdown: string, title: string): string | null { - const lines = markdown.replace(/\r\n/g, '\n').split('\n') - const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&') - const target = new RegExp(`^ {0,3}##\\s+${escapedTitle}\\s*#*\\s*$`, 'iu') - const nextSection = /^ {0,3}#{1,2}\s+\S/u - const fencePattern = /^ {0,3}(`{3,}|~{3,})/u - let fence: { marker: '`' | '~'; length: number } | null = null - let start = -1 - let end = lines.length - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index] ?? '' - if (fence) { - if (isClosingFence(line, fence)) fence = null - continue - } - const fenceMatch = line.match(fencePattern)?.[1] - if (fenceMatch) { - const marker = fenceMatch[0] as '`' | '~' - fence = { marker, length: fenceMatch.length } - continue - } - if (start < 0) { - if (target.test(line)) start = index - continue - } - if (nextSection.test(line)) { - end = index - break - } - } - if (start < 0) return null - const body = lines - .slice(start + 1, end) - .join('\n') - .trim() - return body ? lines.slice(start, end).join('\n').trim() : null -} - -function isClosingFence(line: string, fence: { marker: '`' | '~'; length: number }): boolean { - const candidate = line.replace(/^ {0,3}/u, '').trimEnd() - return ( - candidate.length >= fence.length && - [...candidate].every((character) => character === fence.marker) - ) -} - function truncateToBudgets( value: string, maxBytes: number, @@ -1814,11 +1920,24 @@ function createWaitResult( ): LiveDelegationWaitResult { const maxBytesPerEvent = Math.min( MAX_MODEL_EVENT_BYTES, - Math.max(1, Math.floor(MAX_MODEL_WAIT_BYTES / Math.max(1, events.length))) + Math.max(1, Math.floor(MAX_MODEL_WAIT_CONTENT_BYTES / Math.max(1, events.length))) ) + const projected: LiveDelegationEventSummary[] = [] + for (const event of events) { + const candidate = projectEventSummary(event, maxBytesPerEvent) + const next = [...projected, candidate] + if ( + projected.length > 0 && + Buffer.byteLength(JSON.stringify({ events: next, cursor: candidate.id, timedOut }), 'utf8') > + MAX_MODEL_WAIT_RESPONSE_BYTES + ) { + break + } + projected.push(candidate) + } return { - events: events.map((event) => projectEventSummary(event, maxBytesPerEvent)), - cursor: events.at(-1)?.id ?? priorCursor, + events: projected, + cursor: projected.at(-1)?.id ?? priorCursor, timedOut } } @@ -1827,11 +1946,19 @@ function projectEventSummary( event: LiveDelegationEvent, maxBytes: number ): LiveDelegationEventSummary { - const { content, ...identity } = event + const { content, evaluation, evaluationRef, ...identity } = event return { ...identity, contentPreview: truncateUtf8(content, maxBytes), - contentTruncated: Buffer.byteLength(content, 'utf8') > maxBytes + contentTruncated: Buffer.byteLength(content, 'utf8') > maxBytes, + evaluation: + evaluation && evaluationRef + ? projectTaskEvaluationSummary( + evaluation, + evaluationRef, + MAX_MODEL_EVENT_EVALUATION_EVIDENCE + ) + : null } } @@ -1845,12 +1972,30 @@ function projectDelegationSummary(delegation: LiveDelegation): LiveDelegationSum } function projectTurnSummary(turn: LiveDelegationTurn): LiveDelegationTurnSummary { - const { prompt, resultSummary, error, ...identity } = turn + const { + prompt, + resultSummary, + error, + taskContract: _taskContract, + taskContractRef: _taskContractRef, + inheritedTaskContractRef: _inheritedTaskContractRef, + evaluation, + evaluationRef, + ...identity + } = turn return { ...identity, promptPreview: truncateUtf8(prompt, MAX_MODEL_PREVIEW_BYTES), resultPreview: resultSummary ? truncateUtf8(resultSummary, MAX_MODEL_PREVIEW_BYTES) : null, - errorPreview: error ? truncateUtf8(error, MAX_MODEL_PREVIEW_BYTES) : null + errorPreview: error ? truncateUtf8(error, MAX_MODEL_PREVIEW_BYTES) : null, + evaluation: + evaluation && evaluationRef + ? projectTaskEvaluationSummary( + evaluation, + evaluationRef, + MAX_MODEL_TURN_EVALUATION_EVIDENCE + ) + : null } } diff --git a/src/main/orchestration/liveDelegationTaskContract.ts b/src/main/orchestration/liveDelegationTaskContract.ts new file mode 100644 index 000000000..db22c6d4b --- /dev/null +++ b/src/main/orchestration/liveDelegationTaskContract.ts @@ -0,0 +1,56 @@ +import type { + DeepChatEvaluationRef, + DeepChatHandoffFormatRequirement, + DeepChatTaskWorkspaceCeiling +} from '@shared/types/task-contract' + +export const LIVE_DELEGATION_REQUIRED_HANDOFF_SECTIONS = [ + 'Handoff', + 'Result', + 'Evidence', + 'Changed Files', + 'Validation', + 'Unresolved' +] as const + +export interface LiveDelegationTaskContractInput { + workspace: DeepChatTaskWorkspaceCeiling + handoffFormat: readonly DeepChatHandoffFormatRequirement[] + predecessorEvaluationRef: DeepChatEvaluationRef | null + maxToolEffect: 'read' | 'write' + maxSubagentDepth: number +} + +export type LegacyLiveDelegationTaskContractInput = LiveDelegationTaskContractInput & { + creationReason: 'legacy_recovery' +} + +export function createLiveDelegationTaskContractInput( + projectDir: string | null, + predecessorEvaluationRef: DeepChatEvaluationRef | null = null +): LiveDelegationTaskContractInput { + return { + workspace: projectDir ? { kind: 'path', path: projectDir } : { kind: 'runtime_default' }, + handoffFormat: [ + { + id: 'live-delegation-required-sections', + kind: 'required_sections', + level: 2, + sections: LIVE_DELEGATION_REQUIRED_HANDOFF_SECTIONS + } + ], + predecessorEvaluationRef, + maxToolEffect: 'write', + maxSubagentDepth: 0 + } +} + +export function createLegacyLiveDelegationTaskContractInput( + projectDir: string | null +): LegacyLiveDelegationTaskContractInput { + return { + ...createLiveDelegationTaskContractInput(projectDir), + handoffFormat: [], + creationReason: 'legacy_recovery' + } +} diff --git a/src/main/session/data/database.ts b/src/main/session/data/database.ts index c9f30a7f1..10f3936e8 100644 --- a/src/main/session/data/database.ts +++ b/src/main/session/data/database.ts @@ -16,10 +16,15 @@ import { DeepChatSearchDocumentsTable } from './tables/deepchatSearchDocuments' import { DeepChatPendingInputsTable } from './tables/deepchatPendingInputs' import { DeepChatUsageStatsTable } from './tables/deepchatUsageStats' import { + DeepChatContractStore, DeepChatExecutionJournalStore, DeepChatTapeEntriesTable } from '@/tape/infrastructure/sqlite/tapeEntryStore' -import type { ExecutionJournalPersistenceStore, TapeMutationProjection } from '@/tape/ports/storage' +import type { + ContractPersistenceStore, + ExecutionJournalPersistenceStore, + TapeMutationProjection +} from '@/tape/ports/storage' import { SqliteTapeLifecycleAdapter } from '@/tape/infrastructure/sqlite/tapeLifecycleAdapter' import { DeepChatTapeSearchProjectionTable } from '@/tape/infrastructure/sqlite/tapeSearchProjectionStore' import { DeepChatSessionMetadataTable } from './tables/deepchatSessionMetadata' @@ -104,6 +109,10 @@ export class SessionDatabase { return new DeepChatExecutionJournalStore(this.getDatabase(), this.getTapeMutationProjection?.()) } + get deepchatContractStore(): ContractPersistenceStore { + return new DeepChatContractStore(this.getDatabase(), this.getTapeMutationProjection?.()) + } + get tapeLifecycle() { return new SqliteTapeLifecycleAdapter(this.getDatabase(), this.getTapeMutationProjection?.()) } diff --git a/src/main/tape/application/forkService.ts b/src/main/tape/application/forkService.ts index de358b7dc..95dcf8f71 100644 --- a/src/main/tape/application/forkService.ts +++ b/src/main/tape/application/forkService.ts @@ -1,6 +1,7 @@ import { nanoid } from 'nanoid' import logger from 'electron-log' import type { DeepChatTapeEntryRow } from '../domain/entry' +import { isContractTapeReservedName } from '../domain/contractFacts' import { isExecutionJournalReservedName } from '../domain/executionJournal' import type { TapeApplicationProviders } from '../ports/application' import { deleteTapeGeneration } from './generationLifecycle' @@ -201,6 +202,7 @@ export class TapeForkService { .filter( (entry) => !isExecutionJournalReservedName(entry.name) && + !isContractTapeReservedName(entry.name) && !( entry.kind === 'anchor' && (entry.name === 'session/start' || entry.name === 'fork/start') diff --git a/src/main/tape/application/lineageService.ts b/src/main/tape/application/lineageService.ts index 42aba422c..567fa2801 100644 --- a/src/main/tape/application/lineageService.ts +++ b/src/main/tape/application/lineageService.ts @@ -11,6 +11,7 @@ import { } from '../domain/entry' import type { TapeApplicationProviders } from '../ports/application' import { parseJsonObject, parseJsonValue } from './common' +import { computeTapeIdentity, TAPE_IDENTITY_PATTERN } from '../domain/tapeIdentity' type TapeLineageProviders = Pick< TapeApplicationProviders, @@ -27,8 +28,6 @@ const SUBAGENT_TAPE_LINK_EVENT_NAME = 'subagent/tape_linked' const SUBAGENT_TAPE_LINK_VERSION = 2 -const TAPE_IDENTITY_PATTERN = /^[a-f0-9]{64}$/ - const SUBAGENT_TAPE_LINK_OUTCOMES = new Set([ 'completed', 'error', @@ -110,26 +109,6 @@ function isUnmarkedLegacyTape(row: DeepChatTapeEntryRow): boolean { ) } -function computeTapeIdentity(row: DeepChatTapeEntryRow): string { - return createHash('sha256') - .update( - JSON.stringify([ - row.session_id, - row.entry_id, - row.kind, - row.name, - row.source_type, - row.source_id, - row.source_seq, - row.provenance_key, - row.payload_json, - row.meta_json, - row.created_at - ]) - ) - .digest('hex') -} - function subagentTapeLinkProvenanceKey(input: SubagentTapeLinkInput): string { // This version belongs to the stable task-identity key, independently of the evolving event // payload's linkVersion. diff --git a/src/main/tape/application/taskContractService.ts b/src/main/tape/application/taskContractService.ts new file mode 100644 index 000000000..23f4bfe1f --- /dev/null +++ b/src/main/tape/application/taskContractService.ts @@ -0,0 +1,388 @@ +import { + DEEPCHAT_TASK_CONTRACT_REF_SCHEMA_VERSION, + type DeepChatTaskContract, + type DeepChatTaskContractRef +} from '@shared/types/task-contract' +import { + isDeepChatTaskContract, + isDeepChatTaskContractRef, + serializeTaskContractRef +} from '../domain/taskContract' +import { canonicalJsonStringifyData } from '../domain/canonicalJson' +import { computeTapeIdentity } from '../domain/tapeIdentity' +import type { DeepChatTapeEntryRow, TapeEventAppendInput } from '../domain/entry' +import type { ContractPersistenceStore } from '../ports/storage' + +const TASK_CONTRACT_FACT_SCHEMA_VERSION = 1 as const +const TASK_CONTRACT_FACT_NAME = 'contract/task_frozen' as const +const TASK_CONTRACT_FACT_PROTOCOL_VERSION = 1 as const + +type TaskContractFactDelivery = 'parent_frozen' | 'child_inherited' | 'projection_recovery' + +type TaskContractFactData = { + schemaVersion: typeof TASK_CONTRACT_FACT_SCHEMA_VERSION + delivery: TaskContractFactDelivery + contract: DeepChatTaskContract + originRef: DeepChatTaskContractRef | null + supersedesRef: DeepChatTaskContractRef | null +} + +type StrictTaskContractEventInput = Omit & { + name: typeof TASK_CONTRACT_FACT_NAME + source: NonNullable + provenanceKey: string + data: TaskContractFactData +} + +export interface FreezeParentTaskContractInput { + parentSessionId: string + contract: DeepChatTaskContract + createdAt?: number +} + +export interface EnsureParentTaskContractInput extends FreezeParentTaskContractInput { + currentRef: DeepChatTaskContractRef +} + +export interface EnsureChildTaskContractInput { + childSessionId: string + contract: DeepChatTaskContract + originRef: DeepChatTaskContractRef + currentRef: DeepChatTaskContractRef | null + createdAt?: number +} + +export interface TaskContractCommitReceipt { + contract: DeepChatTaskContract + ref: DeepChatTaskContractRef + created: boolean +} + +export interface ParentTaskContractWriter { + freezeParentTaskContract(input: FreezeParentTaskContractInput): TaskContractCommitReceipt +} + +export interface TaskContractWriter extends ParentTaskContractWriter { + ensureParentTaskContract(input: EnsureParentTaskContractInput): TaskContractCommitReceipt + ensureChildTaskContract(input: EnsureChildTaskContractInput): TaskContractCommitReceipt +} + +export class TaskContractPersistenceError extends Error { + constructor( + message: string, + readonly code: + | 'invalid_contract' + | 'transaction_required' + | 'corruption' + | 'persistence_failed', + options?: ErrorOptions + ) { + super(message, options) + this.name = 'TaskContractPersistenceError' + } +} + +function canonicalJsonEquals(raw: string, expected: unknown): boolean { + try { + return canonicalJsonStringifyData(JSON.parse(raw)) === canonicalJsonStringifyData(expected) + } catch { + return false + } +} + +function rowMatchesTaskContractFact( + row: DeepChatTapeEntryRow, + input: StrictTaskContractEventInput +): boolean { + return ( + row.session_id === input.sessionId && + row.kind === 'event' && + row.name === input.name && + row.source_type === input.source.type && + row.source_id === input.source.id && + row.source_seq === (input.source.seq ?? null) && + row.provenance_key === input.provenanceKey && + canonicalJsonEquals(row.payload_json, { name: input.name, data: input.data }) && + canonicalJsonEquals(row.meta_json, input.meta ?? {}) + ) +} + +function rowContainsReferencedTaskContractFact( + row: DeepChatTapeEntryRow, + input: CommitTaskContractInput, + provenanceKey: string +): boolean { + if ( + row.session_id !== input.targetSessionId || + row.kind !== 'event' || + row.name !== TASK_CONTRACT_FACT_NAME || + row.source_type !== 'subagent' || + row.source_id !== input.contract.taskDescription.turnId || + row.source_seq !== input.contract.taskDescription.turnSeq || + row.provenance_key !== provenanceKey || + !canonicalJsonEquals(row.meta_json, { protocolVersion: TASK_CONTRACT_FACT_PROTOCOL_VERSION }) + ) { + return false + } + + try { + const payload = JSON.parse(row.payload_json) as { + name?: unknown + data?: Record + } + const data = payload.data + if ( + payload.name !== TASK_CONTRACT_FACT_NAME || + !data || + Object.keys(data).length !== 5 || + data.schemaVersion !== TASK_CONTRACT_FACT_SCHEMA_VERSION || + canonicalJsonStringifyData(data.contract) !== canonicalJsonStringifyData(input.contract) + ) { + return false + } + + const delivery = data.delivery + const originRef = data.originRef + const supersedesRef = data.supersedesRef + if (input.role === 'parent') { + if (delivery !== 'parent_frozen' && delivery !== 'projection_recovery') return false + if (originRef !== null) return false + } else { + if (delivery !== 'child_inherited' && delivery !== 'projection_recovery') return false + if ( + !isDeepChatTaskContractRef(originRef) || + originRef.sessionId !== input.contract.taskDescription.parentSessionId || + originRef.contractHash !== input.contract.contractHash + ) { + return false + } + } + + if (delivery === 'projection_recovery') { + return ( + isDeepChatTaskContractRef(supersedesRef) && + supersedesRef.sessionId === input.targetSessionId && + supersedesRef.contractHash === input.contract.contractHash && + supersedesRef.tapeIdentity !== input.currentRef?.tapeIdentity + ) + } + return supersedesRef === null + } catch { + return false + } +} + +function buildTaskContractRef( + row: DeepChatTapeEntryRow, + tapeIdentity: string, + contractHash: string +): DeepChatTaskContractRef { + const ref: DeepChatTaskContractRef = { + schemaVersion: DEEPCHAT_TASK_CONTRACT_REF_SCHEMA_VERSION, + sessionId: row.session_id, + tapeIdentity, + entryId: row.entry_id, + contractHash + } + serializeTaskContractRef(ref) + return Object.freeze(ref) +} + +type CommitTaskContractInput = { + targetSessionId: string + role: 'parent' | 'child' + contract: DeepChatTaskContract + originRef: DeepChatTaskContractRef | null + currentRef: DeepChatTaskContractRef | null + createdAt?: number +} + +export class TaskContractService implements TaskContractWriter { + constructor(private readonly getStore: () => ContractPersistenceStore) {} + + freezeParentTaskContract(input: FreezeParentTaskContractInput): TaskContractCommitReceipt { + return this.commitTaskContract({ + targetSessionId: input.parentSessionId, + role: 'parent', + contract: input.contract, + originRef: null, + currentRef: null, + createdAt: input.createdAt + }) + } + + ensureParentTaskContract(input: EnsureParentTaskContractInput): TaskContractCommitReceipt { + return this.commitTaskContract({ + targetSessionId: input.parentSessionId, + role: 'parent', + contract: input.contract, + originRef: null, + currentRef: input.currentRef, + createdAt: input.createdAt + }) + } + + ensureChildTaskContract(input: EnsureChildTaskContractInput): TaskContractCommitReceipt { + return this.commitTaskContract({ + targetSessionId: input.childSessionId, + role: 'child', + contract: input.contract, + originRef: input.originRef, + currentRef: input.currentRef, + createdAt: input.createdAt + }) + } + + private commitTaskContract(input: CommitTaskContractInput): TaskContractCommitReceipt { + if (!isDeepChatTaskContract(input.contract)) { + throw new TaskContractPersistenceError( + 'Cannot freeze a malformed or non-canonical TaskContract.', + 'invalid_contract' + ) + } + const description = input.contract.taskDescription + if ( + (input.role === 'parent' && input.targetSessionId !== description.parentSessionId) || + (input.role === 'child' && input.targetSessionId === description.parentSessionId) + ) { + throw new TaskContractPersistenceError( + 'TaskContract Session does not match its persistence role.', + 'invalid_contract' + ) + } + if ( + (input.role === 'parent' && input.originRef !== null) || + (input.role === 'child' && + (!isDeepChatTaskContractRef(input.originRef) || + input.originRef.sessionId !== description.parentSessionId || + input.originRef.contractHash !== input.contract.contractHash)) + ) { + throw new TaskContractPersistenceError( + 'TaskContract origin reference is invalid.', + 'invalid_contract' + ) + } + if ( + input.currentRef !== null && + (!isDeepChatTaskContractRef(input.currentRef) || + input.currentRef.sessionId !== input.targetSessionId || + input.currentRef.contractHash !== input.contract.contractHash) + ) { + throw new TaskContractPersistenceError( + 'TaskContract runtime reference is invalid.', + 'invalid_contract' + ) + } + if ( + input.createdAt !== undefined && + (!Number.isSafeInteger(input.createdAt) || input.createdAt < 0) + ) { + throw new TaskContractPersistenceError( + 'TaskContract timestamp is invalid.', + 'invalid_contract' + ) + } + + const store = this.getStore() + if (!store.isInTransaction()) { + throw new TaskContractPersistenceError( + 'TaskContract persistence requires the live-delegation host transaction.', + 'transaction_required' + ) + } + + try { + store.ensureBootstrapAnchor(input.targetSessionId) + const firstEntry = store.getFirstEntriesBySessions([input.targetSessionId])[0] + if (!firstEntry || firstEntry.session_id !== input.targetSessionId) { + throw new TaskContractPersistenceError( + `Tape ${input.targetSessionId} has no stable identity.`, + 'persistence_failed' + ) + } + const tapeIdentity = computeTapeIdentity(firstEntry) + const provenanceKey = `contract:task_frozen:v1:${input.role}:${description.turnId}` + const recovering = input.currentRef !== null && input.currentRef.tapeIdentity !== tapeIdentity + const delivery: TaskContractFactDelivery = recovering + ? 'projection_recovery' + : input.role === 'parent' + ? 'parent_frozen' + : 'child_inherited' + const event: StrictTaskContractEventInput = { + sessionId: input.targetSessionId, + name: TASK_CONTRACT_FACT_NAME, + source: { type: 'subagent', id: description.turnId, seq: description.turnSeq }, + provenanceKey, + data: { + schemaVersion: TASK_CONTRACT_FACT_SCHEMA_VERSION, + delivery, + contract: input.contract, + originRef: input.originRef, + supersedesRef: recovering ? input.currentRef : null + }, + meta: { protocolVersion: TASK_CONTRACT_FACT_PROTOCOL_VERSION }, + createdAt: input.createdAt + } + + const existing = store.getByProvenanceKey(input.targetSessionId, provenanceKey) + if (existing) { + const currentRefNamesExisting = + input.currentRef !== null && + input.currentRef.tapeIdentity === tapeIdentity && + input.currentRef.entryId === existing.entry_id + if ( + !rowMatchesTaskContractFact(existing, event) && + (!currentRefNamesExisting || + !rowContainsReferencedTaskContractFact(existing, input, provenanceKey)) + ) { + throw new TaskContractPersistenceError( + `Stored TaskContract conflicts with turn ${description.turnId}.`, + 'corruption' + ) + } + if ( + input.currentRef !== null && + input.currentRef.tapeIdentity === tapeIdentity && + input.currentRef.entryId !== existing.entry_id + ) { + throw new TaskContractPersistenceError( + `Stored TaskContract reference conflicts with turn ${description.turnId}.`, + 'corruption' + ) + } + return { + contract: input.contract, + ref: buildTaskContractRef(existing, tapeIdentity, input.contract.contractHash), + created: false + } + } + + if (input.currentRef !== null && input.currentRef.tapeIdentity === tapeIdentity) { + throw new TaskContractPersistenceError( + `Stored TaskContract is missing for turn ${description.turnId}.`, + 'corruption' + ) + } + + const row = store.appendContractEvent({ ...event, idempotent: false }) + if (!rowMatchesTaskContractFact(row, event)) { + throw new TaskContractPersistenceError( + `Contract writer returned a conflicting fact for turn ${description.turnId}.`, + 'corruption' + ) + } + return { + contract: input.contract, + ref: buildTaskContractRef(row, tapeIdentity, input.contract.contractHash), + created: true + } + } catch (error) { + if (error instanceof TaskContractPersistenceError) throw error + throw new TaskContractPersistenceError( + `Failed to freeze TaskContract for turn ${description.turnId}.`, + 'persistence_failed', + { cause: error } + ) + } + } +} diff --git a/src/main/tape/application/taskEvaluationService.ts b/src/main/tape/application/taskEvaluationService.ts new file mode 100644 index 000000000..18792e493 --- /dev/null +++ b/src/main/tape/application/taskEvaluationService.ts @@ -0,0 +1,280 @@ +import { + DEEPCHAT_EVALUATION_REF_SCHEMA_VERSION, + type DeepChatEvaluationRef, + type DeepChatTaskContractRef, + type DeepChatTaskEvaluation +} from '@shared/types/task-contract' +import { canonicalJsonStringifyData } from '../domain/canonicalJson' +import { computeTapeIdentity } from '../domain/tapeIdentity' +import type { DeepChatTapeEntryRow, TapeEventAppendInput } from '../domain/entry' +import { isDeepChatTaskEvaluation, serializeEvaluationRef } from '../domain/taskEvaluation' +import { isDeepChatTaskContract, isDeepChatTaskContractRef } from '../domain/taskContract' +import type { ContractPersistenceStore } from '../ports/storage' + +const TASK_CONTRACT_FACT_NAME = 'contract/task_frozen' as const +const TASK_EVALUATION_FACT_SCHEMA_VERSION = 1 as const +const TASK_EVALUATION_FACT_NAME = 'contract/evaluated' as const +const TASK_EVALUATION_FACT_PROTOCOL_VERSION = 1 as const + +type TaskEvaluationFactData = { + schemaVersion: typeof TASK_EVALUATION_FACT_SCHEMA_VERSION + evaluation: DeepChatTaskEvaluation + taskContractRef: DeepChatTaskContractRef +} + +type StrictTaskEvaluationEventInput = Omit & { + name: typeof TASK_EVALUATION_FACT_NAME + source: NonNullable + provenanceKey: string + data: TaskEvaluationFactData +} + +export interface CommitTaskEvaluationInput { + parentSessionId: string + turnSeq: number + evaluation: DeepChatTaskEvaluation + taskContractRef: DeepChatTaskContractRef + createdAt?: number +} + +export interface TaskEvaluationCommitReceipt { + evaluation: DeepChatTaskEvaluation + ref: DeepChatEvaluationRef + created: boolean +} + +export interface TaskEvaluationWriter { + commitTaskEvaluation(input: CommitTaskEvaluationInput): TaskEvaluationCommitReceipt +} + +export class TaskEvaluationPersistenceError extends Error { + constructor( + message: string, + readonly code: + | 'invalid_evaluation' + | 'transaction_required' + | 'corruption' + | 'persistence_failed', + options?: ErrorOptions + ) { + super(message, options) + this.name = 'TaskEvaluationPersistenceError' + } +} + +export class TaskEvaluationService implements TaskEvaluationWriter { + constructor(private readonly getStore: () => ContractPersistenceStore) {} + + commitTaskEvaluation(input: CommitTaskEvaluationInput): TaskEvaluationCommitReceipt { + if (!isDeepChatTaskEvaluation(input.evaluation)) { + throw new TaskEvaluationPersistenceError( + 'Cannot persist a malformed Task evaluation.', + 'invalid_evaluation' + ) + } + if ( + !isDeepChatTaskContractRef(input.taskContractRef) || + input.taskContractRef.sessionId !== input.parentSessionId || + input.taskContractRef.contractHash !== input.evaluation.taskContractHash + ) { + throw new TaskEvaluationPersistenceError( + 'Task evaluation does not match its parent TaskContract reference.', + 'invalid_evaluation' + ) + } + if (!Number.isSafeInteger(input.turnSeq) || input.turnSeq <= 0) { + throw new TaskEvaluationPersistenceError( + 'Task evaluation turn sequence is invalid.', + 'invalid_evaluation' + ) + } + if ( + input.createdAt !== undefined && + (!Number.isSafeInteger(input.createdAt) || input.createdAt < 0) + ) { + throw new TaskEvaluationPersistenceError( + 'Task evaluation timestamp is invalid.', + 'invalid_evaluation' + ) + } + + const store = this.getStore() + if (!store.isInTransaction()) { + throw new TaskEvaluationPersistenceError( + 'Task evaluation persistence requires the live-delegation host transaction.', + 'transaction_required' + ) + } + + try { + store.ensureBootstrapAnchor(input.parentSessionId) + const firstEntry = store.getFirstEntriesBySessions([input.parentSessionId])[0] + if (!firstEntry || firstEntry.session_id !== input.parentSessionId) { + throw new TaskEvaluationPersistenceError( + `Parent Tape ${input.parentSessionId} has no stable identity.`, + 'persistence_failed' + ) + } + const tapeIdentity = computeTapeIdentity(firstEntry) + if (tapeIdentity !== input.taskContractRef.tapeIdentity) { + throw new TaskEvaluationPersistenceError( + 'Task evaluation and TaskContract reference name different parent Tape incarnations.', + 'corruption' + ) + } + const taskContractProvenanceKey = `contract:task_frozen:v1:parent:${input.evaluation.turnId}` + const taskContractFact = store.getByProvenanceKey( + input.parentSessionId, + taskContractProvenanceKey + ) + if ( + !taskContractFact || + !rowMatchesTaskContractReference( + taskContractFact, + input.taskContractRef, + input.evaluation, + input.turnSeq, + taskContractProvenanceKey + ) + ) { + throw new TaskEvaluationPersistenceError( + `TaskContract reference does not resolve for turn ${input.evaluation.turnId}.`, + 'corruption' + ) + } + + const provenanceKey = `contract:evaluated:v1:${input.evaluation.turnId}` + const event: StrictTaskEvaluationEventInput = { + sessionId: input.parentSessionId, + name: TASK_EVALUATION_FACT_NAME, + source: { + type: 'subagent', + id: input.evaluation.turnId, + seq: input.turnSeq + }, + provenanceKey, + data: { + schemaVersion: TASK_EVALUATION_FACT_SCHEMA_VERSION, + evaluation: input.evaluation, + taskContractRef: input.taskContractRef + }, + meta: { protocolVersion: TASK_EVALUATION_FACT_PROTOCOL_VERSION }, + createdAt: input.createdAt + } + + const existing = store.getByProvenanceKey(input.parentSessionId, provenanceKey) + if (existing) { + if (!rowMatchesTaskEvaluationFact(existing, event)) { + throw new TaskEvaluationPersistenceError( + `Stored Task evaluation conflicts with turn ${input.evaluation.turnId}.`, + 'corruption' + ) + } + return { + evaluation: input.evaluation, + ref: buildEvaluationRef(existing, tapeIdentity, input.evaluation.evaluationHash), + created: false + } + } + + const row = store.appendContractEvent({ ...event, idempotent: false }) + if (!rowMatchesTaskEvaluationFact(row, event)) { + throw new TaskEvaluationPersistenceError( + `Contract writer returned a conflicting evaluation for turn ${input.evaluation.turnId}.`, + 'corruption' + ) + } + return { + evaluation: input.evaluation, + ref: buildEvaluationRef(row, tapeIdentity, input.evaluation.evaluationHash), + created: true + } + } catch (error) { + if (error instanceof TaskEvaluationPersistenceError) throw error + throw new TaskEvaluationPersistenceError( + `Failed to persist Task evaluation for turn ${input.evaluation.turnId}.`, + 'persistence_failed', + { cause: error } + ) + } + } +} + +function rowMatchesTaskContractReference( + row: DeepChatTapeEntryRow, + ref: DeepChatTaskContractRef, + evaluation: DeepChatTaskEvaluation, + turnSeq: number, + provenanceKey: string +): boolean { + if ( + row.session_id !== ref.sessionId || + row.entry_id !== ref.entryId || + row.kind !== 'event' || + row.name !== TASK_CONTRACT_FACT_NAME || + row.source_type !== 'subagent' || + row.source_id !== evaluation.turnId || + row.source_seq !== turnSeq || + row.provenance_key !== provenanceKey + ) { + return false + } + + try { + const payload = JSON.parse(row.payload_json) as { + name?: unknown + data?: { contract?: unknown } + } + return ( + payload.name === TASK_CONTRACT_FACT_NAME && + isDeepChatTaskContract(payload.data?.contract) && + payload.data.contract.contractHash === ref.contractHash && + payload.data.contract.taskDescription.turnId === evaluation.turnId && + payload.data.contract.taskDescription.turnSeq === turnSeq && + payload.data.contract.taskDescription.parentSessionId === ref.sessionId + ) + } catch { + return false + } +} + +function rowMatchesTaskEvaluationFact( + row: DeepChatTapeEntryRow, + input: StrictTaskEvaluationEventInput +): boolean { + return ( + row.session_id === input.sessionId && + row.kind === 'event' && + row.name === input.name && + row.source_type === input.source.type && + row.source_id === input.source.id && + row.source_seq === (input.source.seq ?? null) && + row.provenance_key === input.provenanceKey && + canonicalJsonEquals(row.payload_json, { name: input.name, data: input.data }) && + canonicalJsonEquals(row.meta_json, input.meta ?? {}) + ) +} + +function buildEvaluationRef( + row: DeepChatTapeEntryRow, + tapeIdentity: string, + evaluationHash: string +): DeepChatEvaluationRef { + const ref: DeepChatEvaluationRef = { + schemaVersion: DEEPCHAT_EVALUATION_REF_SCHEMA_VERSION, + sessionId: row.session_id, + tapeIdentity, + entryId: row.entry_id, + evaluationHash + } + serializeEvaluationRef(ref) + return Object.freeze(ref) +} + +function canonicalJsonEquals(raw: string, expected: unknown): boolean { + try { + return canonicalJsonStringifyData(JSON.parse(raw)) === canonicalJsonStringifyData(expected) + } catch { + return false + } +} diff --git a/src/main/tape/domain/canonicalJson.ts b/src/main/tape/domain/canonicalJson.ts index 66b1fe521..64f665987 100644 --- a/src/main/tape/domain/canonicalJson.ts +++ b/src/main/tape/domain/canonicalJson.ts @@ -21,7 +21,15 @@ function normalizeForStableJson(value: unknown): unknown { }, {}) } -function normalizeJsonData(value: unknown, ancestors: Set): unknown { +export interface CanonicalJsonDataOptions { + omitUndefinedProperties?: boolean +} + +function normalizeJsonData( + value: unknown, + ancestors: Set, + options: CanonicalJsonDataOptions +): unknown { if ( value === null || typeof value === 'string' || @@ -54,7 +62,7 @@ function normalizeJsonData(value: unknown, ancestors: Set): unknown { if (!descriptor?.enumerable || !('value' in descriptor)) { throw new TypeError('Value contains a non-data array item.') } - normalized.push(normalizeJsonData(descriptor.value, ancestors)) + normalized.push(normalizeJsonData(descriptor.value, ancestors, options)) } return normalized } @@ -72,7 +80,10 @@ function normalizeJsonData(value: unknown, ancestors: Set): unknown { if (!descriptor?.enumerable || !('value' in descriptor)) { throw new TypeError('Value contains a non-data property.') } - normalized[key] = normalizeJsonData(descriptor.value, ancestors) + if (descriptor.value === undefined && options.omitUndefinedProperties) { + continue + } + normalized[key] = normalizeJsonData(descriptor.value, ancestors, options) } return normalized } finally { @@ -88,12 +99,15 @@ export function hashJson(value: unknown): string { return createHash('sha256').update(stableJsonStringify(value)).digest('hex') } -// ViewManifest hashes keep the legacy object accumulator; journal identities need a +// Legacy ViewManifest hashes keep the old object accumulator. New persisted identities use a // null-prototype accumulator so JSON keys such as "__proto__" remain identity-bearing. -export function canonicalJsonStringifyData(value: unknown): string { - return JSON.stringify(normalizeJsonData(value, new Set())) +export function canonicalJsonStringifyData( + value: unknown, + options: CanonicalJsonDataOptions = {} +): string { + return JSON.stringify(normalizeJsonData(value, new Set(), options)) } -export function hashJsonData(value: unknown): string { - return createHash('sha256').update(canonicalJsonStringifyData(value)).digest('hex') +export function hashJsonData(value: unknown, options: CanonicalJsonDataOptions = {}): string { + return createHash('sha256').update(canonicalJsonStringifyData(value, options)).digest('hex') } diff --git a/src/main/tape/domain/contractFacts.ts b/src/main/tape/domain/contractFacts.ts new file mode 100644 index 000000000..2081c2897 --- /dev/null +++ b/src/main/tape/domain/contractFacts.ts @@ -0,0 +1,7 @@ +export const CONTRACT_TAPE_EVENT_NAMES = ['contract/task_frozen', 'contract/evaluated'] as const + +export type ContractTapeEventName = (typeof CONTRACT_TAPE_EVENT_NAMES)[number] + +export function isContractTapeReservedName(name: string | null | undefined): boolean { + return typeof name === 'string' && name.startsWith('contract/') +} diff --git a/src/main/tape/domain/effectiveView.ts b/src/main/tape/domain/effectiveView.ts index ac6501748..eebb870be 100644 --- a/src/main/tape/domain/effectiveView.ts +++ b/src/main/tape/domain/effectiveView.ts @@ -1,6 +1,7 @@ import type { ChatMessageRecord } from '@shared/types/agent-interface' import type { DeepChatTapeEntryKind, DeepChatTapeEntryRow, DeepChatTapeSearchInput } from './entry' import { EXECUTION_JOURNAL_EVENT_NAMES } from './executionJournal' +import { CONTRACT_TAPE_EVENT_NAMES, isContractTapeReservedName } from './contractFacts' import { parseNestedTapeJsonObject, parseTapeJsonObject, @@ -42,6 +43,7 @@ export const DEFAULT_EXCLUDED_TAPE_EVENT_NAMES = [ 'message/retracted', 'message/compaction_indicator', 'migration/backfill', + ...CONTRACT_TAPE_EVENT_NAMES, ...EXECUTION_JOURNAL_EVENT_NAMES ] as const @@ -117,7 +119,10 @@ function shouldReplaceMessage( } function isAuditEvent(row: DeepChatTapeEntryRow): boolean { - return row.name !== null && DEFAULT_EXCLUDED_TAPE_EVENT_NAME_SET.has(row.name) + return ( + row.name !== null && + (DEFAULT_EXCLUDED_TAPE_EVENT_NAME_SET.has(row.name) || isContractTapeReservedName(row.name)) + ) } function shouldReplaceToolRow( diff --git a/src/main/tape/domain/executionContract.ts b/src/main/tape/domain/executionContract.ts new file mode 100644 index 000000000..2f540869c --- /dev/null +++ b/src/main/tape/domain/executionContract.ts @@ -0,0 +1,1148 @@ +import { createHash } from 'node:crypto' +import type { ChatMessage } from '@shared/types/core/chat-message' +import { + stripToolExecutionContract, + type MCPToolDefinition, + type ToolEffect, + type ToolExecutionContract +} from '@shared/types/core/mcp' +import type { PermissionMode } from '@shared/types/agent-interface' +import type { ModelConfig } from '@shared/types/provider' +import { + DEEPCHAT_PROMPT_DEGRADATION_CODES, + DEEPCHAT_PROMPT_SECTION_INCLUSIONS, + DEEPCHAT_PROMPT_SECTION_KINDS, + DEEPCHAT_PROMPT_SOURCE_FRESHNESS_VALUES, + type DeepChatPromptAssembly, + type DeepChatPromptDegradationCode, + type DeepChatPromptSectionProvenance +} from '@shared/types/prompt-assembly' +import { + DEEPCHAT_EXECUTION_CONTRACT_BINDING_SCHEMA_VERSION, + DEEPCHAT_EXECUTION_CONTRACT_HASH_VERSION, + DEEPCHAT_EXECUTION_CONTRACT_SCHEMA_VERSION, + type DeepChatExecutionContract, + type DeepChatExecutionContractBinding, + type DeepChatExecutionContractRequest, + type DeepChatExecutionDynamicControlSnapshot, + type DeepChatExecutionToolCeiling, + type DeepChatExecutionToolTargetIdentity, + type DeepChatExecutionWorkspaceCeiling +} from '@shared/types/execution-contract' +import type { DeepChatTaskContractContext } from '@shared/types/task-contract' +import { canonicalJsonStringifyData, hashJsonData } from './canonicalJson' +import { isDeepChatTaskContract, isDeepChatTaskContractRef } from './taskContract' +import { + isWorkspacePathWithin, + normalizeAbsoluteWorkspacePath, + workspacePathsMatch +} from './workspacePath' + +export const MAX_EXECUTION_CONTRACT_BYTES = 64 * 1024 +export const MAX_EXECUTION_CONTRACT_BINDING_BYTES = 4 * 1024 +export const MAX_EXECUTION_CONTRACT_TOOLS = 256 +export const MAX_EXECUTION_CONTRACT_PROMPT_SECTIONS = 64 +export const MAX_EXECUTION_CONTRACT_SUBAGENT_DEPTH = 1 + +const MAX_IDENTITY_BYTES = 1_024 +const MAX_SOURCE_REF_BYTES = 2_048 +const MAX_WORKSPACE_PATH_BYTES = 32 * 1_024 +const MAX_ASSEMBLER_VERSION_BYTES = 256 +const MAX_SECTION_DEGRADATION_CODES = 16 +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const SHA_256_PATTERN = /^[0-9a-f]{64}$/ +const JSON_HASH_OPTIONS = Object.freeze({ omitUndefinedProperties: true }) +const PROMPT_SECTION_KINDS = new Set(DEEPCHAT_PROMPT_SECTION_KINDS) +const PROMPT_SECTION_INCLUSIONS = new Set(DEEPCHAT_PROMPT_SECTION_INCLUSIONS) +const PROMPT_SOURCE_FRESHNESS_VALUES = new Set(DEEPCHAT_PROMPT_SOURCE_FRESHNESS_VALUES) +const PROMPT_DEGRADATION_CODES = new Set(DEEPCHAT_PROMPT_DEGRADATION_CODES) +const PERMISSION_MODES = new Set(['default', 'auto_approve', 'full_access']) +const EXECUTION_CONTRACT_KEYS = [ + 'schemaVersion', + 'hashVersion', + 'request', + 'ceilings', + 'dynamicControlSnapshot', + 'provenance', + 'contractHash' +] as const +const EXECUTION_REQUEST_KEYS = ['sessionId', 'messageId', 'runId', 'requestSeq'] as const +const EXECUTION_CONTRACT_BINDING_KEYS = ['schemaVersion', 'request', 'contractHash'] as const +const EXECUTION_CEILINGS_KEYS = ['tools', 'workspace', 'maxSubagentDepth'] as const +const EXECUTION_TOOL_CEILING_KEYS = ['target', 'execution'] as const +const EXECUTION_TOOL_TARGET_KEYS = [ + 'providerVisibleName', + 'source', + 'serverName', + 'serverId', + 'configGeneration', + 'bindingHash', + 'originalName' +] as const +const EXECUTION_POLICY_KEYS = ['effect', 'mode'] as const +const DYNAMIC_CONTROL_KEYS = ['permissionMode', 'requestAdmitted', 'cancellationRequested'] as const +const EXECUTION_PROVENANCE_KEYS = [ + 'promptSections', + 'providerId', + 'modelId', + 'promptHash', + 'effectiveGenerationConfigHash', + 'providerVisibleToolDefinitionsHash', + 'internalExecutionPolicyHash', + 'assemblerVersion', + 'taskContractRef' +] as const +const PROMPT_SECTION_KEYS = [ + 'kind', + 'sourceRef', + 'inclusion', + 'contentHash', + 'freshness', + 'degradationCodes' +] as const + +export interface BuildExecutionContractInput { + request: DeepChatExecutionContractRequest + promptAssembly: DeepChatPromptAssembly + providerMessages: readonly ChatMessage[] + tools: readonly MCPToolDefinition[] + providerId: string + modelId: string + modelConfig: ModelConfig + temperature: number + maxTokens: number + workspace: DeepChatExecutionWorkspaceCeiling + maxSubagentDepth: number + dynamicControlSnapshot: DeepChatExecutionDynamicControlSnapshot + assemblerVersion: string + taskContractContext?: DeepChatTaskContractContext | null +} + +export class ExecutionContractError extends Error { + constructor( + message: string, + readonly code: 'invalid_input' | 'limit_exceeded' | 'conflicting_tool', + options?: ErrorOptions + ) { + super(message, options) + this.name = 'ExecutionContractError' + } +} + +export type ExecutionContractDispatchErrorCode = + | 'invalid_contract' + | 'identity_mismatch' + | 'tool_not_allowed' + | 'target_mismatch' + | 'effect_exceeds_ceiling' + | 'execution_mode_mismatch' + | 'workspace_mismatch' + | 'subagent_depth_exceeded' + | 'invalid_runtime_authority' + +export class ExecutionContractDispatchError extends Error { + constructor( + message: string, + readonly code: ExecutionContractDispatchErrorCode, + options?: ErrorOptions + ) { + super(message, options) + this.name = 'ExecutionContractDispatchError' + } +} + +export interface ExecutionContractDispatchInput { + request: DeepChatExecutionContractRequest + currentTool: MCPToolDefinition + currentWorkspace: DeepChatExecutionWorkspaceCeiling + currentMaxSubagentDepth: number + requestedSubagentDepth: number +} + +export function buildExecutionContractBinding( + contract: DeepChatExecutionContract +): DeepChatExecutionContractBinding { + if (!isDeepChatExecutionContract(contract)) { + throw new ExecutionContractError( + 'ExecutionContract binding requires a canonical contract.', + 'invalid_input' + ) + } + return deepFreeze({ + schemaVersion: DEEPCHAT_EXECUTION_CONTRACT_BINDING_SCHEMA_VERSION, + request: contract.request, + contractHash: contract.contractHash + }) +} + +export function isDeepChatExecutionContractBinding( + value: unknown +): value is DeepChatExecutionContractBinding { + return ( + hasExactKeys(value, EXECUTION_CONTRACT_BINDING_KEYS) && + value.schemaVersion === DEEPCHAT_EXECUTION_CONTRACT_BINDING_SCHEMA_VERSION && + isStoredExecutionContractRequest(value.request) && + isSha256(value.contractHash) + ) +} + +export function parseExecutionContractBinding( + value: unknown +): DeepChatExecutionContractBinding | null { + if (typeof value !== 'string' || utf8Length(value) > MAX_EXECUTION_CONTRACT_BINDING_BYTES) { + return null + } + try { + const parsed = JSON.parse(value) as unknown + return isDeepChatExecutionContractBinding(parsed) ? deepFreeze(parsed) : null + } catch { + return null + } +} + +export function executionContractMatchesBinding( + contract: unknown, + binding: DeepChatExecutionContractBinding +): contract is DeepChatExecutionContract { + return ( + isDeepChatExecutionContract(contract) && + contract.contractHash === binding.contractHash && + contract.request.sessionId === binding.request.sessionId && + contract.request.messageId === binding.request.messageId && + contract.request.runId === binding.request.runId && + contract.request.requestSeq === binding.request.requestSeq + ) +} + +export function restoreExecutionContract(value: unknown): DeepChatExecutionContract | null { + return isDeepChatExecutionContract(value) ? deepFreeze(value) : null +} + +function utf8Length(value: string): number { + return Buffer.byteLength(value, 'utf8') +} + +function requireString( + value: unknown, + label: string, + maxBytes: number, + options: { preserveOuterWhitespace?: boolean } = {} +): string { + if ( + typeof value !== 'string' || + !value.trim() || + value.includes('\0') || + utf8Length(value) > maxBytes + ) { + throw new ExecutionContractError( + `${label} must be a non-empty UTF-8 string no longer than ${maxBytes} bytes.`, + 'invalid_input' + ) + } + if (!options.preserveOuterWhitespace && value !== value.trim()) { + throw new ExecutionContractError(`${label} must not contain outer whitespace.`, 'invalid_input') + } + return value +} + +function requireUuid(value: unknown, label: string): string { + const uuid = requireString(value, label, MAX_IDENTITY_BYTES) + if (!UUID_PATTERN.test(uuid)) { + throw new ExecutionContractError(`${label} must be a UUID.`, 'invalid_input') + } + return uuid.toLowerCase() +} + +function requireSha256(value: unknown, label: string): string { + if (typeof value !== 'string' || !SHA_256_PATTERN.test(value)) { + throw new ExecutionContractError(`${label} must be a lowercase SHA-256 hash.`, 'invalid_input') + } + return value +} + +function requirePositiveSafeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new ExecutionContractError(`${label} must be a positive safe integer.`, 'invalid_input') + } + return value as number +} + +function requireNonNegativeSafeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new ExecutionContractError( + `${label} must be a non-negative safe integer.`, + 'invalid_input' + ) + } + return value as number +} + +function requireFiniteNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new ExecutionContractError(`${label} must be a finite number.`, 'invalid_input') + } + return value +} + +function hashData(value: unknown, label: string, omitUndefinedProperties = false): string { + try { + return hashJsonData(value, omitUndefinedProperties ? JSON_HASH_OPTIONS : undefined) + } catch (error) { + throw new ExecutionContractError(`${label} must contain only JSON data.`, 'invalid_input', { + cause: error + }) + } +} + +function isRecordObject(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + +function hasExactKeys( + value: unknown, + requiredKeys: readonly string[], + optionalKeys: readonly string[] = [] +): value is Record { + if (!isRecordObject(value)) return false + const actualKeys = Object.keys(value) + const allowedKeys = new Set([...requiredKeys, ...optionalKeys]) + return ( + requiredKeys.every((key) => Object.hasOwn(value, key)) && + actualKeys.every((key) => allowedKeys.has(key)) && + actualKeys.length >= requiredKeys.length + ) +} + +function matchesNormalizedString( + value: unknown, + label: string, + maxBytes: number, + options?: { preserveOuterWhitespace?: boolean } +): value is string { + try { + return requireString(value, label, maxBytes, options) === value + } catch { + return false + } +} + +function matchesNormalizedUuid(value: unknown, label: string): value is string { + try { + return requireUuid(value, label) === value + } catch { + return false + } +} + +function isSha256(value: unknown): value is string { + return typeof value === 'string' && SHA_256_PATTERN.test(value) +} + +function compareCodePoints(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function normalizeExecution(value: ToolExecutionContract, label: string): ToolExecutionContract { + if (value?.effect === 'read' && (value.mode === 'sequential' || value.mode === 'parallel')) { + return { effect: 'read', mode: value.mode } + } + if (value?.effect === 'write' && value.mode === 'sequential') { + return { effect: 'write', mode: 'sequential' } + } + throw new ExecutionContractError(`${label} is invalid.`, 'invalid_input') +} + +function normalizeOptionalAgentBinding(definition: MCPToolDefinition): { + serverId: string | null + configGeneration: number | null + bindingHash: string | null +} { + const values = [ + definition.server?.id, + definition.server?.configGeneration, + definition.server?.bindingHash + ] + if (values.every((value) => value === undefined)) { + return { serverId: null, configGeneration: null, bindingHash: null } + } + if (values.some((value) => value === undefined)) { + throw new ExecutionContractError( + `Agent tool ${definition.function?.name ?? ''} has an incomplete stable binding.`, + 'invalid_input' + ) + } + return { + serverId: requireUuid(definition.server.id, 'tool.server.id'), + configGeneration: requirePositiveSafeInteger( + definition.server.configGeneration, + 'tool.server.configGeneration' + ), + bindingHash: requireSha256(definition.server.bindingHash, 'tool.server.bindingHash') + } +} + +function normalizeToolCeiling( + definition: MCPToolDefinition, + index: number +): DeepChatExecutionToolCeiling { + const label = `tools[${index}]` + if (definition?.source !== 'agent' && definition?.source !== 'mcp') { + throw new ExecutionContractError(`${label}.source must be agent or mcp.`, 'invalid_input') + } + const providerVisibleName = requireString( + definition.function?.name, + `${label}.function.name`, + MAX_IDENTITY_BYTES + ) + const serverName = requireString( + definition.server?.name, + `${label}.server.name`, + MAX_IDENTITY_BYTES + ) + const originalName = requireString( + definition.raw?.name ?? providerVisibleName, + `${label}.originalName`, + MAX_IDENTITY_BYTES + ) + const binding = + definition.source === 'mcp' + ? { + serverId: requireUuid(definition.server.id, `${label}.server.id`), + configGeneration: requirePositiveSafeInteger( + definition.server.configGeneration, + `${label}.server.configGeneration` + ), + bindingHash: requireSha256(definition.server.bindingHash, `${label}.server.bindingHash`) + } + : normalizeOptionalAgentBinding(definition) + + return { + target: { + providerVisibleName, + source: definition.source, + serverName, + ...binding, + originalName + }, + execution: normalizeExecution(definition.execution, `${label}.execution`) + } +} + +export function buildExecutionToolCeiling( + definition: MCPToolDefinition +): DeepChatExecutionToolCeiling { + return normalizeToolCeiling(definition, 0) +} + +export function buildExecutionToolTargetKey(target: DeepChatExecutionToolTargetIdentity): string { + return canonicalJsonStringifyData(target) +} + +function normalizeToolCeilings( + definitions: readonly MCPToolDefinition[] +): DeepChatExecutionToolCeiling[] { + if (definitions.length > MAX_EXECUTION_CONTRACT_TOOLS) { + throw new ExecutionContractError( + `Execution contract has more than ${MAX_EXECUTION_CONTRACT_TOOLS} tools.`, + 'limit_exceeded' + ) + } + + const ceilingByTarget = new Map< + string, + { ceiling: DeepChatExecutionToolCeiling; providerDefinitionHash: string } + >() + const targetKeyByVisibleName = new Map() + definitions.forEach((definition, index) => { + const ceiling = normalizeToolCeiling(definition, index) + const targetKey = buildExecutionToolTargetKey(ceiling.target) + const providerDefinitionHash = buildProviderVisibleToolDefinitionsHash([definition]) + const visibleName = ceiling.target.providerVisibleName + const previousTargetKey = targetKeyByVisibleName.get(visibleName) + if (previousTargetKey !== undefined && previousTargetKey !== targetKey) { + throw new ExecutionContractError( + `Provider-visible tool ${visibleName} resolves to conflicting targets.`, + 'conflicting_tool' + ) + } + targetKeyByVisibleName.set(visibleName, targetKey) + + const previous = ceilingByTarget.get(targetKey) + if (!previous) { + ceilingByTarget.set(targetKey, { ceiling, providerDefinitionHash }) + return + } + if ( + previous.ceiling.execution.effect !== ceiling.execution.effect || + previous.ceiling.execution.mode !== ceiling.execution.mode + ) { + throw new ExecutionContractError( + `Tool target ${visibleName} has conflicting execution policies.`, + 'conflicting_tool' + ) + } + if (previous.providerDefinitionHash !== providerDefinitionHash) { + throw new ExecutionContractError( + `Tool target ${visibleName} has conflicting provider definitions.`, + 'conflicting_tool' + ) + } + }) + + return [...ceilingByTarget.entries()] + .sort(([left], [right]) => compareCodePoints(left, right)) + .map(([, value]) => value.ceiling) +} + +function normalizePromptSections( + assembly: DeepChatPromptAssembly +): DeepChatPromptSectionProvenance[] { + if (assembly.sections.length > MAX_EXECUTION_CONTRACT_PROMPT_SECTIONS) { + throw new ExecutionContractError( + `Execution contract has more than ${MAX_EXECUTION_CONTRACT_PROMPT_SECTIONS} prompt sections.`, + 'limit_exceeded' + ) + } + + return assembly.sections.map((section, index) => { + const label = `promptAssembly.sections[${index}]` + if (!PROMPT_SECTION_KINDS.has(section.kind)) { + throw new ExecutionContractError(`${label}.kind is invalid.`, 'invalid_input') + } + if (!PROMPT_SECTION_INCLUSIONS.has(section.inclusion)) { + throw new ExecutionContractError(`${label}.inclusion is invalid.`, 'invalid_input') + } + const sourceRef = requireString(section.sourceRef, `${label}.sourceRef`, MAX_SOURCE_REF_BYTES) + if (typeof section.content !== 'string') { + throw new ExecutionContractError(`${label}.content must be a string.`, 'invalid_input') + } + if (section.degradationCodes !== undefined && !Array.isArray(section.degradationCodes)) { + throw new ExecutionContractError( + `${label}.degradationCodes must be an array.`, + 'invalid_input' + ) + } + if ((section.degradationCodes?.length ?? 0) > MAX_SECTION_DEGRADATION_CODES) { + throw new ExecutionContractError( + `${label} has more than ${MAX_SECTION_DEGRADATION_CODES} degradation codes.`, + 'limit_exceeded' + ) + } + const degradationCodes = [...new Set(section.degradationCodes ?? [])] + if (degradationCodes.some((code) => !PROMPT_DEGRADATION_CODES.has(code))) { + throw new ExecutionContractError(`${label}.degradationCodes is invalid.`, 'invalid_input') + } + degradationCodes.sort(compareCodePoints) + if (section.freshness !== undefined && !PROMPT_SOURCE_FRESHNESS_VALUES.has(section.freshness)) { + throw new ExecutionContractError(`${label}.freshness is invalid.`, 'invalid_input') + } + + const hasContent = section.content.trim().length > 0 + const expectedInclusion = !hasContent + ? 'omitted' + : degradationCodes.length > 0 + ? 'degraded' + : 'included' + if (section.inclusion !== expectedInclusion) { + throw new ExecutionContractError( + `${label}.inclusion does not match its content and degradation state.`, + 'invalid_input' + ) + } + const contentHash = hasContent + ? createHash('sha256').update(section.content, 'utf8').digest('hex') + : undefined + if (section.contentHash !== contentHash) { + throw new ExecutionContractError( + `${label}.contentHash does not match its content.`, + 'invalid_input' + ) + } + + return { + kind: section.kind, + sourceRef, + inclusion: section.inclusion, + ...(contentHash ? { contentHash } : {}), + ...(section.freshness ? { freshness: section.freshness } : {}), + ...(degradationCodes.length > 0 + ? { degradationCodes: degradationCodes as DeepChatPromptDegradationCode[] } + : {}) + } + }) +} + +function normalizeRequest( + request: DeepChatExecutionContractRequest +): DeepChatExecutionContractRequest { + return { + sessionId: requireString(request.sessionId, 'request.sessionId', MAX_IDENTITY_BYTES), + messageId: requireString(request.messageId, 'request.messageId', MAX_IDENTITY_BYTES), + runId: requireUuid(request.runId, 'request.runId'), + requestSeq: requirePositiveSafeInteger(request.requestSeq, 'request.requestSeq') + } +} + +function normalizeWorkspace( + workspace: DeepChatExecutionWorkspaceCeiling +): DeepChatExecutionWorkspaceCeiling { + if (workspace?.kind === 'runtime_default') { + return { kind: 'runtime_default' } + } + if (workspace?.kind !== 'path') { + throw new ExecutionContractError('workspace.kind is invalid.', 'invalid_input') + } + const workspacePath = requireString(workspace.path, 'workspace.path', MAX_WORKSPACE_PATH_BYTES, { + preserveOuterWhitespace: true + }) + const normalized = normalizeAbsoluteWorkspacePath(workspacePath) + if (normalized === null) { + throw new ExecutionContractError('workspace.path must be absolute.', 'invalid_input') + } + return { kind: 'path', path: normalized.path } +} + +function normalizeMaxSubagentDepth(value: unknown): number { + const depth = requireNonNegativeSafeInteger(value, 'maxSubagentDepth') + if (depth > MAX_EXECUTION_CONTRACT_SUBAGENT_DEPTH) { + throw new ExecutionContractError( + `maxSubagentDepth exceeds the V1 limit of ${MAX_EXECUTION_CONTRACT_SUBAGENT_DEPTH}.`, + 'limit_exceeded' + ) + } + return depth +} + +function normalizeDynamicControlSnapshot( + snapshot: DeepChatExecutionDynamicControlSnapshot +): DeepChatExecutionDynamicControlSnapshot { + if (!PERMISSION_MODES.has(snapshot?.permissionMode)) { + throw new ExecutionContractError( + 'dynamicControlSnapshot.permissionMode is invalid.', + 'invalid_input' + ) + } + if ( + typeof snapshot.requestAdmitted !== 'boolean' || + typeof snapshot.cancellationRequested !== 'boolean' + ) { + throw new ExecutionContractError( + 'dynamicControlSnapshot admission and cancellation values must be boolean.', + 'invalid_input' + ) + } + return { + permissionMode: snapshot.permissionMode, + requestAdmitted: snapshot.requestAdmitted, + cancellationRequested: snapshot.cancellationRequested + } +} + +function resolveLeadingSystemPrompt(messages: readonly ChatMessage[]): string { + const first = messages[0] + return first?.role === 'system' && typeof first.content === 'string' ? first.content : '' +} + +function isStoredToolTarget(value: unknown): value is DeepChatExecutionToolTargetIdentity { + if (!hasExactKeys(value, EXECUTION_TOOL_TARGET_KEYS)) return false + if (value.source !== 'agent' && value.source !== 'mcp') return false + if ( + !matchesNormalizedString( + value.providerVisibleName, + 'target.providerVisibleName', + MAX_IDENTITY_BYTES + ) || + !matchesNormalizedString(value.serverName, 'target.serverName', MAX_IDENTITY_BYTES) || + !matchesNormalizedString(value.originalName, 'target.originalName', MAX_IDENTITY_BYTES) + ) { + return false + } + + const hasNullBinding = + value.serverId === null && value.configGeneration === null && value.bindingHash === null + const hasStableBinding = + matchesNormalizedUuid(value.serverId, 'target.serverId') && + Number.isSafeInteger(value.configGeneration) && + (value.configGeneration as number) > 0 && + isSha256(value.bindingHash) + return value.source === 'mcp' ? hasStableBinding : hasNullBinding || hasStableBinding +} + +function isStoredExecutionPolicy(value: unknown): value is ToolExecutionContract { + if (!hasExactKeys(value, EXECUTION_POLICY_KEYS)) return false + return ( + (value.effect === 'read' && (value.mode === 'sequential' || value.mode === 'parallel')) || + (value.effect === 'write' && value.mode === 'sequential') + ) +} + +function isStoredWorkspace(value: unknown): value is DeepChatExecutionWorkspaceCeiling { + if (!isRecordObject(value)) return false + if (value.kind === 'runtime_default') { + return hasExactKeys(value, ['kind']) + } + if (value.kind !== 'path' || !hasExactKeys(value, ['kind', 'path'])) return false + if ( + !matchesNormalizedString(value.path, 'workspace.path', MAX_WORKSPACE_PATH_BYTES, { + preserveOuterWhitespace: true + }) + ) { + return false + } + return normalizeAbsoluteWorkspacePath(value.path)?.path === value.path +} + +function isStoredPromptSection(value: unknown): value is DeepChatPromptSectionProvenance { + if ( + !hasExactKeys(value, PROMPT_SECTION_KEYS.slice(0, 3), PROMPT_SECTION_KEYS.slice(3)) || + !PROMPT_SECTION_KINDS.has(value.kind as string) || + !matchesNormalizedString(value.sourceRef, 'promptSection.sourceRef', MAX_SOURCE_REF_BYTES) || + !PROMPT_SECTION_INCLUSIONS.has(value.inclusion as string) + ) { + return false + } + if ( + value.freshness !== undefined && + !PROMPT_SOURCE_FRESHNESS_VALUES.has(value.freshness as string) + ) { + return false + } + if (value.contentHash !== undefined && !isSha256(value.contentHash)) return false + + const degradationCodes = value.degradationCodes + if (degradationCodes !== undefined) { + if ( + !Array.isArray(degradationCodes) || + degradationCodes.length === 0 || + degradationCodes.length > MAX_SECTION_DEGRADATION_CODES || + degradationCodes.some( + (code, index) => + typeof code !== 'string' || + !PROMPT_DEGRADATION_CODES.has(code) || + (index > 0 && compareCodePoints(degradationCodes[index - 1], code) >= 0) + ) + ) { + return false + } + } + + const hasContentHash = value.contentHash !== undefined + const hasDegradation = degradationCodes !== undefined + return ( + (value.inclusion === 'omitted' && !hasContentHash) || + (value.inclusion === 'included' && hasContentHash && !hasDegradation) || + (value.inclusion === 'degraded' && hasContentHash && hasDegradation) + ) +} + +function isStoredExecutionContractRequest( + value: unknown +): value is DeepChatExecutionContractRequest { + return ( + hasExactKeys(value, EXECUTION_REQUEST_KEYS) && + matchesNormalizedString(value.sessionId, 'request.sessionId', MAX_IDENTITY_BYTES) && + matchesNormalizedString(value.messageId, 'request.messageId', MAX_IDENTITY_BYTES) && + matchesNormalizedUuid(value.runId, 'request.runId') && + Number.isSafeInteger(value.requestSeq) && + (value.requestSeq as number) > 0 + ) +} + +function isStoredExecutionCeilings(value: unknown): value is DeepChatExecutionContract['ceilings'] { + if ( + !hasExactKeys(value, EXECUTION_CEILINGS_KEYS) || + !Array.isArray(value.tools) || + value.tools.length > MAX_EXECUTION_CONTRACT_TOOLS || + !isStoredWorkspace(value.workspace) || + !Number.isSafeInteger(value.maxSubagentDepth) || + (value.maxSubagentDepth as number) < 0 || + (value.maxSubagentDepth as number) > MAX_EXECUTION_CONTRACT_SUBAGENT_DEPTH + ) { + return false + } + + let previousTargetKey: string | null = null + const targetKeyByVisibleName = new Map() + for (const tool of value.tools) { + if ( + !hasExactKeys(tool, EXECUTION_TOOL_CEILING_KEYS) || + !isStoredToolTarget(tool.target) || + !isStoredExecutionPolicy(tool.execution) + ) { + return false + } + const targetKey = buildExecutionToolTargetKey(tool.target) + if (previousTargetKey !== null && compareCodePoints(previousTargetKey, targetKey) >= 0) { + return false + } + const previousVisibleTarget = targetKeyByVisibleName.get(tool.target.providerVisibleName) + if (previousVisibleTarget !== undefined && previousVisibleTarget !== targetKey) return false + targetKeyByVisibleName.set(tool.target.providerVisibleName, targetKey) + previousTargetKey = targetKey + } + return true +} + +function isStoredDynamicControlSnapshot( + value: unknown +): value is DeepChatExecutionDynamicControlSnapshot { + return ( + hasExactKeys(value, DYNAMIC_CONTROL_KEYS) && + PERMISSION_MODES.has(value.permissionMode as PermissionMode) && + typeof value.requestAdmitted === 'boolean' && + typeof value.cancellationRequested === 'boolean' + ) +} + +function isStoredExecutionProvenance( + value: unknown, + ceilings: DeepChatExecutionContract['ceilings'] +): value is DeepChatExecutionContract['provenance'] { + return ( + hasExactKeys(value, EXECUTION_PROVENANCE_KEYS) && + Array.isArray(value.promptSections) && + value.promptSections.length <= MAX_EXECUTION_CONTRACT_PROMPT_SECTIONS && + value.promptSections.every(isStoredPromptSection) && + matchesNormalizedString(value.providerId, 'provenance.providerId', MAX_IDENTITY_BYTES) && + matchesNormalizedString(value.modelId, 'provenance.modelId', MAX_IDENTITY_BYTES) && + isSha256(value.promptHash) && + isSha256(value.effectiveGenerationConfigHash) && + isSha256(value.providerVisibleToolDefinitionsHash) && + isSha256(value.internalExecutionPolicyHash) && + value.internalExecutionPolicyHash === hashData(ceilings, 'internal execution policy') && + matchesNormalizedString( + value.assemblerVersion, + 'provenance.assemblerVersion', + MAX_ASSEMBLER_VERSION_BYTES + ) && + (value.taskContractRef === null || isDeepChatTaskContractRef(value.taskContractRef)) + ) +} + +export function buildEffectiveGenerationConfigHash(input: { + modelConfig: ModelConfig + temperature: number + maxTokens: number +}): string { + const modelConfig = Object.fromEntries( + Object.entries(input.modelConfig).filter(([key]) => key !== 'conversationId') + ) + return hashData( + { + modelConfig, + temperature: requireFiniteNumber(input.temperature, 'temperature'), + maxTokens: requirePositiveSafeInteger(input.maxTokens, 'maxTokens') + }, + 'generation config', + true + ) +} + +export function buildProviderMessagesHash(messages: readonly ChatMessage[]): string { + return hashData(messages, 'provider messages', true) +} + +export function buildProviderVisibleToolDefinitionsHash( + definitions: readonly MCPToolDefinition[] +): string { + return hashData( + definitions.map((definition) => stripToolExecutionContract(definition)), + 'provider-visible tool definitions', + true + ) +} + +export function isToolEffectWithinCeiling(effect: ToolEffect, ceiling: ToolEffect): boolean { + if ((effect !== 'read' && effect !== 'write') || (ceiling !== 'read' && ceiling !== 'write')) { + throw new ExecutionContractError('Tool effect is invalid.', 'invalid_input') + } + return effect === 'read' || ceiling === 'write' +} + +export function meetToolEffects(left: ToolEffect, right: ToolEffect): ToolEffect { + return isToolEffectWithinCeiling(left, right) ? left : right +} + +function executionWorkspacesMatch( + current: DeepChatExecutionWorkspaceCeiling, + ceiling: DeepChatExecutionWorkspaceCeiling +): boolean { + if (current.kind === 'runtime_default' || ceiling.kind === 'runtime_default') { + return current.kind === ceiling.kind + } + return workspacePathsMatch(current.path, ceiling.path) +} + +function isExecutionWorkspaceWithinTaskCeiling( + execution: DeepChatExecutionWorkspaceCeiling, + taskCeiling: DeepChatExecutionWorkspaceCeiling +): boolean { + if (execution.kind === 'runtime_default' || taskCeiling.kind === 'runtime_default') { + return execution.kind === taskCeiling.kind + } + + return isWorkspacePathWithin(execution.path, taskCeiling.path) +} + +function normalizeTaskContractRef( + context: DeepChatTaskContractContext | null | undefined, + request: DeepChatExecutionContractRequest, + ceilings: DeepChatExecutionContract['ceilings'] +): DeepChatExecutionContract['provenance']['taskContractRef'] { + if (context == null) return null + if (!isDeepChatTaskContract(context.contract) || !isDeepChatTaskContractRef(context.localRef)) { + throw new ExecutionContractError('TaskContract context is not canonical.', 'invalid_input') + } + if ( + context.localRef.sessionId !== request.sessionId || + context.localRef.contractHash !== context.contract.contractHash + ) { + throw new ExecutionContractError( + 'TaskContract context does not belong to the provider request Session.', + 'invalid_input' + ) + } + + const taskCeilings = context.contract.taskHarness.ceilings + const exceedingTool = ceilings.tools.find( + (tool) => !isToolEffectWithinCeiling(tool.execution.effect, taskCeilings.maxToolEffect) + ) + if (exceedingTool) { + throw new ExecutionContractError( + `Tool '${exceedingTool.target.providerVisibleName}' exceeds the TaskContract effect ceiling.`, + 'invalid_input' + ) + } + if (!isExecutionWorkspaceWithinTaskCeiling(ceilings.workspace, taskCeilings.workspace)) { + throw new ExecutionContractError( + 'Execution workspace exceeds the TaskContract workspace ceiling.', + 'invalid_input' + ) + } + if (ceilings.maxSubagentDepth > taskCeilings.maxSubagentDepth) { + throw new ExecutionContractError( + 'Execution nesting exceeds the TaskContract Subagent ceiling.', + 'invalid_input' + ) + } + + return { + schemaVersion: context.localRef.schemaVersion, + sessionId: context.localRef.sessionId, + tapeIdentity: context.localRef.tapeIdentity, + entryId: context.localRef.entryId, + contractHash: context.localRef.contractHash + } +} + +export function assertExecutionContractAllowsDispatch( + contract: DeepChatExecutionContract, + input: ExecutionContractDispatchInput +): void { + if (!isDeepChatExecutionContract(contract)) { + throw new ExecutionContractDispatchError( + 'Tool dispatch requires a canonical ExecutionContract with a valid hash.', + 'invalid_contract' + ) + } + if ( + contract.request.sessionId !== input.request.sessionId || + contract.request.messageId !== input.request.messageId || + contract.request.runId !== input.request.runId || + contract.request.requestSeq !== input.request.requestSeq + ) { + throw new ExecutionContractDispatchError( + 'ExecutionContract identity does not match the active provider View.', + 'identity_mismatch' + ) + } + + let currentTool: DeepChatExecutionToolCeiling + try { + currentTool = buildExecutionToolCeiling(input.currentTool) + } catch (error) { + throw new ExecutionContractDispatchError( + 'Current tool authority is missing a valid stable identity or execution policy.', + 'invalid_runtime_authority', + { cause: error } + ) + } + const ceiling = contract.ceilings.tools.find( + (candidate) => candidate.target.providerVisibleName === currentTool.target.providerVisibleName + ) + if (!ceiling) { + throw new ExecutionContractDispatchError( + `Tool '${currentTool.target.providerVisibleName}' is outside the frozen View ceiling.`, + 'tool_not_allowed' + ) + } + if ( + buildExecutionToolTargetKey(ceiling.target) !== buildExecutionToolTargetKey(currentTool.target) + ) { + throw new ExecutionContractDispatchError( + `Tool '${currentTool.target.providerVisibleName}' no longer resolves to the frozen target.`, + 'target_mismatch' + ) + } + if (!isToolEffectWithinCeiling(currentTool.execution.effect, ceiling.execution.effect)) { + throw new ExecutionContractDispatchError( + `Tool '${currentTool.target.providerVisibleName}' exceeds its frozen effect ceiling.`, + 'effect_exceeds_ceiling' + ) + } + if (currentTool.execution.mode !== ceiling.execution.mode) { + throw new ExecutionContractDispatchError( + `Tool '${currentTool.target.providerVisibleName}' execution mode changed after View assembly.`, + 'execution_mode_mismatch' + ) + } + + let currentWorkspace: DeepChatExecutionWorkspaceCeiling + let currentMaxSubagentDepth: number + let requestedSubagentDepth: number + try { + currentWorkspace = normalizeWorkspace(input.currentWorkspace) + currentMaxSubagentDepth = requireNonNegativeSafeInteger( + input.currentMaxSubagentDepth, + 'currentMaxSubagentDepth' + ) + requestedSubagentDepth = requireNonNegativeSafeInteger( + input.requestedSubagentDepth, + 'requestedSubagentDepth' + ) + } catch (error) { + throw new ExecutionContractDispatchError( + 'Current workspace or nesting authority is invalid.', + 'invalid_runtime_authority', + { cause: error } + ) + } + if (!executionWorkspacesMatch(currentWorkspace, contract.ceilings.workspace)) { + throw new ExecutionContractDispatchError( + 'Current workspace does not match the frozen View ceiling.', + 'workspace_mismatch' + ) + } + if ( + requestedSubagentDepth > contract.ceilings.maxSubagentDepth || + requestedSubagentDepth > currentMaxSubagentDepth + ) { + throw new ExecutionContractDispatchError( + 'Requested Subagent nesting exceeds the effective runtime ceiling.', + 'subagent_depth_exceeded' + ) + } +} + +function deepFreeze(value: T): T { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value + for (const nested of Object.values(value as Record)) { + deepFreeze(nested) + } + return Object.freeze(value) +} + +function buildContractHash(contract: Omit): string { + return hashData(contract, 'execution contract') +} + +export function buildExecutionContract( + input: BuildExecutionContractInput +): DeepChatExecutionContract { + if (input.promptAssembly.prompt !== resolveLeadingSystemPrompt(input.providerMessages)) { + throw new ExecutionContractError( + 'Prompt assembly does not match the provider-visible system message.', + 'invalid_input' + ) + } + + const tools = normalizeToolCeilings(input.tools) + const ceilings = { + tools, + workspace: normalizeWorkspace(input.workspace), + maxSubagentDepth: normalizeMaxSubagentDepth(input.maxSubagentDepth) + } + const request = normalizeRequest(input.request) + const taskContractRef = normalizeTaskContractRef(input.taskContractContext, request, ceilings) + const draft: Omit = { + schemaVersion: DEEPCHAT_EXECUTION_CONTRACT_SCHEMA_VERSION, + hashVersion: DEEPCHAT_EXECUTION_CONTRACT_HASH_VERSION, + request, + ceilings, + dynamicControlSnapshot: normalizeDynamicControlSnapshot(input.dynamicControlSnapshot), + provenance: { + promptSections: normalizePromptSections(input.promptAssembly), + providerId: requireString(input.providerId, 'providerId', MAX_IDENTITY_BYTES), + modelId: requireString(input.modelId, 'modelId', MAX_IDENTITY_BYTES), + promptHash: buildProviderMessagesHash(input.providerMessages), + effectiveGenerationConfigHash: buildEffectiveGenerationConfigHash(input), + providerVisibleToolDefinitionsHash: buildProviderVisibleToolDefinitionsHash(input.tools), + internalExecutionPolicyHash: hashData(ceilings, 'internal execution policy'), + assemblerVersion: requireString( + input.assemblerVersion, + 'assemblerVersion', + MAX_ASSEMBLER_VERSION_BYTES + ), + taskContractRef + } + } + const contract: DeepChatExecutionContract = { + ...draft, + contractHash: buildContractHash(draft) + } + const serialized = canonicalJsonStringifyData(contract) + if (utf8Length(serialized) > MAX_EXECUTION_CONTRACT_BYTES) { + throw new ExecutionContractError( + `Execution contract exceeds ${MAX_EXECUTION_CONTRACT_BYTES} UTF-8 bytes.`, + 'limit_exceeded' + ) + } + return deepFreeze(contract) +} + +export function verifyExecutionContractHash(contract: DeepChatExecutionContract): boolean { + if ( + contract?.schemaVersion !== DEEPCHAT_EXECUTION_CONTRACT_SCHEMA_VERSION || + contract?.hashVersion !== DEEPCHAT_EXECUTION_CONTRACT_HASH_VERSION || + typeof contract.contractHash !== 'string' || + !SHA_256_PATTERN.test(contract.contractHash) + ) { + return false + } + try { + const { contractHash, ...draft } = contract + return buildContractHash(draft) === contractHash + } catch { + return false + } +} + +export function isDeepChatExecutionContract(value: unknown): value is DeepChatExecutionContract { + try { + const serialized = canonicalJsonStringifyData(value) + if ( + utf8Length(serialized) > MAX_EXECUTION_CONTRACT_BYTES || + !hasExactKeys(value, EXECUTION_CONTRACT_KEYS) || + value.schemaVersion !== DEEPCHAT_EXECUTION_CONTRACT_SCHEMA_VERSION || + value.hashVersion !== DEEPCHAT_EXECUTION_CONTRACT_HASH_VERSION || + !isStoredExecutionContractRequest(value.request) || + !isStoredExecutionCeilings(value.ceilings) || + !isStoredDynamicControlSnapshot(value.dynamicControlSnapshot) || + !isStoredExecutionProvenance(value.provenance, value.ceilings) || + !isSha256(value.contractHash) + ) { + return false + } + if ( + value.provenance.taskContractRef !== null && + value.provenance.taskContractRef.sessionId !== value.request.sessionId + ) { + return false + } + const contract = value as unknown as DeepChatExecutionContract + const { contractHash, ...draft } = contract + return buildContractHash(draft) === contractHash + } catch { + return false + } +} diff --git a/src/main/tape/domain/replay.ts b/src/main/tape/domain/replay.ts index df4149d7b..4ec0494ca 100644 --- a/src/main/tape/domain/replay.ts +++ b/src/main/tape/domain/replay.ts @@ -4,6 +4,7 @@ import type { DeepChatTapeViewManifest } from '@shared/types/tape-view-manifest' import type { DeepChatTapeReplaySlice } from '@shared/types/tape-replay' +import { isDeepChatExecutionContract } from './executionContract' import { hashJson } from './viewManifest' const VIEW_POLICIES = new Set([ @@ -137,6 +138,34 @@ function isViewManifestMeta(value: unknown): value is DeepChatTapeViewManifest[' ) } +function hasExecutionContractForSchema( + value: Record, + schemaVersion: DeepChatTapeViewManifest['schemaVersion'] +): boolean { + if (schemaVersion !== 5) return value.executionContract === undefined + if ( + value.hashVersion !== 3 || + !isDeepChatExecutionContract(value.executionContract) || + !isRecordObject(value.meta) || + !isRecordObject(value.hashes) + ) { + return false + } + + const contract = value.executionContract + return ( + contract.request.sessionId === value.sessionId && + contract.request.messageId === value.messageId && + contract.request.requestSeq === value.requestSeq && + contract.provenance.providerId === value.meta.providerId && + contract.provenance.modelId === value.meta.modelId && + contract.provenance.promptHash === value.hashes.promptHash && + contract.provenance.providerVisibleToolDefinitionsHash === value.hashes.toolDefinitionsHash && + typeof value.hashes.manifestHash === 'string' && + value.viewId === `view_${value.hashes.manifestHash.slice(0, 16)}` + ) +} + export function isTapeViewManifest( value: unknown, sessionId: string @@ -146,7 +175,8 @@ export function isTapeViewManifest( value.schemaVersion === 1 || value.schemaVersion === 2 || value.schemaVersion === 3 || - value.schemaVersion === 4 + value.schemaVersion === 4 || + value.schemaVersion === 5 ? value.schemaVersion : null if (schemaVersion === null) return false @@ -185,6 +215,7 @@ export function isTapeViewManifest( ]) && hasStringFields(value.hashes, ['promptHash', 'toolDefinitionsHash', 'manifestHash']) && isViewManifestMeta(value.meta) && + hasExecutionContractForSchema(value, schemaVersion) && typeof value.assembledAt === 'number' ) } diff --git a/src/main/tape/domain/tapeIdentity.ts b/src/main/tape/domain/tapeIdentity.ts new file mode 100644 index 000000000..56b71a527 --- /dev/null +++ b/src/main/tape/domain/tapeIdentity.ts @@ -0,0 +1,24 @@ +import { createHash } from 'node:crypto' +import type { DeepChatTapeEntryRow } from './entry' + +export const TAPE_IDENTITY_PATTERN = /^[a-f0-9]{64}$/u + +export function computeTapeIdentity(row: DeepChatTapeEntryRow): string { + return createHash('sha256') + .update( + JSON.stringify([ + row.session_id, + row.entry_id, + row.kind, + row.name, + row.source_type, + row.source_id, + row.source_seq, + row.provenance_key, + row.payload_json, + row.meta_json, + row.created_at + ]) + ) + .digest('hex') +} diff --git a/src/main/tape/domain/taskContract.ts b/src/main/tape/domain/taskContract.ts new file mode 100644 index 000000000..46764e395 --- /dev/null +++ b/src/main/tape/domain/taskContract.ts @@ -0,0 +1,380 @@ +import { Buffer } from 'node:buffer' +import { + DEEPCHAT_TASK_CONTRACT_HASH_VERSION, + DEEPCHAT_TASK_CONTRACT_SCHEMA_VERSION, + MAX_TASK_CONTRACT_BYTES, + MAX_TASK_CONTRACT_REQUIREMENTS, + type DeepChatEvaluationRef, + type DeepChatTaskContract, + type DeepChatTaskContractRef, + type DeepChatHandoffFormatRequirement, + type DeepChatTaskWorkspaceCeiling +} from '@shared/types/task-contract' +import { canonicalJsonStringifyData, hashJsonData } from './canonicalJson' +import { normalizeAbsoluteWorkspacePath } from './workspacePath' + +const MAX_IDENTITY_BYTES = 1_024 +const MAX_TITLE_BYTES = 1_024 +const MAX_PROMPT_BYTES = 64 * 1024 +const MAX_SECTION_NAME_BYTES = 256 +const MAX_WORKSPACE_PATH_BYTES = 32 * 1024 +const MAX_TASK_INPUT_BYTES = 64 * 1024 +const MAX_SUBAGENT_DEPTH = 1 +const SHA_256_PATTERN = /^[0-9a-f]{64}$/u + +const TASK_CONTRACT_KEYS = [ + 'schemaVersion', + 'hashVersion', + 'taskSchema', + 'taskConfig', + 'taskDescription', + 'taskHarness', + 'contractHash' +] as const + +const TASK_CONTRACT_REF_KEYS = [ + 'schemaVersion', + 'sessionId', + 'tapeIdentity', + 'entryId', + 'contractHash' +] as const + +export interface BuildTaskContractInput { + delegationId: string + turnId: string + turnSeq: number + turnKind: 'initial' | 'follow_up' + parentSessionId: string + slotId: string + targetAgentId: string + title: string + prompt: string + workspace: DeepChatTaskWorkspaceCeiling + handoffFormat: readonly DeepChatHandoffFormatRequirement[] + creationReason?: 'delegation_created' | 'legacy_recovery' + predecessorEvaluationRef?: DeepChatEvaluationRef | null + maxToolEffect?: 'read' | 'write' + maxSubagentDepth?: number +} + +export class TaskContractError extends Error { + constructor( + message: string, + readonly code: 'invalid_input' | 'limit_exceeded', + options?: ErrorOptions + ) { + super(message, options) + this.name = 'TaskContractError' + } +} + +function utf8Length(value: string): number { + return Buffer.byteLength(value, 'utf8') +} + +function requireString( + value: unknown, + label: string, + maxBytes: number, + maxCharacters?: number +): string { + if (typeof value !== 'string') { + throw new TaskContractError(`${label} must be a string.`, 'invalid_input') + } + const normalized = value.trim() + if ( + !normalized || + normalized.includes('\0') || + utf8Length(normalized) > maxBytes || + (maxCharacters !== undefined && normalized.length > maxCharacters) + ) { + throw new TaskContractError( + `${label} must contain between 1 and ${maxBytes} UTF-8 bytes.`, + 'invalid_input' + ) + } + return normalized +} + +function requirePositiveSafeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new TaskContractError(`${label} must be a positive safe integer.`, 'invalid_input') + } + return value as number +} + +function requireNonNegativeSafeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TaskContractError(`${label} must be a non-negative safe integer.`, 'invalid_input') + } + return value as number +} + +function compareCodePoints(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function normalizeWorkspace(workspace: DeepChatTaskWorkspaceCeiling): DeepChatTaskWorkspaceCeiling { + if (workspace?.kind === 'runtime_default') return { kind: 'runtime_default' } + if (workspace?.kind !== 'path' || typeof workspace.path !== 'string') { + throw new TaskContractError('workspace.kind is invalid.', 'invalid_input') + } + const normalized = normalizeAbsoluteWorkspacePath(workspace.path) + if ( + !workspace.path || + workspace.path.includes('\0') || + utf8Length(workspace.path) > MAX_WORKSPACE_PATH_BYTES || + normalized === null + ) { + throw new TaskContractError('workspace.path must be a bounded absolute path.', 'invalid_input') + } + return { kind: 'path', path: normalized.path } +} + +function normalizeEvaluationRef(value: DeepChatEvaluationRef | null): DeepChatEvaluationRef | null { + if (value === null) return null + const sessionId = requireString( + value?.sessionId, + 'predecessorEvaluationRef.sessionId', + MAX_IDENTITY_BYTES, + 256 + ) + const entryId = requirePositiveSafeInteger(value?.entryId, 'predecessorEvaluationRef.entryId') + if ( + value?.schemaVersion !== 1 || + !SHA_256_PATTERN.test(value.tapeIdentity) || + !SHA_256_PATTERN.test(value.evaluationHash) + ) { + throw new TaskContractError('predecessorEvaluationRef is invalid.', 'invalid_input') + } + return { + schemaVersion: 1, + sessionId, + tapeIdentity: value.tapeIdentity, + entryId, + evaluationHash: value.evaluationHash + } +} + +function normalizeHandoffFormat( + requirements: readonly DeepChatHandoffFormatRequirement[] +): DeepChatHandoffFormatRequirement[] { + if (!Array.isArray(requirements)) { + throw new TaskContractError('handoffFormat must be an array.', 'invalid_input') + } + if (requirements.length > MAX_TASK_CONTRACT_REQUIREMENTS) { + throw new TaskContractError( + `handoffFormat exceeds ${MAX_TASK_CONTRACT_REQUIREMENTS} requirements.`, + 'limit_exceeded' + ) + } + const ids = new Set() + const normalized = requirements.map((requirement, index) => { + const label = `handoffFormat[${index}]` + const id = requireString(requirement?.id, `${label}.id`, MAX_IDENTITY_BYTES) + if (ids.has(id)) { + throw new TaskContractError( + `Handoff format requirement ID is duplicated: ${id}.`, + 'invalid_input' + ) + } + ids.add(id) + + if (requirement.kind !== 'required_sections' || requirement.level !== 2) { + throw new TaskContractError(`${label}.kind is invalid.`, 'invalid_input') + } + if (!Array.isArray(requirement.sections)) { + throw new TaskContractError(`${label} is invalid.`, 'invalid_input') + } + const seenSections = new Set() + const sections = requirement.sections.map((section, sectionIndex) => { + const normalizedSection = requireString( + section, + `${label}.sections[${sectionIndex}]`, + MAX_SECTION_NAME_BYTES + ) + const identity = normalizedSection.toLowerCase() + if (seenSections.has(identity)) { + throw new TaskContractError( + `${label} contains a duplicate section: ${normalizedSection}.`, + 'invalid_input' + ) + } + seenSections.add(identity) + return normalizedSection + }) + if (sections.length === 0 || sections.length > MAX_TASK_CONTRACT_REQUIREMENTS) { + throw new TaskContractError(`${label}.sections has an invalid size.`, 'invalid_input') + } + sections.sort(compareCodePoints) + return { id, kind: 'required_sections' as const, level: 2 as const, sections } + }) + return normalized.sort((left, right) => compareCodePoints(left.id, right.id)) +} + +function deepFreeze(value: T): T { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value + for (const nested of Object.values(value as Record)) deepFreeze(nested) + return Object.freeze(value) +} + +function hasExactKeys(value: unknown, keys: readonly string[]): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const actual = Object.keys(value) + return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key)) +} + +function buildTaskContractDraft( + input: BuildTaskContractInput +): Omit { + const maxSubagentDepth = requireNonNegativeSafeInteger( + input.maxSubagentDepth ?? 0, + 'maxSubagentDepth' + ) + if (maxSubagentDepth > MAX_SUBAGENT_DEPTH) { + throw new TaskContractError( + `maxSubagentDepth exceeds the V1 limit of ${MAX_SUBAGENT_DEPTH}.`, + 'limit_exceeded' + ) + } + const maxToolEffect = input.maxToolEffect ?? 'write' + if (maxToolEffect !== 'read' && maxToolEffect !== 'write') { + throw new TaskContractError('maxToolEffect is invalid.', 'invalid_input') + } + const creationReason = input.creationReason ?? 'delegation_created' + if (creationReason !== 'delegation_created' && creationReason !== 'legacy_recovery') { + throw new TaskContractError('creationReason is invalid.', 'invalid_input') + } + const parentSessionId = requireString( + input.parentSessionId, + 'parentSessionId', + MAX_IDENTITY_BYTES, + 256 + ) + const predecessorEvaluationRef = normalizeEvaluationRef(input.predecessorEvaluationRef ?? null) + if (predecessorEvaluationRef && predecessorEvaluationRef.sessionId !== parentSessionId) { + throw new TaskContractError( + 'predecessorEvaluationRef must belong to the parent Session.', + 'invalid_input' + ) + } + + return { + schemaVersion: DEEPCHAT_TASK_CONTRACT_SCHEMA_VERSION, + hashVersion: DEEPCHAT_TASK_CONTRACT_HASH_VERSION, + taskSchema: { + input: { kind: 'text', maxBytes: MAX_TASK_INPUT_BYTES }, + output: { kind: 'markdown' } + }, + taskConfig: { + completionMode: 'single_response', + retryMode: 'parent_follow_up', + creationReason, + predecessorEvaluationRef + }, + taskDescription: { + delegationId: requireString(input.delegationId, 'delegationId', MAX_IDENTITY_BYTES, 256), + turnId: requireString(input.turnId, 'turnId', MAX_IDENTITY_BYTES, 256), + turnSeq: requirePositiveSafeInteger(input.turnSeq, 'turnSeq'), + turnKind: input.turnKind, + parentSessionId, + slotId: requireString(input.slotId, 'slotId', MAX_IDENTITY_BYTES, 256), + targetAgentId: requireString(input.targetAgentId, 'targetAgentId', MAX_IDENTITY_BYTES, 256), + title: requireString(input.title, 'title', MAX_TITLE_BYTES, 160), + prompt: requireString(input.prompt, 'prompt', MAX_PROMPT_BYTES) + }, + taskHarness: { + acceptance: normalizeHandoffFormat(input.handoffFormat), + ceilings: { + maxToolEffect, + workspace: normalizeWorkspace(input.workspace), + maxSubagentDepth + } + } + } +} + +export function buildTaskContract(input: BuildTaskContractInput): DeepChatTaskContract { + if (input.turnKind !== 'initial' && input.turnKind !== 'follow_up') { + throw new TaskContractError('turnKind is invalid.', 'invalid_input') + } + const draft = buildTaskContractDraft(input) + const contract: DeepChatTaskContract = { + ...draft, + contractHash: hashJsonData(draft) + } + if (utf8Length(canonicalJsonStringifyData(contract)) > MAX_TASK_CONTRACT_BYTES) { + throw new TaskContractError( + `TaskContract exceeds ${MAX_TASK_CONTRACT_BYTES} UTF-8 bytes.`, + 'limit_exceeded' + ) + } + return deepFreeze(contract) +} + +export function isDeepChatTaskContract(value: unknown): value is DeepChatTaskContract { + if ( + !hasExactKeys(value, TASK_CONTRACT_KEYS) || + value.schemaVersion !== DEEPCHAT_TASK_CONTRACT_SCHEMA_VERSION || + value.hashVersion !== DEEPCHAT_TASK_CONTRACT_HASH_VERSION || + typeof value.contractHash !== 'string' || + !SHA_256_PATTERN.test(value.contractHash) + ) { + return false + } + try { + const contract = value as unknown as DeepChatTaskContract + const normalized = buildTaskContract({ + ...contract.taskDescription, + workspace: contract.taskHarness.ceilings.workspace, + handoffFormat: contract.taskHarness.acceptance, + creationReason: contract.taskConfig.creationReason, + predecessorEvaluationRef: contract.taskConfig.predecessorEvaluationRef, + maxToolEffect: contract.taskHarness.ceilings.maxToolEffect, + maxSubagentDepth: contract.taskHarness.ceilings.maxSubagentDepth + }) + return canonicalJsonStringifyData(normalized) === canonicalJsonStringifyData(contract) + } catch { + return false + } +} + +export function restoreTaskContract(value: unknown): DeepChatTaskContract | null { + return isDeepChatTaskContract(value) ? deepFreeze(value) : null +} + +export function serializeTaskContract(contract: DeepChatTaskContract): string { + if (!isDeepChatTaskContract(contract)) { + throw new TaskContractError('TaskContract is not canonical.', 'invalid_input') + } + return canonicalJsonStringifyData(contract) +} + +export function serializeTaskContractRef(ref: DeepChatTaskContractRef): string { + if (!isDeepChatTaskContractRef(ref)) { + throw new TaskContractError('TaskContractRef is invalid.', 'invalid_input') + } + return canonicalJsonStringifyData(ref) +} + +export function isDeepChatTaskContractRef(value: unknown): value is DeepChatTaskContractRef { + if (!hasExactKeys(value, TASK_CONTRACT_REF_KEYS) || value.schemaVersion !== 1) return false + try { + return ( + requireString(value.sessionId, 'TaskContractRef.sessionId', MAX_IDENTITY_BYTES, 256) === + value.sessionId && + typeof value.tapeIdentity === 'string' && + SHA_256_PATTERN.test(value.tapeIdentity) && + typeof value.contractHash === 'string' && + SHA_256_PATTERN.test(value.contractHash) && + requirePositiveSafeInteger(value.entryId, 'TaskContractRef.entryId') === value.entryId + ) + } catch { + return false + } +} + +export function restoreTaskContractRef(value: unknown): DeepChatTaskContractRef | null { + return isDeepChatTaskContractRef(value) ? deepFreeze(value) : null +} diff --git a/src/main/tape/domain/taskEvaluation.ts b/src/main/tape/domain/taskEvaluation.ts new file mode 100644 index 000000000..4bba818ef --- /dev/null +++ b/src/main/tape/domain/taskEvaluation.ts @@ -0,0 +1,544 @@ +import { Buffer } from 'node:buffer' +import { createHash } from 'node:crypto' +import { + DEEPCHAT_EVALUATION_REF_SCHEMA_VERSION, + DEEPCHAT_TASK_EVALUATION_REASON_CODES, + DEEPCHAT_TASK_EVALUATION_HASH_VERSION, + DEEPCHAT_TASK_EVALUATION_SCHEMA_VERSION, + DEEPCHAT_TASK_EVALUATOR_VERSION, + DeepChatLegacyTaskEvaluationProjectionSchema, + DeepChatTaskEvaluationProjectionSchema, + MAX_TASK_EVALUATION_BYTES, + MAX_TASK_EVALUATION_CANDIDATE_BYTES, + MAX_TASK_EVALUATION_PARENT_EVIDENCE, + MAX_TASK_EVALUATION_RECORDS, + type DeepChatEvaluationRef, + type DeepChatLegacyTaskEvaluation, + type DeepChatLegacyTaskEvaluationReasonCode, + type DeepChatLegacyTaskEvaluationRecord, + type DeepChatStoredTaskEvaluation, + type DeepChatTaskContract, + type DeepChatTaskEvaluation, + type DeepChatTaskEvaluationExecutionStatus, + type DeepChatTaskEvaluationReasonCode, + type DeepChatTaskEvaluationRecord, + type DeepChatTaskEvaluationSummary +} from '@shared/types/task-contract' +import { indexMarkdownLevelTwoSections } from '@shared/orchestration/liveDelegationMarkdown' +import { canonicalJsonStringifyData, hashJsonData } from './canonicalJson' +import { isDeepChatTaskContract } from './taskContract' + +const SHA_256_PATTERN = /^[0-9a-f]{64}$/u +const SUCCESS_REASON_CODES = new Set([ + 'required_sections_present' +]) +const LEGACY_SUCCESS_REASON_CODES = new Set([ + 'required_sections_present', + 'result_schema_valid' +]) +const CURRENT_REASON_CODES = new Set(DEEPCHAT_TASK_EVALUATION_REASON_CODES) + +export interface BuildTaskEvaluationInput { + contract: DeepChatTaskContract + executionStatus: DeepChatTaskEvaluationExecutionStatus + candidateResult: string | null +} + +export class TaskEvaluationError extends Error { + constructor( + message: string, + readonly code: 'invalid_input' | 'limit_exceeded', + options?: ErrorOptions + ) { + super(message, options) + this.name = 'TaskEvaluationError' + } +} + +export function buildTaskEvaluation(input: BuildTaskEvaluationInput): DeepChatTaskEvaluation { + if (!isDeepChatTaskContract(input.contract)) { + throw new TaskEvaluationError( + 'Task evaluation requires a canonical TaskContract.', + 'invalid_input' + ) + } + if (!['completed', 'failed', 'cancelled', 'interrupted'].includes(input.executionStatus)) { + throw new TaskEvaluationError('Task evaluation execution status is invalid.', 'invalid_input') + } + if (input.candidateResult !== null && typeof input.candidateResult !== 'string') { + throw new TaskEvaluationError('Task evaluation candidate is invalid.', 'invalid_input') + } + + const candidateResult = input.candidateResult?.trim() || null + const candidate = candidateResult + ? { + kind: 'answer' as const, + sha256: createHash('sha256').update(candidateResult, 'utf8').digest('hex'), + utf8Bytes: Buffer.byteLength(candidateResult, 'utf8') + } + : ({ kind: 'absent' } as const) + let records: DeepChatTaskEvaluationRecord[] + + if (input.executionStatus === 'cancelled' || input.executionStatus === 'interrupted') { + records = [ + evaluationRecord({ + outcome: 'indeterminate', + code: + input.executionStatus === 'cancelled' ? 'execution_cancelled' : 'execution_interrupted' + }) + ] + } else if (!candidateResult) { + records = [evaluationRecord({ outcome: 'indeterminate', code: 'candidate_missing' })] + } else if ( + candidate.kind === 'answer' && + candidate.utf8Bytes > MAX_TASK_EVALUATION_CANDIDATE_BYTES + ) { + records = [evaluationRecord({ outcome: 'indeterminate', code: 'candidate_too_large' })] + } else { + records = evaluateRequirements(input.contract, candidateResult) + } + + const formatStatus = records.some((record) => record.outcome === 'invalid') + ? 'invalid' + : records.some((record) => record.outcome === 'indeterminate') + ? 'indeterminate' + : 'valid' + const reasonCodes = [ + ...new Set(records.filter((record) => record.outcome !== 'valid').map((record) => record.code)) + ].sort(compareCodePoints) + + return finalizeEvaluation({ + schemaVersion: DEEPCHAT_TASK_EVALUATION_SCHEMA_VERSION, + hashVersion: DEEPCHAT_TASK_EVALUATION_HASH_VERSION, + evaluatorVersion: DEEPCHAT_TASK_EVALUATOR_VERSION, + evaluationKind: 'handoff_format', + turnId: input.contract.taskDescription.turnId, + taskContractHash: input.contract.contractHash, + candidate, + executionStatus: input.executionStatus, + formatStatus, + reasonCodes, + records, + omittedRecordCount: 0 + }) +} + +export function restoreTaskEvaluation(value: unknown): DeepChatTaskEvaluation | null { + const parsed = DeepChatTaskEvaluationProjectionSchema.safeParse(value) + if (!parsed.success) return null + const evaluation = parsed.data + if ( + Buffer.byteLength(canonicalJsonStringifyData(evaluation), 'utf8') > MAX_TASK_EVALUATION_BYTES + ) { + return null + } + const { evaluationHash, ...draft } = evaluation + if (hashJsonData(draft) !== evaluationHash) return null + if (!isCanonicalEvaluation(evaluation)) return null + return deepFreeze(evaluation) +} + +export function restoreStoredTaskEvaluation(value: unknown): DeepChatStoredTaskEvaluation | null { + const current = restoreTaskEvaluation(value) + if (current) return current + + const parsed = DeepChatLegacyTaskEvaluationProjectionSchema.safeParse(value) + if (!parsed.success) return null + const evaluation = parsed.data + if ( + Buffer.byteLength(canonicalJsonStringifyData(evaluation), 'utf8') > MAX_TASK_EVALUATION_BYTES + ) { + return null + } + const { evaluationHash, ...draft } = evaluation + if (hashJsonData(draft) !== evaluationHash) return null + if (!isCanonicalLegacyEvaluation(evaluation) || !isProjectableLegacyEvaluation(evaluation)) { + return null + } + return deepFreeze(evaluation) +} + +export function isDeepChatTaskEvaluation(value: unknown): value is DeepChatTaskEvaluation { + return restoreTaskEvaluation(value) !== null +} + +export function serializeTaskEvaluation(evaluation: DeepChatTaskEvaluation): string { + const restored = restoreTaskEvaluation(evaluation) + if (!restored) throw new TaskEvaluationError('Task evaluation is invalid.', 'invalid_input') + return canonicalJsonStringifyData(restored) +} + +export function isDeepChatEvaluationRef(value: unknown): value is DeepChatEvaluationRef { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const ref = value as Record + return ( + Object.keys(ref).length === 5 && + ref.schemaVersion === DEEPCHAT_EVALUATION_REF_SCHEMA_VERSION && + typeof ref.sessionId === 'string' && + ref.sessionId.trim() === ref.sessionId && + ref.sessionId.length > 0 && + ref.sessionId.length <= 256 && + typeof ref.tapeIdentity === 'string' && + SHA_256_PATTERN.test(ref.tapeIdentity) && + Number.isSafeInteger(ref.entryId) && + (ref.entryId as number) > 0 && + typeof ref.evaluationHash === 'string' && + SHA_256_PATTERN.test(ref.evaluationHash) + ) +} + +export function restoreEvaluationRef(value: unknown): DeepChatEvaluationRef | null { + return isDeepChatEvaluationRef(value) ? Object.freeze({ ...value }) : null +} + +export function serializeEvaluationRef(ref: DeepChatEvaluationRef): string { + if (!isDeepChatEvaluationRef(ref)) { + throw new TaskEvaluationError('Task evaluation reference is invalid.', 'invalid_input') + } + return canonicalJsonStringifyData(ref) +} + +export function projectTaskEvaluationSummary( + evaluation: DeepChatStoredTaskEvaluation, + evaluationRef: DeepChatEvaluationRef, + maxEvidenceRecords = MAX_TASK_EVALUATION_PARENT_EVIDENCE +): DeepChatTaskEvaluationSummary { + const canonicalEvaluation = restoreStoredTaskEvaluation(evaluation) + const canonicalRef = restoreEvaluationRef(evaluationRef) + if ( + !canonicalEvaluation || + !canonicalRef || + canonicalRef.evaluationHash !== canonicalEvaluation.evaluationHash + ) { + throw new TaskEvaluationError('Task evaluation summary inputs conflict.', 'invalid_input') + } + if (!Number.isSafeInteger(maxEvidenceRecords) || maxEvidenceRecords < 0) { + throw new TaskEvaluationError('Task evaluation evidence limit is invalid.', 'invalid_input') + } + const evidenceLimit = Math.min(maxEvidenceRecords, MAX_TASK_EVALUATION_PARENT_EVIDENCE) + const projected = + canonicalEvaluation.schemaVersion === DEEPCHAT_TASK_EVALUATION_SCHEMA_VERSION + ? { + formatStatus: canonicalEvaluation.formatStatus, + reasonCodes: canonicalEvaluation.reasonCodes, + records: canonicalEvaluation.records + } + : projectLegacyEvaluation(canonicalEvaluation) + const relevant = projected.records.filter((record) => record.outcome !== 'valid') + const evidence = relevant.slice(0, evidenceLimit) + return deepFreeze({ + evaluationKind: 'handoff_format', + formatStatus: projected.formatStatus, + reasonCodes: [...projected.reasonCodes], + candidate: canonicalEvaluation.candidate, + evidence, + evaluationRef: canonicalRef, + omittedEvidenceCount: + canonicalEvaluation.omittedRecordCount + Math.max(0, relevant.length - evidence.length) + }) +} + +function projectLegacyEvaluation(evaluation: DeepChatLegacyTaskEvaluation): { + formatStatus: DeepChatTaskEvaluation['formatStatus'] + reasonCodes: readonly DeepChatTaskEvaluationReasonCode[] + records: readonly DeepChatTaskEvaluationRecord[] +} { + const records = evaluation.records.map( + (record): DeepChatTaskEvaluationRecord => ({ + requirementId: record.requirementId, + requirementKind: record.requirementKind as 'required_sections' | null, + outcome: + record.outcome === 'passed' + ? 'valid' + : record.outcome === 'failed' + ? 'invalid' + : 'indeterminate', + code: record.code as DeepChatTaskEvaluationReasonCode, + section: record.section, + additionalEvidenceCount: record.additionalEvidenceCount + }) + ) + const formatStatus = records.some((record) => record.outcome === 'invalid') + ? 'invalid' + : records.some((record) => record.outcome === 'indeterminate') + ? 'indeterminate' + : 'valid' + return { + formatStatus, + reasonCodes: evaluation.reasonCodes as readonly DeepChatTaskEvaluationReasonCode[], + records + } +} + +function evaluateRequirements( + contract: DeepChatTaskContract, + candidateResult: string +): DeepChatTaskEvaluationRecord[] { + const sections = indexMarkdownLevelTwoSections(candidateResult) + + return contract.taskHarness.acceptance.map((requirement) => { + const missing = requirement.sections.filter( + (section) => !(sections.get(section.toLowerCase())?.body.trim() ?? '') + ) + return evaluationRecord({ + requirementId: requirement.id, + requirementKind: requirement.kind, + outcome: missing.length === 0 ? 'valid' : 'invalid', + code: missing.length === 0 ? 'required_sections_present' : 'required_sections_missing', + section: missing[0] ?? null, + additionalEvidenceCount: Math.max(0, missing.length - 1) + }) + }) +} + +function evaluationRecord( + input: Partial & + Pick +): DeepChatTaskEvaluationRecord { + return { + requirementId: input.requirementId ?? null, + requirementKind: input.requirementKind ?? null, + outcome: input.outcome, + code: input.code, + section: input.section ?? null, + additionalEvidenceCount: input.additionalEvidenceCount ?? 0 + } +} + +function finalizeEvaluation( + input: Omit +): DeepChatTaskEvaluation { + const records = [...input.records].slice(0, MAX_TASK_EVALUATION_RECORDS) + let omittedRecordCount = + input.omittedRecordCount + Math.max(0, input.records.length - records.length) + + while (true) { + const draft: Omit = { + ...input, + records, + omittedRecordCount + } + const evaluation: DeepChatTaskEvaluation = { + ...draft, + evaluationHash: hashJsonData(draft) + } + const serialized = canonicalJsonStringifyData(evaluation) + if (Buffer.byteLength(serialized, 'utf8') <= MAX_TASK_EVALUATION_BYTES) { + const restored = restoreTaskEvaluation(evaluation) + if (!restored) { + throw new TaskEvaluationError('Task evaluation is not canonical.', 'invalid_input') + } + return restored + } + if (records.length === 0) { + throw new TaskEvaluationError( + `Task evaluation exceeds ${MAX_TASK_EVALUATION_BYTES} UTF-8 bytes.`, + 'limit_exceeded' + ) + } + records.pop() + omittedRecordCount += 1 + } +} + +function isCanonicalEvaluation(evaluation: DeepChatTaskEvaluation): boolean { + if (evaluation.reasonCodes.some((code) => SUCCESS_REASON_CODES.has(code))) return false + if ( + canonicalJsonStringifyData(evaluation.reasonCodes) !== + canonicalJsonStringifyData([...new Set(evaluation.reasonCodes)].sort(compareCodePoints)) + ) { + return false + } + const recordedReasonCodes = [ + ...new Set( + evaluation.records.filter((record) => record.outcome !== 'valid').map((record) => record.code) + ) + ].sort(compareCodePoints) + if ( + evaluation.omittedRecordCount === 0 && + canonicalJsonStringifyData(evaluation.reasonCodes) !== + canonicalJsonStringifyData(recordedReasonCodes) + ) { + return false + } + const reasonOutcomes = evaluation.reasonCodes.map(reasonCodeOutcome) + const expectedFormatStatus = reasonOutcomes.includes('invalid') + ? 'invalid' + : reasonOutcomes.includes('indeterminate') + ? 'indeterminate' + : 'valid' + if (evaluation.formatStatus !== expectedFormatStatus) return false + return evaluation.records.every( + (record) => + recordMatchesReasonCode(record) && + (record.outcome === 'valid' || evaluation.reasonCodes.includes(record.code)) + ) +} + +function isCanonicalLegacyEvaluation(evaluation: DeepChatLegacyTaskEvaluation): boolean { + if ((evaluation.verdict === 'passed') !== (evaluation.disposition === 'accepted')) return false + if (evaluation.reasonCodes.some((code) => LEGACY_SUCCESS_REASON_CODES.has(code))) return false + if ( + canonicalJsonStringifyData(evaluation.reasonCodes) !== + canonicalJsonStringifyData([...new Set(evaluation.reasonCodes)].sort(compareCodePoints)) + ) { + return false + } + const recordedReasonCodes = [ + ...new Set( + evaluation.records + .filter((record) => record.outcome !== 'passed') + .map((record) => record.code) + ) + ].sort(compareCodePoints) + if ( + evaluation.omittedRecordCount === 0 && + canonicalJsonStringifyData(evaluation.reasonCodes) !== + canonicalJsonStringifyData(recordedReasonCodes) + ) { + return false + } + const reasonOutcomes = evaluation.reasonCodes.map(legacyReasonCodeOutcome) + const expectedVerdict = reasonOutcomes.includes('failed') + ? 'failed' + : reasonOutcomes.includes('indeterminate') + ? 'indeterminate' + : 'passed' + if (evaluation.verdict !== expectedVerdict) return false + return evaluation.records.every( + (record) => + legacyRecordMatchesReasonCode(record) && + (record.outcome === 'passed' || evaluation.reasonCodes.includes(record.code)) + ) +} + +function isProjectableLegacyEvaluation(evaluation: DeepChatLegacyTaskEvaluation): boolean { + return ( + evaluation.omittedRecordCount === 0 && + evaluation.reasonCodes.every((code) => CURRENT_REASON_CODES.has(code)) && + evaluation.records.every( + (record) => + record.requirementKind !== 'result_schema' && + CURRENT_REASON_CODES.has(record.code) && + record.instancePath === null && + record.keyword === null + ) && + legacyEvaluationMatchesWriterState(evaluation) + ) +} + +function legacyEvaluationMatchesWriterState(evaluation: DeepChatLegacyTaskEvaluation): boolean { + if (evaluation.candidate.kind === 'answer' && evaluation.candidate.utf8Bytes === 0) return false + if (evaluation.executionStatus === 'cancelled') { + return hasOnlyLegacyStateRecord(evaluation.records, 'execution_cancelled') + } + if (evaluation.executionStatus === 'interrupted') { + return hasOnlyLegacyStateRecord(evaluation.records, 'execution_interrupted') + } + if (evaluation.candidate.kind === 'absent') { + return hasOnlyLegacyStateRecord(evaluation.records, 'candidate_missing') + } + if (evaluation.candidate.utf8Bytes > MAX_TASK_EVALUATION_CANDIDATE_BYTES) { + return hasOnlyLegacyStateRecord(evaluation.records, 'candidate_too_large') + } + + const requirementIds = new Set() + let previousRequirementId: string | null = null + return evaluation.records.every((record) => { + if ( + record.requirementId === null || + record.requirementKind !== 'required_sections' || + requirementIds.has(record.requirementId) || + (previousRequirementId !== null && + compareCodePoints(previousRequirementId, record.requirementId) >= 0) + ) { + return false + } + requirementIds.add(record.requirementId) + previousRequirementId = record.requirementId + return record.code === 'required_sections_present' + ? record.outcome === 'passed' && + record.section === null && + record.additionalEvidenceCount === 0 + : record.code === 'required_sections_missing' && + record.outcome === 'failed' && + record.section !== null + }) +} + +function hasOnlyLegacyStateRecord( + records: readonly DeepChatLegacyTaskEvaluationRecord[], + code: + | 'candidate_missing' + | 'candidate_too_large' + | 'execution_cancelled' + | 'execution_interrupted' +): boolean { + if (records.length !== 1) return false + const [record] = records + return ( + record.requirementId === null && + record.requirementKind === null && + record.outcome === 'indeterminate' && + record.code === code && + record.section === null && + record.instancePath === null && + record.keyword === null && + record.additionalEvidenceCount === 0 + ) +} + +function legacyRecordMatchesReasonCode(record: DeepChatLegacyTaskEvaluationRecord): boolean { + const expectedOutcome = legacyReasonCodeOutcome(record.code) + if (record.outcome !== expectedOutcome) return false + const requirementCode = + record.code.startsWith('required_sections_') || + record.code.startsWith('result_') || + record.code === 'candidate_too_complex' || + record.code === 'evaluator_error' + return requirementCode + ? record.requirementId !== null && record.requirementKind !== null + : record.requirementId === null && record.requirementKind === null +} + +function legacyReasonCodeOutcome( + code: DeepChatLegacyTaskEvaluationReasonCode +): DeepChatLegacyTaskEvaluationRecord['outcome'] { + if (LEGACY_SUCCESS_REASON_CODES.has(code)) return 'passed' + if ( + code === 'required_sections_missing' || + code === 'result_section_missing' || + code === 'result_json_invalid' || + code === 'result_schema_mismatch' + ) { + return 'failed' + } + return 'indeterminate' +} + +function recordMatchesReasonCode(record: DeepChatTaskEvaluationRecord): boolean { + const expectedOutcome = reasonCodeOutcome(record.code) + if (record.outcome !== expectedOutcome) return false + const requirementCode = record.code.startsWith('required_sections_') + return requirementCode + ? record.requirementId !== null && record.requirementKind !== null + : record.requirementId === null && record.requirementKind === null +} + +function reasonCodeOutcome( + code: DeepChatTaskEvaluationReasonCode +): DeepChatTaskEvaluationRecord['outcome'] { + if (SUCCESS_REASON_CODES.has(code)) return 'valid' + if (code === 'required_sections_missing') return 'invalid' + return 'indeterminate' +} + +function compareCodePoints(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function deepFreeze(value: T): T { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value + for (const nested of Object.values(value as Record)) deepFreeze(nested) + return Object.freeze(value) +} diff --git a/src/main/tape/domain/viewManifest.ts b/src/main/tape/domain/viewManifest.ts index 8de57021a..616b6504d 100644 --- a/src/main/tape/domain/viewManifest.ts +++ b/src/main/tape/domain/viewManifest.ts @@ -1,19 +1,27 @@ import type { ChatMessage } from '@shared/types/core/chat-message' import { stripToolExecutionContract, type MCPToolDefinition } from '@shared/types/core/mcp' import type { ChatMessageRecord } from '@shared/types/agent-interface' +import type { DeepChatExecutionContract } from '@shared/types/execution-contract' import type { DeepChatTapeViewEntryRef, DeepChatTapeViewExcludedRange, DeepChatTapeViewExcludedRef, DeepChatTapeViewManifest, DeepChatTapeViewManifestIntegrity, + DeepChatTapeViewManifestLegacy, DeepChatTapeViewPolicy, + DeepChatTapeViewManifestV5, DeepChatTapeViewSyntheticContribution, DeepChatTapeViewTaskType, DeepChatTapeViewTokenBudget } from '@shared/types/tape-view-manifest' import { estimateMessagesTokens } from '@shared/utils/messageTokens' -import { hashJson } from './canonicalJson' +import { hashJson, hashJsonData } from './canonicalJson' +import { + buildProviderMessagesHash, + buildProviderVisibleToolDefinitionsHash, + isDeepChatExecutionContract +} from './executionContract' export { hashJson, stableJsonStringify } from './canonicalJson' @@ -66,6 +74,7 @@ export type TapeViewManifestBuildInput = { supportsVision: boolean supportsAudioInput: boolean traceDebugEnabled: boolean + executionContract?: DeepChatExecutionContract assembledAt?: number } @@ -119,9 +128,11 @@ export function resolveTapeViewManifestPolicy( } } -export const TAPE_VIEW_MANIFEST_HASH_VERSION = 2 +const TAPE_VIEW_MANIFEST_LEGACY_HASH_VERSION = 2 +/** Current hash version for contract-bearing ViewManifest values. */ +export const TAPE_VIEW_MANIFEST_HASH_VERSION = 3 -function buildManifestHash(manifest: DeepChatTapeViewManifest): string { +function buildManifestHashable(manifest: DeepChatTapeViewManifest): Record { const hashable: Record = { ...manifest } delete hashable.assembledAt delete hashable.viewId @@ -129,7 +140,80 @@ function buildManifestHash(manifest: DeepChatTapeViewManifest): string { promptHash: manifest.hashes.promptHash, toolDefinitionsHash: manifest.hashes.toolDefinitionsHash } - return hashJson(hashable) + return hashable +} + +function buildManifestHashV2(manifest: DeepChatTapeViewManifest): string { + return hashJson(buildManifestHashable(manifest)) +} + +function buildManifestHashV3(manifest: DeepChatTapeViewManifest): string { + return hashJsonData(buildManifestHashable(manifest)) +} + +function executionContractMatchesManifest(manifest: DeepChatTapeViewManifestV5): boolean { + const contract = manifest.executionContract + return ( + isDeepChatExecutionContract(contract) && + contract.request.sessionId === manifest.sessionId && + contract.request.messageId === manifest.messageId && + contract.request.requestSeq === manifest.requestSeq && + contract.provenance.providerId === manifest.meta.providerId && + contract.provenance.modelId === manifest.meta.modelId && + contract.provenance.promptHash === manifest.hashes.promptHash && + contract.provenance.providerVisibleToolDefinitionsHash === manifest.hashes.toolDefinitionsHash + ) +} + +function isDeeplyFrozen(value: unknown): boolean { + if (!value || typeof value !== 'object') return true + return Object.isFrozen(value) && Object.values(value).every(isDeeplyFrozen) +} + +function requireExecutionContractMatchesInput( + input: TapeViewManifestBuildInput, + promptHash: string, + toolDefinitionsHash: string +): DeepChatExecutionContract { + const contract = input.executionContract + if (!isDeepChatExecutionContract(contract)) { + throw new TypeError('Execution contract is missing, malformed, or has an invalid hash.') + } + if (!isDeeplyFrozen(contract)) { + throw new TypeError('Execution contract must be immutable before View construction.') + } + if ( + contract.request.sessionId !== input.sessionId || + contract.request.messageId !== input.messageId || + contract.request.requestSeq !== input.requestSeq + ) { + throw new TypeError('Execution contract request identity does not match the View request.') + } + if ( + contract.provenance.providerId !== input.providerId || + contract.provenance.modelId !== input.modelId + ) { + throw new TypeError('Execution contract provider identity does not match the View request.') + } + if (contract.provenance.promptHash !== promptHash) { + throw new TypeError('Execution contract provider-message hash does not match the View payload.') + } + if (contract.provenance.providerVisibleToolDefinitionsHash !== toolDefinitionsHash) { + throw new TypeError('Execution contract tool-definition hash does not match the View payload.') + } + return contract +} + +function finalizeManifest(draft: T): T { + const manifestHash = + draft.hashVersion === TAPE_VIEW_MANIFEST_HASH_VERSION + ? buildManifestHashV3(draft) + : buildManifestHashV2(draft) + return { + ...draft, + viewId: `view_${manifestHash.slice(0, 16)}`, + hashes: { ...draft.hashes, manifestHash } + } as T } function buildExcludedRanges( @@ -156,10 +240,20 @@ function buildExcludedRanges( export function verifyTapeViewManifestHash( manifest: DeepChatTapeViewManifest ): DeepChatTapeViewManifestIntegrity { - if (manifest.hashVersion !== TAPE_VIEW_MANIFEST_HASH_VERSION) { - return 'unverified' + if (manifest.hashVersion === TAPE_VIEW_MANIFEST_LEGACY_HASH_VERSION) { + if (Number(manifest.schemaVersion) === 5) return 'invalid' + return buildManifestHashV2(manifest) === manifest.hashes.manifestHash ? 'valid' : 'invalid' } - return buildManifestHash(manifest) === manifest.hashes.manifestHash ? 'valid' : 'invalid' + if (manifest.hashVersion === TAPE_VIEW_MANIFEST_HASH_VERSION) { + if (manifest.schemaVersion !== 5 || !executionContractMatchesManifest(manifest)) + return 'invalid' + const manifestHash = buildManifestHashV3(manifest) + return manifestHash === manifest.hashes.manifestHash && + manifest.viewId === `view_${manifestHash.slice(0, 16)}` + ? 'valid' + : 'invalid' + } + return 'unverified' } export function createTapeViewManifest( @@ -167,9 +261,7 @@ export function createTapeViewManifest( ): DeepChatTapeViewManifest { const assembledAt = input.assembledAt ?? Date.now() const excludedRanges = buildExcludedRanges(input.summaryCursor) - const draft: DeepChatTapeViewManifest = { - schemaVersion: 4, - hashVersion: TAPE_VIEW_MANIFEST_HASH_VERSION, + const common = { viewId: '', sessionId: input.sessionId, messageId: input.messageId, @@ -195,11 +287,6 @@ export function createTapeViewManifest( ...input.tokenBudget, estimatedPromptTokens: estimateMessagesTokens(input.messages) }, - hashes: { - promptHash: hashJson(input.messages), - toolDefinitionsHash: hashJson(input.tools.map(stripToolExecutionContract)), - manifestHash: '' - }, meta: { providerId: input.providerId, modelId: input.modelId, @@ -211,12 +298,35 @@ export function createTapeViewManifest( assembledAt } - const manifestHash = buildManifestHash(draft) - return { - ...draft, - viewId: `view_${manifestHash.slice(0, 16)}`, - hashes: { ...draft.hashes, manifestHash } + if (input.executionContract !== undefined) { + const promptHash = buildProviderMessagesHash(input.messages) + const toolDefinitionsHash = buildProviderVisibleToolDefinitionsHash(input.tools) + const executionContract = requireExecutionContractMatchesInput( + input, + promptHash, + toolDefinitionsHash + ) + const draft: DeepChatTapeViewManifestV5 = { + schemaVersion: 5, + hashVersion: TAPE_VIEW_MANIFEST_HASH_VERSION, + ...common, + hashes: { promptHash, toolDefinitionsHash, manifestHash: '' }, + executionContract + } + return finalizeManifest(draft) + } + + const draft: DeepChatTapeViewManifestLegacy = { + schemaVersion: 4, + hashVersion: TAPE_VIEW_MANIFEST_LEGACY_HASH_VERSION, + ...common, + hashes: { + promptHash: hashJson(input.messages), + toolDefinitionsHash: hashJson(input.tools.map(stripToolExecutionContract)), + manifestHash: '' + } } + return finalizeManifest(draft) } export function buildIncludedRefs( diff --git a/src/main/tape/domain/workspacePath.ts b/src/main/tape/domain/workspacePath.ts new file mode 100644 index 000000000..5abcb4ab0 --- /dev/null +++ b/src/main/tape/domain/workspacePath.ts @@ -0,0 +1,52 @@ +import path from 'node:path' + +export interface NormalizedAbsoluteWorkspacePath { + readonly flavor: 'posix' | 'win32' + readonly path: string +} + +export function normalizeAbsoluteWorkspacePath( + value: string +): NormalizedAbsoluteWorkspacePath | null { + if (path.posix.isAbsolute(value)) { + return { flavor: 'posix', path: path.posix.normalize(value) } + } + if (path.win32.isAbsolute(value)) { + return { flavor: 'win32', path: path.win32.normalize(value) } + } + return null +} + +export function workspacePathsMatch(left: string, right: string): boolean { + const normalizedLeft = normalizeAbsoluteWorkspacePath(left) + const normalizedRight = normalizeAbsoluteWorkspacePath(right) + if ( + normalizedLeft === null || + normalizedRight === null || + normalizedLeft.flavor !== normalizedRight.flavor + ) { + return false + } + + const pathApi = normalizedLeft.flavor === 'posix' ? path.posix : path.win32 + return pathApi.relative(normalizedLeft.path, normalizedRight.path) === '' +} + +export function isWorkspacePathWithin(candidate: string, ceiling: string): boolean { + const normalizedCandidate = normalizeAbsoluteWorkspacePath(candidate) + const normalizedCeiling = normalizeAbsoluteWorkspacePath(ceiling) + if ( + normalizedCandidate === null || + normalizedCeiling === null || + normalizedCandidate.flavor !== normalizedCeiling.flavor + ) { + return false + } + + const pathApi = normalizedCandidate.flavor === 'posix' ? path.posix : path.win32 + const relative = pathApi.relative(normalizedCeiling.path, normalizedCandidate.path) + return ( + relative === '' || + (relative !== '..' && !relative.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative)) + ) +} diff --git a/src/main/tape/infrastructure/sqlite/tapeEntryStore.ts b/src/main/tape/infrastructure/sqlite/tapeEntryStore.ts index 6cef30493..7986ef0a7 100644 --- a/src/main/tape/infrastructure/sqlite/tapeEntryStore.ts +++ b/src/main/tape/infrastructure/sqlite/tapeEntryStore.ts @@ -21,7 +21,13 @@ import { isExecutionJournalReservedName, type ExecutionJournalEventName } from '@/tape/domain/executionJournal' +import { + CONTRACT_TAPE_EVENT_NAMES, + isContractTapeReservedName, + type ContractTapeEventName +} from '@/tape/domain/contractFacts' import type { + ContractPersistenceStore, ExecutionJournalPersistenceStore, TapeBootstrapStore, TapeEntryStore, @@ -368,7 +374,13 @@ const EFFECTIVE_TAPE_ROWS_CTE_SQL = ` provenance_key, payload_json, meta_json, created_at FROM bounded_rows WHERE kind = 'event' - AND (name IS NULL OR name NOT IN (${DEFAULT_EXCLUDED_TAPE_EVENT_NAMES_SQL})) + AND ( + name IS NULL + OR ( + name NOT IN (${DEFAULT_EXCLUDED_TAPE_EVENT_NAMES_SQL}) + AND name NOT GLOB 'contract/*' + ) + ) UNION ALL SELECT session_id, entry_id, kind, name, source_type, source_id, source_seq, @@ -405,7 +417,10 @@ const EFFECTIVE_TAPE_SEARCH_ROW_PREDICATE_SQL = ` candidate.kind = 'event' AND ( candidate.name IS NULL - OR candidate.name NOT IN (${DEFAULT_EXCLUDED_TAPE_EVENT_NAMES_SQL}) + OR ( + candidate.name NOT IN (${DEFAULT_EXCLUDED_TAPE_EVENT_NAMES_SQL}) + AND candidate.name NOT GLOB 'contract/*' + ) ) ) OR ( @@ -545,18 +560,21 @@ export class DeepChatTapeEntriesTable } append(input: DeepChatTapeAppendInput): DeepChatTapeEntryRow { - return this.appendInternal(input, false) + return this.appendInternal(input, null) } protected appendInternal( input: DeepChatTapeAppendInput, - allowExecutionJournal: boolean + authorizedNamespace: 'execution' | 'contract' | null ): DeepChatTapeEntryRow { - if (!allowExecutionJournal && isExecutionJournalReservedName(input.name)) { + if (authorizedNamespace !== 'execution' && isExecutionJournalReservedName(input.name)) { throw new Error( 'The execution/* namespace is reserved for the strict Execution Journal writer.' ) } + if (authorizedNamespace !== 'contract' && isContractTapeReservedName(input.name)) { + throw new Error('The contract/* namespace is reserved for the strict Contract writer.') + } const append = this.db.transaction(() => { const provenanceKey = buildProvenanceKey(input) if (input.idempotent && provenanceKey) { @@ -1258,7 +1276,37 @@ export class DeepChatExecutionJournalStore createdAt: input.createdAt, idempotent: input.idempotent }, - true + 'execution' + ) + } +} + +export class DeepChatContractStore + extends DeepChatTapeEntriesTable + implements ContractPersistenceStore +{ + appendContractEvent( + input: TapeEventAppendInput & { name: ContractTapeEventName } + ): DeepChatTapeEntryRow { + if (!CONTRACT_TAPE_EVENT_NAMES.includes(input.name)) { + throw new Error(`Unsupported Contract event name: ${input.name}.`) + } + return this.appendInternal( + { + sessionId: input.sessionId, + kind: 'event', + name: input.name, + source: input.source, + provenanceKey: input.provenanceKey, + payload: { + name: input.name, + data: input.data + }, + meta: input.meta, + createdAt: input.createdAt, + idempotent: input.idempotent + }, + 'contract' ) } } diff --git a/src/main/tape/ports/storage.ts b/src/main/tape/ports/storage.ts index 4bb86acbf..483e63fb6 100644 --- a/src/main/tape/ports/storage.ts +++ b/src/main/tape/ports/storage.ts @@ -8,6 +8,7 @@ import type { TapeEventAppendInput } from '../domain/entry' import type { ExecutionJournalEventName } from '../domain/executionJournal' +import type { ContractTapeEventName } from '../domain/contractFacts' export interface TapeMutationProjection { applyAppendedEntry(row: DeepChatTapeEntryRow, previousSessionMaxEntryId: number): boolean @@ -81,6 +82,15 @@ export interface ExecutionJournalPersistenceStore getByProvenanceKey(sessionId: string, provenanceKey: string): DeepChatTapeEntryRow | undefined } +/** Strict contract facts share the caller's host transaction and have their own namespace gate. */ +export interface ContractPersistenceStore extends TapeTransactionRunner, TapeBootstrapStore { + appendContractEvent( + input: TapeEventAppendInput & { name: ContractTapeEventName } + ): DeepChatTapeEntryRow + getByProvenanceKey(sessionId: string, provenanceKey: string): DeepChatTapeEntryRow | undefined + getFirstEntriesBySessions(sessionIds: string[]): DeepChatTapeEntryRow[] +} + export interface TapeEntryLifecycleStore { deleteBySession(sessionId: string): void } diff --git a/src/main/tool/index.ts b/src/main/tool/index.ts index 4dad07702..c0ffb0b37 100644 --- a/src/main/tool/index.ts +++ b/src/main/tool/index.ts @@ -58,6 +58,10 @@ import { resolvePluginToolPolicy } from '@/plugin/toolPolicyStore' import { composeSubagentAuthority } from '@/session/subagentAuthority' import type { LiveDelegationConsentIssuer } from '@/orchestration/liveDelegationConsent' import { parseChildAgentResultEnvelopeText } from '@shared/orchestration/resultSafety' +import { + ExecutionContractDispatchError, + assertExecutionContractAllowsDispatch +} from '@/tape/domain/executionContract' type McpToolPort = Pick @@ -134,16 +138,13 @@ export class ToolService implements ToolServicePort { private readonly conversationMappers: Map private globalMapperConversationId: string | null = null private readonly conversationMcpAccessContexts = new Map() - private readonly conversationReviewedExecutions = new Map< - string, - Map - >() + private readonly conversationAgentDefinitions = new Map>() private readonly options: ToolServiceOptions private readonly permissionBroker: ToolPermissionBroker private readonly conversationMcpDefinitions = new Map>() private globalMcpDefinitions = new Map() private agentToolManager: AgentToolManager | null = null - private globalReviewedExecutions = new Map() + private globalAgentDefinitions = new Map() constructor(options: ToolServiceOptions) { this.options = options @@ -295,7 +296,7 @@ export class ToolService implements ToolServicePort { } this.conversationMappers.delete(normalizedConversationId) - this.conversationReviewedExecutions.delete(normalizedConversationId) + this.conversationAgentDefinitions.delete(normalizedConversationId) this.conversationMcpAccessContexts.delete(normalizedConversationId) this.conversationMcpDefinitions.delete(normalizedConversationId) this.permissionBroker.cancelConversation(normalizedConversationId) @@ -325,6 +326,7 @@ export class ToolService implements ToolServicePort { if (!source) { throw new Error(`Tool ${toolName} not found in any source`) } + await this.assertExecutionContractDispatchAllowed(request, source, options) const permissionMode = (await this.observeToolAuthorization(request, source, options?.signal))?.permissionMode ?? options?.permissionMode @@ -383,6 +385,7 @@ export class ToolService implements ToolServicePort { options?.signal ) this.assertSubagentAgentToolAllowed(dispatchPolicy, toolName) + await this.assertExecutionContractDispatchAllowed(request, source, options) // Route to Agent tool manager const response = await this.agentToolManager.callTool( toolName, @@ -469,6 +472,7 @@ export class ToolService implements ToolServicePort { definition, toolName ) + await this.assertExecutionContractDispatchAllowed(request, source, options) return await this.options.mcpService.callTool(request, { agentId: options?.agentId ?? storedAccess?.agentId, enabledServerIds, @@ -661,6 +665,105 @@ export class ToolService implements ToolServicePort { ) } + private async assertExecutionContractDispatchAllowed( + request: MCPToolCall, + expectedSource: ToolSource, + options?: ToolCallOptions + ): Promise { + const contract = options?.executionContract + if (!contract) return + + const sessionId = request.conversationId?.trim() + const messageId = options.messageId?.trim() + const runId = options.runId?.trim() + const requestSeq = options.requestSeq + if ( + !sessionId || + !messageId || + !runId || + !Number.isSafeInteger(requestSeq) || + (requestSeq as number) <= 0 + ) { + throw new ExecutionContractDispatchError( + 'Contract-bearing tool dispatch requires complete provider View identity.', + 'identity_mismatch' + ) + } + + options.signal?.throwIfAborted() + let currentAuthority + try { + currentAuthority = await awaitWithAbort( + this.options.agentTools.sessions.resolveConversationExecutionAuthority(sessionId), + options.signal + ) + } catch (error) { + options.signal?.throwIfAborted() + throw new ExecutionContractDispatchError( + `Session ${sessionId} runtime authority could not be resolved.`, + 'invalid_runtime_authority', + { cause: error } + ) + } + options.signal?.throwIfAborted() + if (!currentAuthority || currentAuthority.sessionId.trim() !== sessionId) { + throw new ExecutionContractDispatchError( + `Session ${sessionId} runtime authority is unavailable.`, + 'invalid_runtime_authority' + ) + } + + const currentSource = this.getToolSource(request.function.name, sessionId) + const currentDefinition = + currentSource === 'mcp' + ? this.getMcpDefinition(request.function.name, sessionId) + : currentSource === 'agent' + ? this.getAgentDefinition(request.function.name, sessionId) + : undefined + if (currentSource !== expectedSource || !currentDefinition) { + throw new ExecutionContractDispatchError( + `Tool '${request.function.name}' no longer resolves to its provider View target.`, + 'target_mismatch' + ) + } + if ( + currentSource === 'agent' && + isUserConfigurableAgentTool(request.function.name) && + normalizeToolNames(currentAuthority.disabledAgentTools).includes(request.function.name) + ) { + throw new ExecutionContractDispatchError( + `Tool '${request.function.name}' is disabled by current runtime authority.`, + 'tool_not_allowed' + ) + } + if (currentSource === 'mcp' && Array.isArray(currentAuthority.enabledMcpServerIds)) { + const serverId = currentDefinition.server.id?.trim() + const enabledServerIds = normalizeToolNames(currentAuthority.enabledMcpServerIds) + if (!serverId || !enabledServerIds.includes(serverId)) { + throw new ExecutionContractDispatchError( + `Tool '${request.function.name}' is disabled by current runtime authority.`, + 'tool_not_allowed' + ) + } + } + + const currentProjectDir = currentAuthority.projectDir + assertExecutionContractAllowsDispatch(contract, { + request: { + sessionId, + messageId, + runId, + requestSeq: requestSeq as number + }, + currentTool: currentDefinition, + currentWorkspace: currentProjectDir + ? { kind: 'path', path: currentProjectDir } + : { kind: 'runtime_default' }, + currentMaxSubagentDepth: currentAuthority.subagentCapability.available ? 1 : 0, + requestedSubagentDepth: request.function.name === LIVE_DELEGATION_AGENT_TOOL_NAME ? 1 : 0 + }) + } + private async resolveSubagentExecutionToolPolicy( conversationId: string | undefined, signal?: AbortSignal @@ -792,14 +895,14 @@ export class ToolService implements ToolServicePort { definitions: MCPToolDefinition[] ): void { const normalizedConversationId = conversationId?.trim() - const reviewedExecutions = new Map( + const agentDefinitions = new Map( definitions .filter((definition) => definition.source === 'agent') - .map((definition) => [definition.function.name, definition.execution]) + .map((definition) => [definition.function.name, definition]) ) if (normalizedConversationId) { this.conversationMappers.set(normalizedConversationId, mapper) - this.conversationReviewedExecutions.set(normalizedConversationId, reviewedExecutions) + this.conversationAgentDefinitions.set(normalizedConversationId, agentDefinitions) } this.mapper.clear() @@ -807,7 +910,7 @@ export class ToolService implements ToolServicePort { this.mapper.registerTool(mapping.toolName, mapping.source, mapping.originalName) } this.globalMapperConversationId = normalizedConversationId || null - this.globalReviewedExecutions = reviewedExecutions + this.globalAgentDefinitions = agentDefinitions } private rememberMcpDefinitions( @@ -839,6 +942,23 @@ export class ToolService implements ToolServicePort { return this.globalMcpDefinitions.get(toolName) } + private getAgentDefinition( + toolName: string, + conversationId?: string + ): MCPToolDefinition | undefined { + const normalizedConversationId = conversationId?.trim() + if (normalizedConversationId) { + const definitions = this.conversationAgentDefinitions.get(normalizedConversationId) + if (definitions) { + return definitions.get(toolName) + } + if (this.globalMapperConversationId !== null) { + return undefined + } + } + return this.globalAgentDefinitions.get(toolName) + } + private createExpectedMcpTarget( finalName: string, definition: MCPToolDefinition | undefined @@ -941,18 +1061,7 @@ export class ToolService implements ToolServicePort { toolName: string, conversationId?: string ): ToolExecutionContract | null { - const normalizedConversationId = conversationId?.trim() - if (normalizedConversationId) { - const executions = this.conversationReviewedExecutions.get(normalizedConversationId) - if (executions) { - return executions.get(toolName) ?? null - } - if (this.globalMapperConversationId !== null) { - return null - } - } - - return this.globalReviewedExecutions.get(toolName) ?? null + return this.getAgentDefinition(toolName, conversationId)?.execution ?? null } buildToolSystemPrompt(context: { diff --git a/src/main/tool/runtimePorts.ts b/src/main/tool/runtimePorts.ts index 5286c4759..5e151dbb5 100644 --- a/src/main/tool/runtimePorts.ts +++ b/src/main/tool/runtimePorts.ts @@ -63,6 +63,16 @@ export interface ConversationSessionInfo { status: SessionStatus } +export interface ConversationExecutionAuthority { + sessionId: string + agentId: string + projectDir: string | null + sessionKind: SessionKind + disabledAgentTools: string[] + enabledMcpServerIds?: string[] | null + subagentCapability: DeepChatSubagentCapability +} + export interface CreateSubagentSessionInput { parentSessionId: string agentId: string @@ -83,6 +93,9 @@ export interface CreateSubagentSessionInput { export interface AgentToolSessionPort { resolveConversationWorkdir(conversationId: string): Promise resolveConversationSessionInfo(conversationId: string): Promise + resolveConversationExecutionAuthority( + conversationId: string + ): Promise } export interface AgentTapeToolPort { diff --git a/src/shared/chat.d.ts b/src/shared/chat.d.ts index d60af0e16..07bac4ed0 100644 --- a/src/shared/chat.d.ts +++ b/src/shared/chat.d.ts @@ -196,6 +196,7 @@ export type AssistantMessageExtra = Record export type LiveDelegationOperation = z.infer export type LiveDelegationTurnStatus = z.infer @@ -185,20 +203,31 @@ export const LiveDelegationSummarySchema = LiveDelegationSchema.omit({ export const LiveDelegationTurnSummarySchema = LiveDelegationTurnBaseSchema.omit({ prompt: true, resultSummary: true, - error: true + error: true, + taskContract: true, + taskContractRef: true, + inheritedTaskContractRef: true, + evaluation: true, + evaluationRef: true }) .extend({ promptPreview: z.string().max(LIVE_DELEGATION_MAX_PREVIEW_CHARACTERS), resultPreview: z.string().max(LIVE_DELEGATION_MAX_PREVIEW_CHARACTERS).nullable(), - errorPreview: z.string().max(LIVE_DELEGATION_MAX_PREVIEW_CHARACTERS).nullable() + errorPreview: z.string().max(LIVE_DELEGATION_MAX_PREVIEW_CHARACTERS).nullable(), + evaluation: DeepChatTaskEvaluationSummarySchema.nullable().default(null) }) .strict() .superRefine(validateLiveDelegationEffect) -export const LiveDelegationEventSummarySchema = LiveDelegationEventSchema.omit({ content: true }) +export const LiveDelegationEventSummarySchema = LiveDelegationEventBaseSchema.omit({ + content: true, + evaluation: true, + evaluationRef: true +}) .extend({ contentPreview: z.string().max(LIVE_DELEGATION_MAX_EVENT_PREVIEW_CHARACTERS), - contentTruncated: z.boolean() + contentTruncated: z.boolean(), + evaluation: DeepChatTaskEvaluationSummarySchema.nullable().default(null) }) .strict() @@ -213,6 +242,7 @@ export const LiveDelegationResultPageSchema = z answerSha256: z.string().regex(/^[0-9a-f]{64}$/u), answerBytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), answerEstimatedTokens: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + evaluation: DeepChatTaskEvaluationSummarySchema.nullable().default(null), text: z.string(), nextCursor: z.string().max(LIVE_DELEGATION_RESULT_CURSOR_MAX_LENGTH).nullable(), done: z.boolean() @@ -263,3 +293,74 @@ function validateLiveDelegationEffect( }) } } + +function validateLiveDelegationTurn( + turn: z.infer, + context: { + addIssue(issue: { code: 'custom'; path: PropertyKey[]; message: string }): void + } +): void { + validateLiveDelegationEffect(turn, context) + if ((turn.evaluation === null) !== (turn.evaluationRef === null)) { + context.addIssue({ + code: 'custom', + path: ['evaluationRef'], + message: 'Live delegation evaluation and reference must be projected together' + }) + return + } + if (!turn.evaluation || !turn.evaluationRef) return + if ( + !turn.taskContract || + turn.evaluation.turnId !== turn.id || + turn.evaluation.taskContractHash !== turn.taskContract.contractHash || + turn.evaluation.executionStatus !== turn.status || + turn.evaluationRef.sessionId !== turn.taskContract.taskDescription.parentSessionId || + turn.evaluationRef.tapeIdentity !== turn.taskContractRef?.tapeIdentity || + turn.evaluationRef.evaluationHash !== turn.evaluation.evaluationHash + ) { + context.addIssue({ + code: 'custom', + path: ['evaluation'], + message: 'Live delegation evaluation does not match its turn projection' + }) + } +} + +function validateLiveDelegationEvent( + event: z.infer, + context: { + addIssue(issue: { code: 'custom'; path: PropertyKey[]; message: string }): void + } +): void { + if ((event.evaluation === null) !== (event.evaluationRef === null)) { + context.addIssue({ + code: 'custom', + path: ['evaluationRef'], + message: 'Live delegation event evaluation and reference must be stored together' + }) + return + } + if (!event.evaluation || !event.evaluationRef) return + const expectedKind = + event.evaluation.executionStatus === 'completed' + ? 'turn_completed' + : event.evaluation.executionStatus === 'failed' + ? 'turn_failed' + : event.evaluation.executionStatus === 'cancelled' + ? 'turn_cancelled' + : 'turn_interrupted' + if ( + event.direction !== 'child_to_parent' || + event.kind !== expectedKind || + event.relatedTurnId !== event.evaluation.turnId || + event.evaluationRef.sessionId !== event.parentSessionId || + event.evaluationRef.evaluationHash !== event.evaluation.evaluationHash + ) { + context.addIssue({ + code: 'custom', + path: ['evaluation'], + message: 'Live delegation event evaluation does not match its mailbox identity' + }) + } +} diff --git a/src/shared/orchestration/liveDelegationMarkdown.ts b/src/shared/orchestration/liveDelegationMarkdown.ts new file mode 100644 index 000000000..1c1c01c5c --- /dev/null +++ b/src/shared/orchestration/liveDelegationMarkdown.ts @@ -0,0 +1,78 @@ +export interface MarkdownLevelTwoSection { + readonly title: string + readonly markdown: string + readonly body: string +} + +export function indexMarkdownLevelTwoSections( + markdown: string +): ReadonlyMap { + const lines = markdown.replace(/\r\n/g, '\n').split('\n') + const sections = new Map() + const headingPattern = /^ {0,3}(#{1,2})\s+(.+?)\s*#*\s*$/u + const fencePattern = /^ {0,3}(`{3,}|~{3,})/u + let fence: { marker: '`' | '~'; length: number } | null = null + let current: { title: string; start: number } | null = null + + const commit = (end: number): void => { + if (!current) return + const body = lines + .slice(current.start + 1, end) + .join('\n') + .trim() + const identity = current.title.toLowerCase() + if (!sections.has(identity)) { + sections.set(identity, { + title: current.title, + markdown: lines.slice(current.start, end).join('\n').trim(), + body + }) + } + } + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (fence) { + if (isClosingFence(line, fence)) fence = null + continue + } + const fenceMatch = line.match(fencePattern)?.[1] + if (fenceMatch) { + const marker = fenceMatch[0] as '`' | '~' + fence = { marker, length: fenceMatch.length } + continue + } + + const heading = line.match(headingPattern) + if (!heading) continue + commit(index) + current = heading[1] === '##' ? { title: heading[2]!.trim(), start: index } : null + } + commit(lines.length) + return sections +} + +export function extractMarkdownLevelTwoSection( + markdown: string, + title: string +): MarkdownLevelTwoSection | null { + return indexMarkdownLevelTwoSections(markdown).get(title.trim().toLowerCase()) ?? null +} + +export function removeEnclosingMarkdownFence(value: string): string { + const lines = value.replace(/\r\n/g, '\n').trim().split('\n') + if (lines.length < 2) return value.trim() + const opening = lines[0]!.match(/^ {0,3}(`{3,}|~{3,})[^`~]*$/u)?.[1] + if (!opening) return value.trim() + const fence = { marker: opening[0] as '`' | '~', length: opening.length } + if (!isClosingFence(lines.at(-1) ?? '', fence)) return value.trim() + return lines.slice(1, -1).join('\n').trim() +} + +function isClosingFence(line: string, fence: { marker: '`' | '~'; length: number }): boolean { + const candidate = line.replace(/^ {0,3}/u, '').trimEnd() + return ( + candidate.length >= fence.length && + [...candidate].every((character) => character === fence.marker) + ) +} diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index 074e4dde7..bd3bc4d59 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -315,6 +315,7 @@ export interface AssistantMessageExtra { providerId?: string permissionRequestId?: string permissionRequest?: string + executionContractBinding?: string commandInfo?: string rememberable?: boolean questionHeader?: string diff --git a/src/shared/types/core/chat.ts b/src/shared/types/core/chat.ts index d277835d5..d0823b1d8 100644 --- a/src/shared/types/core/chat.ts +++ b/src/shared/types/core/chat.ts @@ -146,6 +146,7 @@ export type AssistantMessageExtra = Record = z + .object({ + schemaVersion: z.literal(DEEPCHAT_TASK_EVALUATION_SCHEMA_VERSION), + hashVersion: z.literal(DEEPCHAT_TASK_EVALUATION_HASH_VERSION), + evaluatorVersion: z.literal(DEEPCHAT_TASK_EVALUATOR_VERSION), + evaluationKind: z.literal('handoff_format'), + turnId: StoredIdSchema, + taskContractHash: Sha256Schema, + candidate: DeepChatTaskEvaluationCandidateSchema, + executionStatus: z.enum(['completed', 'failed', 'cancelled', 'interrupted']), + formatStatus: z.enum(['valid', 'invalid', 'indeterminate']), + reasonCodes: z + .array(DeepChatTaskEvaluationReasonCodeSchema) + .max(DEEPCHAT_TASK_EVALUATION_REASON_CODES.length), + records: z.array(DeepChatTaskEvaluationRecordSchema).max(MAX_TASK_EVALUATION_RECORDS), + omittedRecordCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + evaluationHash: Sha256Schema + }) + .strict() + +const DeepChatLegacyTaskEvaluationRecordSchema: z.ZodType = z + .object({ + requirementId: StoredIdSchema.nullable(), + requirementKind: z.enum(['required_sections', 'result_schema']).nullable(), + outcome: z.enum(['passed', 'failed', 'indeterminate']), + code: DeepChatLegacyTaskEvaluationReasonCodeSchema, + section: z.string().trim().min(1).max(256).nullable(), + instancePath: z.string().max(1024).nullable(), + keyword: z.string().trim().min(1).max(128).nullable(), + additionalEvidenceCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) + }) + .strict() + +export const DeepChatLegacyTaskEvaluationProjectionSchema: z.ZodType = + z + .object({ + schemaVersion: z.literal(DEEPCHAT_LEGACY_TASK_EVALUATION_SCHEMA_VERSION), + hashVersion: z.literal(DEEPCHAT_TASK_EVALUATION_HASH_VERSION), + evaluatorVersion: z.literal(DEEPCHAT_LEGACY_TASK_EVALUATOR_VERSION), + turnId: StoredIdSchema, + taskContractHash: Sha256Schema, + candidate: DeepChatTaskEvaluationCandidateSchema, + executionStatus: z.enum(['completed', 'failed', 'cancelled', 'interrupted']), + verdict: z.enum(['passed', 'failed', 'indeterminate']), + disposition: z.enum(['accepted', 'parked']), + reasonCodes: z + .array(DeepChatLegacyTaskEvaluationReasonCodeSchema) + .max(DEEPCHAT_LEGACY_TASK_EVALUATION_REASON_CODES.length), + records: z.array(DeepChatLegacyTaskEvaluationRecordSchema).max(MAX_TASK_EVALUATION_RECORDS), + omittedRecordCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + evaluationHash: Sha256Schema + }) + .strict() + .superRefine((evaluation, context) => { + if ((evaluation.verdict === 'passed') !== (evaluation.disposition === 'accepted')) { + context.addIssue({ + code: 'custom', + path: ['disposition'], + message: 'Only a passed legacy evaluation may be accepted' + }) + } + }) + +export const DeepChatStoredTaskEvaluationProjectionSchema: z.ZodType = + z.union([DeepChatTaskEvaluationProjectionSchema, DeepChatLegacyTaskEvaluationProjectionSchema]) + +export const DeepChatTaskEvaluationSummarySchema: z.ZodType = z + .object({ + evaluationKind: z.literal('handoff_format'), + formatStatus: z.enum(['valid', 'invalid', 'indeterminate']), + reasonCodes: z + .array(DeepChatTaskEvaluationReasonCodeSchema) + .max(DEEPCHAT_TASK_EVALUATION_REASON_CODES.length), + candidate: DeepChatTaskEvaluationCandidateSchema, + evidence: z.array(DeepChatTaskEvaluationRecordSchema).max(MAX_TASK_EVALUATION_PARENT_EVIDENCE), + evaluationRef: DeepChatEvaluationRefSchema, + omittedEvidenceCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) + }) + .strict() + +const DeepChatTaskWorkspaceCeilingSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('path'), path: z.string().min(1) }).strict(), + z.object({ kind: z.literal('runtime_default') }).strict() +]) + +const DeepChatHandoffFormatRequirementSchema = z + .object({ + id: StoredIdSchema, + kind: z.literal('required_sections'), + level: z.literal(2), + sections: z.array(z.string().trim().min(1).max(256)).min(1).max(64) + }) + .strict() + +// This validates the persisted/transport shape only. The main-process TaskContract domain owns +// canonical normalization and contractHash verification. +export const DeepChatTaskContractProjectionSchema: z.ZodType = z + .object({ + schemaVersion: z.literal(DEEPCHAT_TASK_CONTRACT_SCHEMA_VERSION), + hashVersion: z.literal(DEEPCHAT_TASK_CONTRACT_HASH_VERSION), + taskSchema: z + .object({ + input: z + .object({ kind: z.literal('text'), maxBytes: z.number().int().positive() }) + .strict(), + output: z.object({ kind: z.literal('markdown') }).strict() + }) + .strict(), + taskConfig: z + .object({ + completionMode: z.literal('single_response'), + retryMode: z.literal('parent_follow_up'), + creationReason: z.enum(['delegation_created', 'legacy_recovery']), + predecessorEvaluationRef: DeepChatEvaluationRefSchema.nullable() + }) + .strict(), + taskDescription: z + .object({ + delegationId: StoredIdSchema, + turnId: StoredIdSchema, + turnSeq: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + turnKind: z.enum(['initial', 'follow_up']), + parentSessionId: StoredIdSchema, + slotId: StoredIdSchema, + targetAgentId: StoredIdSchema, + title: z.string().trim().min(1).max(160), + prompt: z + .string() + .trim() + .min(1) + .max(64 * 1024) + }) + .strict(), + taskHarness: z + .object({ + acceptance: z + .array(DeepChatHandoffFormatRequirementSchema) + .max(MAX_TASK_CONTRACT_REQUIREMENTS), + ceilings: z + .object({ + maxToolEffect: z.enum(['read', 'write']), + workspace: DeepChatTaskWorkspaceCeilingSchema, + maxSubagentDepth: z.number().int().nonnegative().max(1) + }) + .strict() + }) + .strict(), + contractHash: Sha256Schema + }) + .strict() diff --git a/src/shared/types/tool.d.ts b/src/shared/types/tool.d.ts index 16ca3af42..f0c1bb5e2 100644 --- a/src/shared/types/tool.d.ts +++ b/src/shared/types/tool.d.ts @@ -11,6 +11,7 @@ import type { } from '../core/mcp' import type { DeepChatSubagentCapability, PermissionMode, SessionKind } from '../agent-interface' import type { AgentPlanSnapshot } from '../agent-plan' +import type { DeepChatExecutionContract } from './execution-contract' import type { CommandShellProfile, ResolvedCommandShell } from '../commandShell' export type AgentToolProgressUpdate = @@ -42,6 +43,9 @@ export interface ToolDefinitionContext { export interface ToolCallOptions { runId?: string + messageId?: string + requestSeq?: number + executionContract?: DeepChatExecutionContract onProgress?: (update: AgentToolProgressUpdate) => void signal?: AbortSignal permissionMode?: PermissionMode diff --git a/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts b/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts index 7577fbccf..32cd3d979 100644 --- a/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts +++ b/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createHash } from 'node:crypto' import fs from 'fs/promises' import os from 'os' import path from 'path' @@ -10,7 +11,7 @@ import type { DeepChatSessionState } from '@shared/types/agent-interface' import type { ChatMessage } from '@shared/types/core/chat-message' -import type { MCPToolDefinition } from '@shared/types/core/mcp' +import { TOOL_EXECUTION, type MCPToolDefinition } from '@shared/types/core/mcp' import type { ModelConfig } from '@shared/types/provider' import { ApiEndpointType, ModelType } from '@shared/model' import { @@ -62,6 +63,8 @@ import { ExecutionJournalCorruptionError, ExecutionJournalError } from '@/tape/domain/executionJournal' +import { buildTaskContract } from '@/tape/domain/taskContract' +import { LIVE_DELEGATION_AGENT_TOOL_NAME } from '@shared/agentTools' vi.mock('nanoid', () => ({ nanoid: vi.fn(() => 'mock-msg-id') })) @@ -99,9 +102,8 @@ const skillServiceMock = { discardDraftSkill: vi.fn() } -vi.mock('@/agent/deepchat/resources/systemEnvPromptBuilder', () => ({ - buildRuntimeCapabilitiesPrompt: vi.fn(() => 'RUNTIME_CAPABILITIES'), - buildSystemEnvPrompt: vi.fn( +vi.mock('@/agent/deepchat/resources/systemEnvPromptBuilder', () => { + const buildSystemEnvPrompt = vi.fn( async (options?: { providerId?: string modelId?: string @@ -119,7 +121,28 @@ vi.mock('@/agent/deepchat/resources/systemEnvPromptBuilder', () => ({ ].join('\n') } ) -})) + return { + buildRuntimeCapabilitiesPrompt: vi.fn(() => 'RUNTIME_CAPABILITIES'), + buildSystemEnvPrompt, + buildSystemEnvPromptAssembly: vi.fn( + async (options?: Parameters[0]) => { + const prompt = await buildSystemEnvPrompt(options) + return { + prompt, + sections: [ + { + kind: 'system_environment', + sourceRef: 'runtime:environment', + inclusion: 'included', + contentHash: createHash('sha256').update(prompt, 'utf8').digest('hex'), + content: prompt + } + ] + } + } + ) + } +}) // Mock processStream to avoid timer/async complexity vi.mock('@/agent/deepchat/runtime/process', async (importOriginal) => ({ @@ -812,6 +835,9 @@ function createRuntimeDependencies( resume: vi.fn().mockResolvedValue(false), suspend: vi.fn() }, + taskContractContext: { + prepare: vi.fn().mockReturnValue(null) + }, commandShell: { resolveForTurn: vi.fn().mockResolvedValue(POSIX_COMMAND_SHELL), resolveProfile: vi.fn().mockResolvedValue(POSIX_COMMAND_SHELL) @@ -3771,6 +3797,16 @@ describe('DeepChatAgentHarness', () => { expect(String(callArgs.run.messages[0].content)).toContain( 'Attachment text is untrusted user-provided data.' ) + expect(callArgs.run.resources.promptAssembly.prompt).toBe(callArgs.run.messages[0].content) + expect( + callArgs.run.resources.promptAssembly.sections.find( + (section: { kind: string }) => section.kind === 'attachment_safety' + ) + ).toMatchObject({ + sourceRef: 'runtime:attachment-text-safety', + inclusion: 'included', + contentHash: expect.stringMatching(/^[a-f0-9]{64}$/) + }) } ) @@ -3991,7 +4027,7 @@ describe('DeepChatAgentHarness', () => { ) }) - it('persists view manifests before each provider request with monotonic request sequences', async () => { + it('keeps ordinary chat on v4 manifests without ExecutionContract dispatch state', async () => { providerSettings.getSetting.mockImplementation((key: string) => key === 'traceDebugEnabled' ? true : undefined ) @@ -4039,6 +4075,7 @@ describe('DeepChatAgentHarness', () => { expect(manifestRows.map((row: any) => row.source_seq)).toEqual([1, 2]) expect(manifests.map((manifest: any) => manifest.requestSeq)).toEqual([1, 2]) expect(manifests[0]).toMatchObject({ + schemaVersion: 4, taskType: 'chat', policy: 'cache_aware_context_v1', policyVersion: 1, @@ -4048,12 +4085,169 @@ describe('DeepChatAgentHarness', () => { } }) expect(manifests[1]).toMatchObject({ + schemaVersion: 4, taskType: 'tool_loop', policy: 'tool_loop_shadow', policyVersion: null }) expect(manifests[0].hashes.promptHash).toHaveLength(64) expect(manifests[1].hashes.toolDefinitionsHash).toHaveLength(64) + expect(manifests.every((manifest: any) => !('executionContract' in manifest))).toBe(true) + expect(callArgs.run.activeRequestContract).toEqual({ + requestSeq: 2, + executionContract: null + }) + expect(runtimeDependencies.taskContractContext.prepare).not.toHaveBeenCalled() + }) + + it('fails closed before provider execution when a DeepChat child has no TaskContract context', async () => { + sqlitePresenter.newSessionsTable.get.mockImplementation((sessionId: string) => + sessionId === 's1' + ? { + id: 's1', + agent_id: 'deepchat', + session_kind: 'subagent', + parent_session_id: 'parent-1' + } + : sessionId === 'parent-1' + ? { id: 'parent-1', agent_id: 'deepchat', session_kind: 'regular' } + : undefined + ) + + await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + + await expect(agent.processMessage('s1', 'Hello')).resolves.toMatchObject({ + messageId: 'mock-msg-id' + }) + expect(runtimeDependencies.taskContractContext.prepare).toHaveBeenCalledOnce() + expect(processStream).not.toHaveBeenCalled() + expect((await agent.getSessionState('s1'))?.status).toBe('error') + }) + + it('reuses one child-local TaskContract context across provider Views in a run', async () => { + const taskContract = buildTaskContract({ + delegationId: 'delegation-1', + turnId: 'turn-1', + turnSeq: 1, + turnKind: 'initial', + parentSessionId: 'parent-1', + slotId: 'reviewer', + targetAgentId: 'deepchat', + title: 'Review provider Views', + prompt: 'Keep each View attached to the active task.', + workspace: { kind: 'runtime_default' }, + handoffFormat: [], + maxToolEffect: 'read', + maxSubagentDepth: 0 + }) + const contextForTape = (tapeIdentity: string, entryId: number) => ({ + contract: taskContract, + localRef: { + schemaVersion: 1 as const, + sessionId: 's1', + tapeIdentity, + entryId, + contractHash: taskContract.contractHash + } + }) + sqlitePresenter.newSessionsTable.get.mockImplementation((sessionId: string) => + sessionId === 's1' + ? { + id: 's1', + agent_id: 'deepchat', + session_kind: 'subagent', + parent_session_id: 'parent-1' + } + : sessionId === 'parent-1' + ? { id: 'parent-1', agent_id: 'deepchat', session_kind: 'regular' } + : undefined + ) + const prepareTaskContract = vi.mocked(runtimeDependencies.taskContractContext.prepare) + prepareTaskContract + .mockReturnValueOnce(contextForTape('c'.repeat(64), 2)) + .mockReturnValue(contextForTape('d'.repeat(64), 3)) + const agentTool = ( + name: string, + execution: MCPToolDefinition['execution'] + ): MCPToolDefinition => ({ + source: 'agent', + execution, + type: 'function', + function: { + name, + description: `${name} description`, + parameters: { type: 'object', properties: {} } + }, + server: { name: 'agent-tools', icons: '', description: 'Agent tools' } + }) + toolService.getAllToolDefinitions.mockResolvedValue([ + agentTool('read_file', TOOL_EXECUTION.read.parallel), + agentTool('write_file', TOOL_EXECUTION.write), + agentTool(LIVE_DELEGATION_AGENT_TOOL_NAME, TOOL_EXECUTION.read.sequential) + ]) + + await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + await agent.processMessage('s1', 'Hello') + const callArgs = (processStream as ReturnType).mock.calls[0][0] + expect( + callArgs.run.resources.toolDefinitions.map((tool: MCPToolDefinition) => tool.function.name) + ).toEqual(['read_file']) + + const refreshedTools = await callArgs.toolCatalog.resolve({ + activeSkillNames: ['runtime-skill'] + }) + expect(refreshedTools.map((tool: MCPToolDefinition) => tool.function.name)).toEqual([ + 'read_file' + ]) + for (let index = 0; index < 3; index += 1) { + for await (const _event of callArgs.coreStream( + callArgs.run.messages, + callArgs.modelId, + callArgs.modelConfig, + callArgs.temperature, + callArgs.maxTokens, + index === 0 ? callArgs.run.resources.toolDefinitions : refreshedTools + )) { + } + } + + const manifests = sqlitePresenter.deepchatTapeEntriesTable + .getBySession('s1') + .filter((row: any) => row.kind === 'event' && row.name === 'view/assembled') + .map((row: any) => JSON.parse(row.payload_json).data.manifest) + expect(prepareTaskContract).toHaveBeenCalledTimes(1) + expect(prepareTaskContract).toHaveBeenNthCalledWith(1, 's1') + expect(manifests.map((manifest: any) => manifest.schemaVersion)).toEqual([5, 5, 5]) + expect( + manifests.map((manifest: any) => manifest.executionContract.provenance.taskContractRef) + ).toEqual([ + contextForTape('c'.repeat(64), 2).localRef, + contextForTape('c'.repeat(64), 2).localRef, + contextForTape('c'.repeat(64), 2).localRef + ]) + + installPendingQuestion() + await expect(answerPendingQuestion()).resolves.toEqual({ resumed: true }) + const nextCallArgs = (processStream as ReturnType).mock.calls[1][0] + for await (const _event of nextCallArgs.coreStream( + nextCallArgs.run.messages, + nextCallArgs.modelId, + nextCallArgs.modelConfig, + nextCallArgs.temperature, + nextCallArgs.maxTokens, + nextCallArgs.run.resources.toolDefinitions + )) { + } + + const latestManifest = sqlitePresenter.deepchatTapeEntriesTable + .getBySession('s1') + .filter((row: any) => row.kind === 'event' && row.name === 'view/assembled') + .map((row: any) => JSON.parse(row.payload_json).data.manifest) + .at(-1) + expect(prepareTaskContract).toHaveBeenCalledTimes(2) + expect(latestManifest.executionContract.provenance.taskContractRef).toEqual( + contextForTape('d'.repeat(64), 3).localRef + ) }) it('continues provider requests when view manifest persistence fails', async () => { @@ -4079,8 +4273,16 @@ describe('DeepChatAgentHarness', () => { } expect(providerCoreStream).toHaveBeenCalledTimes(1) + expect(callArgs.run.activeRequestContract).toEqual({ + requestSeq: 1, + executionContract: null + }) + const viewManifests = sqlitePresenter.deepchatTapeEntriesTable + .getBySession('s1') + .filter((row: any) => row.kind === 'event' && row.name === 'view/assembled') + expect(viewManifests).toEqual([]) expect(loggerWarnMock).toHaveBeenCalledWith( - expect.stringContaining('Failed to persist tape view manifest') + expect.stringContaining('Failed to persist tape view manifest: manifest write failed') ) }) @@ -5100,10 +5302,11 @@ describe('DeepChatAgentHarness', () => { order.push('provider-request') initialMessages = params.run.messages initialSystemPrompt = String(params.run.messages[0]?.content ?? '') - refreshedSystemPrompt = await params.refreshSystemPrompt( + const refreshed = await params.refreshSystemPrompt( ['skill-a'], params.run.resources.toolDefinitions ) + refreshedSystemPrompt = typeof refreshed === 'string' ? refreshed : refreshed.prompt order.push('skill-refresh-complete') return { status: 'completed' } }) @@ -5664,6 +5867,28 @@ describe('DeepChatAgentHarness', () => { const callArgs = (processStream as ReturnType).mock.calls[0][0] expect(callArgs.run.resources.toolDefinitions).toEqual([]) expect(callArgs.run.messages).toEqual([{ role: 'user', content: 'Delegated task' }]) + for await (const _event of callArgs.coreStream( + callArgs.run.messages, + callArgs.modelId, + callArgs.modelConfig, + callArgs.temperature, + callArgs.maxTokens, + callArgs.run.resources.toolDefinitions + )) { + } + + const manifest = sqlitePresenter.deepchatTapeEntriesTable + .getBySession('s-acp-subagent') + .filter((row: any) => row.kind === 'event' && row.name === 'view/assembled') + .map((row: any) => JSON.parse(row.payload_json).data.manifest) + .at(-1) + expect(manifest.schemaVersion).toBe(4) + expect(manifest).not.toHaveProperty('executionContract') + expect(callArgs.run.activeRequestContract).toEqual({ + requestSeq: 1, + executionContract: null + }) + expect(runtimeDependencies.taskContractContext.prepare).not.toHaveBeenCalled() }) it('keeps local tool injection for regular ACP sessions', async () => { @@ -10703,10 +10928,11 @@ describe('DeepChatAgentHarness', () => { expect(String(ownerUser?.content)).toContain('RESUME_MEMORY_CONTENT') order.length = 0 - const refreshedSystemPrompt = await streamParams.refreshSystemPrompt( + const refreshed = await streamParams.refreshSystemPrompt( undefined, streamParams.run.resources.toolDefinitions ) + const refreshedSystemPrompt = typeof refreshed === 'string' ? refreshed : refreshed.prompt expect(order).toEqual([]) expect(systemEnvPrompt).toHaveBeenCalledTimes(2) expect((processStream as ReturnType).mock.invocationCallOrder[0]).toBeLessThan( @@ -11831,6 +12057,51 @@ describe('DeepChatAgentHarness', () => { expect(instance?.getPendingToolBatchState()).toBeUndefined() }) + it('rejects an invalid View binding before granting permission or executing the tool', async () => { + await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + makeAssistantRow({ + blocks: [ + { + type: 'tool_call', + status: 'pending', + timestamp: 1, + tool_call: { id: 'tc1', name: 'write_file', params: '{}', response: '' } + }, + { + type: 'action', + action_type: 'tool_call_permission', + status: 'pending', + timestamp: 2, + content: 'Need permission', + tool_call: { id: 'tc1', name: 'write_file', params: '{}' }, + extra: { + needsUserAction: true, + permissionType: 'write', + executionContractBinding: '{', + permissionRequest: JSON.stringify({ + permissionType: 'write', + description: 'Need permission', + toolName: 'write_file', + serverName: 'agent-filesystem', + paths: ['a.txt'] + }) + } + } + ] + }) + + await expect( + agent.respondToolInteraction('s1', 'm1', 'tc1', { + kind: 'permission', + granted: true + }) + ).rejects.toMatchObject({ code: 'invalid_contract' }) + + expect(sessionPermissionPort.approvePermission).not.toHaveBeenCalled() + expect(toolService.callTool).not.toHaveBeenCalled() + expect(runtimeDependencies.interactionContinuationAdmission.resume).not.toHaveBeenCalled() + }) + it('handles permission grant by executing deferred tool and resuming', async () => { await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) makeAssistantRow({ @@ -13807,6 +14078,7 @@ describe('DeepChatAgentHarness', () => { 'm1', expect.objectContaining({ id: 'tc1', name: 'exec' }), expect.any(Function), + undefined, 'git-bash', 'command-grant-after-restart' ) diff --git a/test/main/agent/deepchat/loop/contextCoordinator.test.ts b/test/main/agent/deepchat/loop/contextCoordinator.test.ts index 20e2c1279..0cc7a6006 100644 --- a/test/main/agent/deepchat/loop/contextCoordinator.test.ts +++ b/test/main/agent/deepchat/loop/contextCoordinator.test.ts @@ -75,12 +75,18 @@ function createAttemptInput(options?: { providerEvents?: LLMCoreStreamEvent[][] providerAttempts?: Array<{ events?: LLMCoreStreamEvent[]; error?: unknown }> appendManifest?: (manifest: any) => void + buildExecutionContract?: (input: any) => any viewContext?: false + strictViewContract?: boolean }) { const run = createRun() const order: string[] = [] const manifests: any[] = [] const providerRequests: any[] = [] + const manifestContractRefs: any[] = [] + const providerContractRefs: any[] = [] + const contractBuildInputs: any[] = [] + const executionContractErrors: unknown[] = [] const manifestErrors: unknown[] = [] const outcomes: any[] = [] const outcomeErrors: unknown[] = [] @@ -103,6 +109,10 @@ function createAttemptInput(options?: { order, manifests, providerRequests, + manifestContractRefs, + providerContractRefs, + contractBuildInputs, + executionContractErrors, manifestErrors, outcomes, outcomeErrors, @@ -121,6 +131,7 @@ function createAttemptInput(options?: { supportsVision: true, supportsAudioInput: true, traceDebugEnabled: true, + strictViewContract: options?.strictViewContract, viewContext: options?.viewContext === false ? undefined @@ -158,6 +169,22 @@ function createAttemptInput(options?: { messages: requestMessages })) }, + executionContract: { + build: (input: any) => { + contractBuildInputs.push(structuredClone(input)) + return ( + options?.buildExecutionContract?.(input) ?? { + request: { + sessionId: run.sessionId, + messageId: run.messageId, + runId: run.runId, + requestSeq: input.requestSeq + } + } + ) + }, + onBuildError: (error: unknown) => executionContractErrors.push(error) + }, manifest: { resolvePolicy: ({ recoveredFromContextPressure, viewPolicy, viewPolicyVersion }: any) => ({ policy: recoveredFromContextPressure @@ -167,6 +194,7 @@ function createAttemptInput(options?: { }), append: (manifest: any) => { order.push(`manifest:${manifest.requestSeq}`) + manifestContractRefs.push(manifest.executionContract) manifests.push(structuredClone(manifest)) options?.appendManifest?.(manifest) }, @@ -188,6 +216,7 @@ function createAttemptInput(options?: { provider: { stream: async function* (request: any) { order.push('provider') + providerContractRefs.push(request.executionContract) const { signal, ...serializableRequest } = request providerRequests.push({ ...structuredClone(serializableRequest), signal }) const attempt = providerAttempts[providerAttempt++] ?? { events: [] } @@ -349,6 +378,11 @@ describe('DeepChatContextCoordinator', () => { 'outcome:1' ]) expect(fixture.manifests[0].messages).toEqual(fixture.providerRequests[0].messages) + expect(fixture.manifestContractRefs[0]).toBe(fixture.providerContractRefs[0]) + expect(fixture.run.activeRequestContract).toEqual({ + requestSeq: 1, + executionContract: fixture.providerContractRefs[0] + }) expect(fixture.providerRequests[0]).toMatchObject({ identity: { logicalRound: 1, requestSeq: 1, physicalAttempt: 1 }, requestOrigin: 'chat', @@ -440,6 +474,10 @@ describe('DeepChatContextCoordinator', () => { throw new Error('manifest unavailable') } }) + fixture.input.manifest.onAppendError = (error: unknown) => { + fixture.manifestErrors.push(error) + throw new Error('manifest error reporting unavailable') + } await expect( collect(new DeepChatContextCoordinator().streamProviderAttempts(fixture.input)) @@ -448,11 +486,92 @@ describe('DeepChatContextCoordinator', () => { { type: 'stop', stop_reason: 'complete' } ]) expect(fixture.providerRequests).toHaveLength(1) + expect(fixture.providerRequests[0].executionContract).toBeNull() + expect(fixture.run.activeRequestContract).toEqual({ + requestSeq: 1, + executionContract: null + }) expect(fixture.manifestErrors).toEqual([ - expect.objectContaining({ message: 'manifest unavailable' }) + expect.objectContaining({ + message: expect.stringContaining( + 'ExecutionContract disabled for request 1 because durable ViewManifest persistence could not be confirmed' + ) + }) ]) }) + it('reuses one null contract decision across transient retries after manifest failure', async () => { + const transientError = Object.assign(new Error('fetch failed'), { + code: 'ECONNRESET', + headers: { 'retry-after-ms': '0' } + }) + const fixture = createAttemptInput({ + appendManifest: () => { + throw new Error('manifest unavailable') + }, + providerAttempts: [ + { error: transientError }, + { + events: [ + { type: 'text', content: 'recovered' }, + { type: 'stop', stop_reason: 'complete' } + ] + } + ] + }) + + await collect(new DeepChatContextCoordinator().streamProviderAttempts(fixture.input)) + + expect(fixture.manifests).toHaveLength(1) + expect(fixture.contractBuildInputs).toHaveLength(1) + expect(fixture.providerRequests.map((request) => request.identity)).toEqual([ + { logicalRound: 1, requestSeq: 1, physicalAttempt: 1 }, + { logicalRound: 1, requestSeq: 1, physicalAttempt: 2 } + ]) + expect(fixture.providerContractRefs).toEqual([null, null]) + expect(fixture.run.activeRequestContract).toEqual({ + requestSeq: 1, + executionContract: null + }) + }) + + it.each([ + { + name: 'contract construction', + create: () => + createAttemptInput({ + strictViewContract: true, + buildExecutionContract: () => { + throw new Error('contract unavailable') + } + }), + message: 'contract unavailable', + manifestAttempts: 0 + }, + { + name: 'manifest persistence', + create: () => + createAttemptInput({ + strictViewContract: true, + appendManifest: () => { + throw new Error('manifest unavailable') + } + }), + message: 'manifest unavailable', + manifestAttempts: 1 + } + ])('fails a strict child View before provider admission on $name failure', async (scenario) => { + const fixture = scenario.create() + + await expect( + collect(new DeepChatContextCoordinator().streamProviderAttempts(fixture.input)) + ).rejects.toThrow(scenario.message) + expect(fixture.providerRequests).toHaveLength(0) + expect(fixture.order).not.toContain('rate') + expect(fixture.run.activeRequestContract).toBeNull() + expect(fixture.manifests).toHaveLength(scenario.manifestAttempts) + }) + it('keeps generation fail-open when provider outcome persistence throws', async () => { const fixture = createAttemptInput() const persistenceError = new Error('outcome unavailable') @@ -518,6 +637,11 @@ describe('DeepChatContextCoordinator', () => { { logicalRound: 1, requestSeq: 1, physicalAttempt: 1 }, { logicalRound: 1, requestSeq: 2, physicalAttempt: 1 } ]) + expect(fixture.contractBuildInputs.map((input) => input.requestSeq)).toEqual([1, 2]) + expect(fixture.providerContractRefs[0]).not.toBe(fixture.providerContractRefs[1]) + expect(fixture.run.activeRequestContract?.executionContract).toBe( + fixture.providerContractRefs[1] + ) expect(fixture.order.indexOf('outcome:1')).toBeLessThan(fixture.order.indexOf('manifest:2')) }) @@ -572,6 +696,43 @@ describe('DeepChatContextCoordinator', () => { expect(fixture.outcomes).toHaveLength(0) }) + it('does not freeze a provider View when cancellation lands during context recovery', async () => { + const fixture = createAttemptInput() + fixture.input.budget.preflight = vi + .fn() + .mockReturnValueOnce( + createPreflight(fixture.run.messages, { requiresContextPressureRecovery: true }) + ) + .mockReturnValue(createPreflight(fixture.run.messages)) + let markRecoveryStarted = () => { + throw new Error('Recovery started before initialization') + } + const recoveryStarted = new Promise((resolve) => { + markRecoveryStarted = resolve + }) + let releaseRecovery = () => { + throw new Error('Recovery released before initialization') + } + const recoveryBlocked = new Promise((resolve) => { + releaseRecovery = resolve + }) + fixture.input.recovery.recover = vi.fn(async () => { + markRecoveryStarted() + await recoveryBlocked + return { messages: fixture.run.messages } + }) + + const request = collect(new DeepChatContextCoordinator().streamProviderAttempts(fixture.input)) + await recoveryStarted + fixture.run.abortController.abort(new DOMException('stopped', 'AbortError')) + releaseRecovery() + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }) + expect(fixture.run.requestSeq).toBe(0) + expect(fixture.manifests).toEqual([]) + expect(fixture.providerRequests).toEqual([]) + }) + it('records abort and error attempts without inventing usage', async () => { const aborted = createAttemptInput() aborted.input.provider.stream = async function* () { @@ -676,6 +837,8 @@ describe('DeepChatContextCoordinator', () => { { logicalRound: 1, requestSeq: 1, physicalAttempt: 1 }, { logicalRound: 1, requestSeq: 1, physicalAttempt: 2 } ]) + expect(fixture.contractBuildInputs).toHaveLength(1) + expect(fixture.providerContractRefs[0]).toBe(fixture.providerContractRefs[1]) expect(fixture.outcomes).toEqual([ expectedAttemptOutcome({ status: 'error', @@ -689,6 +852,29 @@ describe('DeepChatContextCoordinator', () => { ]) }) + it('keeps interactive requests fail-open when contract construction fails', async () => { + const contractError = new Error('contract unavailable') + const fixture = createAttemptInput({ + buildExecutionContract: () => { + throw contractError + } + }) + fixture.input.executionContract.onBuildError = (error: unknown) => { + fixture.executionContractErrors.push(error) + throw new Error('contract error reporting unavailable') + } + + await collect(new DeepChatContextCoordinator().streamProviderAttempts(fixture.input)) + + expect(fixture.executionContractErrors).toEqual([contractError]) + expect(fixture.manifests[0].executionContract).toBeUndefined() + expect(fixture.providerContractRefs).toEqual([null]) + expect(fixture.run.activeRequestContract).toEqual({ + requestSeq: 1, + executionContract: null + }) + }) + it('buffers retryable error controls until the retry decision is final', async () => { const fixture = createAttemptInput({ providerEvents: [ diff --git a/test/main/agent/deepchat/loop/loopRun.test.ts b/test/main/agent/deepchat/loop/loopRun.test.ts index f93e1d928..90c188a78 100644 --- a/test/main/agent/deepchat/loop/loopRun.test.ts +++ b/test/main/agent/deepchat/loop/loopRun.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { toAppSessionId } from '@/agent/shared/agentSessionIds' import { advanceRequestSequence, + bindActiveRequestContract, createLoopRun, enterLogicalRound, enterPhysicalAttempt @@ -96,6 +97,49 @@ describe('LoopRun', () => { expect(run.physicalAttempt).toBe(1) }) + it('binds one request-scoped contract and clears it before the next request', () => { + const run = createRun('session') + const requestSeq = advanceRequestSequence(run) + const executionContract = { + request: { + sessionId: run.sessionId, + messageId: run.messageId, + runId: run.runId, + requestSeq + } + } as any + + const binding = bindActiveRequestContract(run, requestSeq, executionContract) + + expect(binding.executionContract).toBe(executionContract) + expect(run.activeRequestContract).toBe(binding) + expect(Object.isFrozen(binding)).toBe(true) + enterPhysicalAttempt(run) + expect(run.activeRequestContract).toBe(binding) + advanceRequestSequence(run) + expect(run.activeRequestContract).toBeNull() + }) + + it('rejects stale or cross-run execution contract bindings', () => { + const run = createRun('session') + const requestSeq = advanceRequestSequence(run) + const contract = (overrides: Record = {}) => + ({ + request: { + sessionId: run.sessionId, + messageId: run.messageId, + runId: run.runId, + requestSeq, + ...overrides + } + }) as any + + expect(() => bindActiveRequestContract(run, requestSeq + 1, null)).toThrow(/request sequence/) + expect(() => bindActiveRequestContract(run, requestSeq, contract({ runId: 'other' }))).toThrow( + /Loop Run/ + ) + }) + it('restores only valid persisted logical rounds', () => { expect( createLoopRun({ @@ -124,6 +168,37 @@ describe('LoopRun', () => { ).toBe(0) }) + it('retains the exact immutable prompt assembly on the run path', () => { + const promptAssembly = Object.freeze({ + prompt: 'system prompt', + sections: Object.freeze([ + Object.freeze({ + kind: 'configured_prompt' as const, + sourceRef: 'session:generation-settings.system-prompt', + inclusion: 'included' as const, + contentHash: 'a'.repeat(64), + content: 'system prompt' + }) + ]) + }) + const run = createLoopRun({ + runId: 'run', + sessionId: toAppSessionId('session'), + messageId: 'message', + abortController: new AbortController(), + messages: [{ role: 'system', content: promptAssembly.prompt }], + streamState: {}, + resources: { + toolDefinitions: [], + activeSkillNames: [], + promptAssembly, + commandShell: POSIX_COMMAND_SHELL + } + }) + + expect(run.resources.promptAssembly).toBe(promptAssembly) + }) + it('fails explicitly instead of wrapping an exhausted request sequence', () => { const run = createRun('session', Number.MAX_SAFE_INTEGER) diff --git a/test/main/agent/deepchat/resources/promptAssembly.test.ts b/test/main/agent/deepchat/resources/promptAssembly.test.ts new file mode 100644 index 000000000..6f8808134 --- /dev/null +++ b/test/main/agent/deepchat/resources/promptAssembly.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest' +import { + appendPromptAssemblySection, + assemblePromptSections, + createOpaquePromptAssembly, + createPromptAssemblySection, + reconcilePromptAssembly, + recordPromptAssemblyObservation +} from '@/agent/deepchat/resources/promptAssembly' +import type { DeepChatPromptDegradationCode } from '@shared/types/prompt-assembly' + +describe('promptAssembly', () => { + it('preserves explicit section separators and omits empty content', () => { + const assembly = assemblePromptSections([ + createPromptAssemblySection({ + kind: 'configured_prompt', + sourceRef: 'configured', + content: 'Configured' + }), + createPromptAssemblySection({ + kind: 'agents_instructions', + sourceRef: 'agents', + content: 'Agents', + separatorBefore: '\n' + }), + createPromptAssemblySection({ + kind: 'tooling', + sourceRef: 'tooling', + content: '' + }), + createPromptAssemblySection({ + kind: 'verification_policy', + sourceRef: 'verification', + content: 'Verify' + }) + ]) + + expect(assembly.prompt).toBe('Configured\nAgents\n\nVerify') + expect(assembly.sections[2]).toMatchObject({ inclusion: 'omitted', content: '' }) + expect(Object.isFrozen(assembly)).toBe(true) + expect(Object.isFrozen(assembly.sections)).toBe(true) + }) + + it('deduplicates degradation codes in deterministic order', () => { + const section = createPromptAssemblySection({ + kind: 'tooling', + sourceRef: 'tooling', + content: 'Tools', + degradationCodes: ['tooling_build_failed', 'environment_build_failed', 'tooling_build_failed'] + }) + + expect(section.degradationCodes).toEqual(['environment_build_failed', 'tooling_build_failed']) + }) + + it('snapshots caller-owned sections at every assembly boundary', () => { + const degradationCodes: DeepChatPromptDegradationCode[] = ['tooling_build_failed'] + const section = { + ...createPromptAssemblySection({ + kind: 'tooling', + sourceRef: 'tooling', + content: 'Tools' + }), + degradationCodes + } + section.content = 'Snapshot' + const assembled = assemblePromptSections([section]) + const appended = appendPromptAssemblySection(assemblePromptSections([]), section) + const observed = recordPromptAssemblyObservation(assemblePromptSections([]), section) + const expected = createPromptAssemblySection({ + kind: 'tooling', + sourceRef: 'tooling', + content: 'Snapshot', + degradationCodes + }) + + section.content = 'Changed' + degradationCodes.push('environment_build_failed') + + expect(assembled.prompt).toBe('Snapshot') + expect(appended.prompt).toBe('Snapshot') + for (const assembly of [assembled, appended, observed]) { + expect(assembly.sections[0]).toMatchObject({ + content: 'Snapshot', + contentHash: expected.contentHash, + inclusion: 'degraded', + degradationCodes: ['tooling_build_failed'] + }) + expect(assembly.sections[0]).not.toBe(section) + expect(Object.isFrozen(assembly.sections[0])).toBe(true) + expect(Object.isFrozen(assembly.sections[0]?.degradationCodes)).toBe(true) + } + + const duplicateSource = { ...expected } + const duplicateAssembly = appendPromptAssemblySection( + { prompt: 'Snapshot', sections: [duplicateSource] }, + duplicateSource + ) + duplicateSource.content = 'Changed duplicate' + + expect(duplicateAssembly.sections[0]?.content).toBe('Snapshot') + expect(Object.isFrozen(duplicateAssembly)).toBe(true) + expect(Object.isFrozen(duplicateAssembly.sections[0])).toBe(true) + + const frozenStaleSection = Object.freeze({ ...expected, content: 'Frozen snapshot' }) + const frozenStaleAssembly = assemblePromptSections([frozenStaleSection]) + const frozenExpected = createPromptAssemblySection({ + kind: 'tooling', + sourceRef: 'tooling', + content: 'Frozen snapshot', + degradationCodes + }) + + expect(frozenStaleAssembly.sections[0]?.contentHash).toBe(frozenExpected.contentHash) + }) + + it('keeps matching provenance and degrades mismatched projections to the effective prompt', () => { + const declared = assemblePromptSections([ + createPromptAssemblySection({ + kind: 'configured_prompt', + sourceRef: 'configured', + content: 'Declared' + }) + ]) + + expect(reconcilePromptAssembly(declared, 'Declared')).toBe(declared) + + const reconciled = reconcilePromptAssembly(declared, 'Effective') + expect(reconciled).toMatchObject({ + prompt: 'Effective', + sections: [ + { + kind: 'effective_system_prompt', + sourceRef: 'runtime:effective-system-message', + inclusion: 'degraded', + contentHash: expect.stringMatching(/^[a-f0-9]{64}$/), + degradationCodes: ['prompt_projection_mismatch'], + content: 'Effective' + } + ] + }) + }) + + it('preserves opaque and reconciled provider prompt bytes', () => { + const opaquePrompt = ' Legacy prompt\n\n' + const opaque = createOpaquePromptAssembly(opaquePrompt) + + expect(opaque.prompt).toBe(opaquePrompt) + expect(opaque.sections[0]).toMatchObject({ + content: opaquePrompt, + contentHash: expect.stringMatching(/^[a-f0-9]{64}$/), + degradationCodes: ['legacy_prompt_provenance'] + }) + + const effectivePrompt = '\n Effective prompt ' + const reconciled = reconcilePromptAssembly(opaque, effectivePrompt) + expect(reconciled.prompt).toBe(effectivePrompt) + expect(reconciled.sections[0]).toMatchObject({ + content: effectivePrompt, + degradationCodes: ['prompt_projection_mismatch'] + }) + }) + + it('does not turn observed provenance into prompt content during a later append', () => { + const configured = assemblePromptSections([ + createPromptAssemblySection({ + kind: 'configured_prompt', + sourceRef: 'configured', + content: 'Configured safety text' + }) + ]) + const observed = recordPromptAssemblyObservation( + configured, + createPromptAssemblySection({ + kind: 'attachment_safety', + sourceRef: 'runtime:attachment-text-safety', + content: 'Configured safety text' + }) + ) + const appended = appendPromptAssemblySection( + observed, + createPromptAssemblySection({ + kind: 'verification_policy', + sourceRef: 'runtime:verification', + content: 'Verify' + }) + ) + + expect(appended.prompt).toBe('Configured safety text\n\nVerify') + expect(appended.sections).toHaveLength(3) + }) +}) diff --git a/test/main/agent/deepchat/resources/systemEnvPromptBuilder.test.ts b/test/main/agent/deepchat/resources/systemEnvPromptBuilder.test.ts index 8e3072ced..142039cfe 100644 --- a/test/main/agent/deepchat/resources/systemEnvPromptBuilder.test.ts +++ b/test/main/agent/deepchat/resources/systemEnvPromptBuilder.test.ts @@ -3,7 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import logger from '@shared/logger' import { buildCommandShellPromptLine, - buildSystemEnvPrompt + buildSystemEnvPrompt, + buildSystemEnvPromptAssembly } from '@/agent/deepchat/resources/systemEnvPromptBuilder' import { CMD_COMMAND_SHELL, @@ -44,11 +45,46 @@ describe('buildSystemEnvPrompt', () => { '[SystemEnvPromptBuilder] Failed to read AGENTS.md', expect.anything() ) + + const assembly = await buildSystemEnvPromptAssembly({ + workdir: '/tmp/deepchat-env-prompt-missing', + providerId: 'provider', + modelId: 'model', + commandShell: POSIX_COMMAND_SHELL, + now: new Date('2026-06-22T00:00:00Z') + }) + expect(assembly.sections.find((section) => section.kind === 'agents_instructions')).toMatchObject( + { + inclusion: 'omitted', + freshness: 'missing', + degradationCodes: ['agents_file_missing'] + } + ) }) it('includes instructions when AGENTS.md exists', async () => { vi.mocked(fs.promises.readFile).mockResolvedValue('Use concise answers.\n') + const freshAssembly = await buildSystemEnvPromptAssembly({ + workdir: '/tmp/deepchat-env-prompt-present', + providerId: 'provider', + modelId: 'model', + commandShell: POSIX_COMMAND_SHELL, + now: new Date('2026-06-22T00:00:00Z') + }) + + expect(freshAssembly.prompt).toContain( + 'Instructions from: /tmp/deepchat-env-prompt-present/AGENTS.md' + ) + expect(freshAssembly.prompt).toContain('Use concise answers.') + expect( + freshAssembly.sections.find((section) => section.kind === 'agents_instructions') + ).toMatchObject({ + inclusion: 'included', + freshness: 'fresh', + contentHash: expect.stringMatching(/^[a-f0-9]{64}$/) + }) + const prompt = await buildSystemEnvPrompt({ workdir: '/tmp/deepchat-env-prompt-present', providerId: 'provider', @@ -56,9 +92,22 @@ describe('buildSystemEnvPrompt', () => { commandShell: POSIX_COMMAND_SHELL, now: new Date('2026-06-22T00:00:00Z') }) + expect(prompt).toBe(freshAssembly.prompt) - expect(prompt).toContain('Instructions from: /tmp/deepchat-env-prompt-present/AGENTS.md') - expect(prompt).toContain('Use concise answers.') + const assembly = await buildSystemEnvPromptAssembly({ + workdir: '/tmp/deepchat-env-prompt-present', + providerId: 'provider', + modelId: 'model', + commandShell: POSIX_COMMAND_SHELL, + now: new Date('2026-06-22T00:00:00Z') + }) + expect(assembly.sections.find((section) => section.kind === 'agents_instructions')).toMatchObject( + { + inclusion: 'included', + freshness: 'cached', + contentHash: expect.stringMatching(/^[a-f0-9]{64}$/) + } + ) }) it('logs lightweight metadata for real AGENTS.md read errors', async () => { @@ -78,6 +127,21 @@ describe('buildSystemEnvPrompt', () => { code: 'EISDIR', message: 'EISDIR mock error' }) + + const assembly = await buildSystemEnvPromptAssembly({ + workdir: '/tmp/deepchat-env-prompt-error', + providerId: 'provider', + modelId: 'model', + commandShell: POSIX_COMMAND_SHELL, + now: new Date('2026-06-22T00:00:00Z') + }) + expect(assembly.sections.find((section) => section.kind === 'agents_instructions')).toMatchObject( + { + inclusion: 'omitted', + freshness: 'read_error', + degradationCodes: ['agents_file_read_error'] + } + ) }) it('defers slow first reads and reuses the late cached result', async () => { @@ -89,7 +153,7 @@ describe('buildSystemEnvPrompt', () => { }) as ReturnType ) - const promptPromise = buildSystemEnvPrompt({ + const promptPromise = buildSystemEnvPromptAssembly({ workdir: '/tmp/deepchat-env-prompt-slow', providerId: 'provider', modelId: 'model', @@ -98,9 +162,16 @@ describe('buildSystemEnvPrompt', () => { }) await vi.advanceTimersByTimeAsync(200) - const prompt = await promptPromise + const promptAssembly = await promptPromise - expect(prompt).not.toContain('Instructions from:') + expect(promptAssembly.prompt).not.toContain('Instructions from:') + expect( + promptAssembly.sections.find((section) => section.kind === 'agents_instructions') + ).toMatchObject({ + inclusion: 'omitted', + freshness: 'deferred', + degradationCodes: ['agents_file_deferred'] + }) expect(logger.warn).toHaveBeenCalledWith('[SystemEnvPromptBuilder] AGENTS.md read deferred', { sourcePath: '/tmp/deepchat-env-prompt-slow/AGENTS.md', budgetMs: 200 @@ -110,7 +181,7 @@ describe('buildSystemEnvPrompt', () => { await Promise.resolve() await Promise.resolve() - const cachedPrompt = await buildSystemEnvPrompt({ + const cachedAssembly = await buildSystemEnvPromptAssembly({ workdir: '/tmp/deepchat-env-prompt-slow', providerId: 'provider', modelId: 'model', @@ -118,7 +189,13 @@ describe('buildSystemEnvPrompt', () => { now: new Date('2026-06-22T00:00:00Z') }) - expect(cachedPrompt).toContain('Late instructions.') + expect(cachedAssembly.prompt).toContain('Late instructions.') + expect( + cachedAssembly.sections.find((section) => section.kind === 'agents_instructions') + ).toMatchObject({ + inclusion: 'included', + freshness: 'cached' + }) expect(fs.promises.readFile).toHaveBeenCalledTimes(1) }) diff --git a/test/main/agent/deepchat/resources/systemPromptBuilder.test.ts b/test/main/agent/deepchat/resources/systemPromptBuilder.test.ts index 7daf6ea04..cfb778338 100644 --- a/test/main/agent/deepchat/resources/systemPromptBuilder.test.ts +++ b/test/main/agent/deepchat/resources/systemPromptBuilder.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it, vi } from 'vitest' import fs from 'fs' import type { DeepChatAgentInstance } from '@/agent/deepchat/instance/deepChatAgentInstance' -import { buildSystemPromptWithSkills } from '@/agent/deepchat/resources/systemPromptBuilder' +import { + buildSystemPromptAssemblyWithSkills, + buildSystemPromptWithSkills +} from '@/agent/deepchat/resources/systemPromptBuilder' import { LIVE_DELEGATION_AGENT_TOOL_NAME } from '@shared/agentTools' import { UNTRUSTED_CHILD_OUTPUT_POLICY } from '@shared/orchestration/resultSafety' import { POSIX_COMMAND_SHELL } from '../../../../helpers/commandShell' @@ -36,8 +39,7 @@ describe('DeepChat system prompt builder', () => { } as unknown as DeepChatAgentInstance const assertCurrent = vi.fn() const dependencies = { - providerSettings: { - } as unknown as ProviderSettingsPort, + providerSettings: {} as unknown as ProviderSettingsPort, skillSettings: { isEnabled: () => false, isDraftSuggestionsEnabled: () => false @@ -75,6 +77,23 @@ describe('DeepChat system prompt builder', () => { commandShell: POSIX_COMMAND_SHELL, resourceInstance: instance }) + const assembly = await buildSystemPromptAssemblyWithSkills(dependencies, { + sessionId: 'session-1', + basePrompt: ' BASE PROMPT ', + toolDefinitions: [], + commandShell: POSIX_COMMAND_SHELL, + resourceInstance: instance + }) + const acpAssembly = await buildSystemPromptAssemblyWithSkills( + { ...dependencies, isAcpBackedSubagentSession: () => true }, + { + sessionId: 'session-1', + basePrompt: ' BASE PROMPT ', + toolDefinitions: [], + commandShell: POSIX_COMMAND_SHELL, + resourceInstance: instance + } + ) const explicit = await buildSystemPromptWithSkills(dependencies, { sessionId: 'session-1', basePrompt: ' BASE PROMPT ', @@ -122,6 +141,51 @@ describe('DeepChat system prompt builder', () => { expect(first).toContain('## Verification Policy') expect(first).not.toContain('## Multi-Agent Orchestration Policy') expect(second).toBe(first) + expect(assembly.prompt).toBe(first) + expect(acpAssembly).toMatchObject({ + prompt: 'BASE PROMPT', + sections: [{ kind: 'configured_prompt', inclusion: 'included' }] + }) + expect(first).toBe( + [ + 'BASE PROMPT', + [ + 'You are powered by the model named GPT-4o.', + 'The exact model ID is openai/gpt-4o', + 'Here is some useful information about the environment you are running in:', + '', + 'Working directory: /tmp/deepchat-system-prompt-builder-test-no-agents', + 'Is directory a git repo: no', + `Platform: ${process.platform}`, + 'Shell: sh.', + `Today's date: ${new Date().toDateString()}`, + '' + ].join('\n'), + [ + '## Verification Policy', + 'After changing code, configuration, tests, docs that affect behavior, or generated assets, check verification status before the final response.', + 'If verification was not run, state the reason explicitly in the final response.' + ].join('\n') + ].join('\n\n') + ) + expect(assembly.sections.map((section) => section.kind)).toEqual([ + 'configured_prompt', + 'runtime_capabilities', + 'system_environment', + 'agents_instructions', + 'skills_metadata', + 'pinned_skills', + 'tooling', + 'orchestration_policy', + 'permission_rules', + 'verification_policy' + ]) + expect(assembly.sections.find((section) => section.kind === 'configured_prompt')).toMatchObject( + { + inclusion: 'included', + contentHash: '2f438783cf88972d8d9fd3394aac256edde99cd6d9a8e9166aff93ec5bcfc2c4' + } + ) expect(explicit).toContain('## Multi-Agent Orchestration Policy') expect(explicit).toContain('explicit multi-Agent collaboration') expect(explicit).toContain('user, an active Skill, or project instructions explicitly request') @@ -189,6 +253,69 @@ describe('DeepChat system prompt builder', () => { expect(loadSkillContent).toHaveBeenCalledWith('writer', 'skill-b') }) + it('loads requested Skills when catalog metadata is temporarily unavailable', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.mocked(fs.promises.readFile).mockRejectedValue( + Object.assign(new Error('missing'), { code: 'ENOENT' }) + ) + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const instance = { + getRuntimeState: () => ({ providerId: 'openai', modelId: 'gpt-4o' }), + hasProjectDir: () => false + } as unknown as DeepChatAgentInstance + const loadSkillContent = vi.fn().mockResolvedValue({ + name: 'skill-a', + content: 'skill-a instructions' + }) + + try { + const assembly = await buildSystemPromptAssemblyWithSkills( + { + providerSettings: {} as unknown as ProviderSettingsPort, + skillSettings: { + isEnabled: () => true, + isDraftSuggestionsEnabled: () => false + }, + providerCatalogPort: { + getProviderModels: () => [{ id: 'gpt-4o', name: 'GPT-4o' }], + getCustomModels: () => [] + }, + skillService: { + resolveSessionAgentId: vi.fn().mockResolvedValue('writer'), + getMetadataList: vi.fn().mockRejectedValue(new Error('catalog unavailable')), + getActiveSkills: vi.fn().mockResolvedValue([]), + loadSkillContent + }, + toolService: { buildToolSystemPrompt: vi.fn().mockReturnValue('') }, + assertCurrent: vi.fn(), + isAcpBackedSubagentSession: () => false, + resolveProjectDir: () => null, + logSlowStep: vi.fn() + }, + { + sessionId: 'session-1', + basePrompt: '', + toolDefinitions: [], + activeSkillNamesOverride: ['skill-a'], + commandShell: POSIX_COMMAND_SHELL, + resourceInstance: instance + } + ) + + expect(loadSkillContent).toHaveBeenCalledWith('writer', 'skill-a') + expect(assembly.prompt).toContain('### skill-a\nskill-a instructions') + expect(assembly.sections.find((section) => section.kind === 'skills_metadata')).toMatchObject({ + inclusion: 'omitted', + degradationCodes: ['skill_metadata_unavailable'] + }) + const pinnedSkills = assembly.sections.find((section) => section.kind === 'pinned_skills') + expect(pinnedSkills).toMatchObject({ inclusion: 'included' }) + expect(pinnedSkills).not.toHaveProperty('degradationCodes') + } finally { + consoleWarn.mockRestore() + } + }) + it('observes model, tool prompt, and package script changes on the next assembly', async () => { let modelName = 'Model One' let toolPrompt = 'TOOL PROMPT ONE' @@ -259,4 +386,128 @@ describe('DeepChat system prompt builder', () => { expect(second).not.toContain('`verify`') expect(fs.readFileSync).toHaveBeenCalledTimes(2) }) + + it('records pinned-skill and tooling degradation without blocking assembly', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.mocked(fs.promises.readFile).mockRejectedValue( + Object.assign(new Error('missing'), { code: 'ENOENT' }) + ) + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const instance = { + getRuntimeState: () => ({ providerId: 'openai', modelId: 'gpt-4o' }), + hasProjectDir: () => true, + getProjectDir: () => '/tmp/deepchat-system-prompt-builder-degraded' + } as unknown as DeepChatAgentInstance + + try { + const assembly = await buildSystemPromptAssemblyWithSkills( + { + providerSettings: {} as unknown as ProviderSettingsPort, + skillSettings: { + isEnabled: () => true, + isDraftSuggestionsEnabled: () => false + }, + providerCatalogPort: { + getProviderModels: () => [{ id: 'gpt-4o', name: 'GPT-4o' }], + getCustomModels: () => [] + }, + skillService: { + resolveSessionAgentId: vi.fn().mockResolvedValue('writer'), + getMetadataList: vi.fn().mockResolvedValue([ + { name: 'skill-a', description: 'Skill A' }, + { name: 'skill-b', description: 'Skill B' } + ]), + getActiveSkills: vi.fn().mockResolvedValue([]), + loadSkillContent: vi.fn(async (_agentId: string, skillName: string) => { + if (skillName === 'skill-b') throw new Error('unavailable') + return { name: skillName, content: `${skillName} instructions` } + }) + }, + toolService: { + buildToolSystemPrompt: vi.fn(() => { + throw new Error('tooling unavailable') + }) + }, + assertCurrent: vi.fn(), + isAcpBackedSubagentSession: () => false, + resolveProjectDir: () => null, + logSlowStep: vi.fn() + }, + { + sessionId: 'session-1', + basePrompt: '', + toolDefinitions: [], + activeSkillNamesOverride: ['skill-a', 'skill-b'], + commandShell: POSIX_COMMAND_SHELL, + resourceInstance: instance + } + ) + + expect(assembly.prompt).toContain('### skill-a') + expect(assembly.prompt).not.toContain('### skill-b') + expect(assembly.sections.find((section) => section.kind === 'pinned_skills')).toMatchObject({ + inclusion: 'degraded', + degradationCodes: ['pinned_skill_load_failed'] + }) + expect(assembly.sections.find((section) => section.kind === 'tooling')).toMatchObject({ + inclusion: 'omitted', + degradationCodes: ['tooling_build_failed'] + }) + } finally { + consoleWarn.mockRestore() + } + }) + + it('records a missing scoped Agent identity even when resolution returns null', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.mocked(fs.promises.readFile).mockRejectedValue( + Object.assign(new Error('missing'), { code: 'ENOENT' }) + ) + const instance = { + getRuntimeState: () => ({ providerId: 'openai', modelId: 'gpt-4o' }), + hasProjectDir: () => true, + getProjectDir: () => '/tmp/deepchat-system-prompt-builder-no-agent' + } as unknown as DeepChatAgentInstance + + const assembly = await buildSystemPromptAssemblyWithSkills( + { + providerSettings: {} as unknown as ProviderSettingsPort, + skillSettings: { + isEnabled: () => true, + isDraftSuggestionsEnabled: () => false + }, + providerCatalogPort: { + getProviderModels: () => [{ id: 'gpt-4o', name: 'GPT-4o' }], + getCustomModels: () => [] + }, + skillService: { + resolveSessionAgentId: vi.fn().mockResolvedValue(null), + getMetadataList: vi.fn().mockResolvedValue([]), + getActiveSkills: vi.fn().mockResolvedValue([]), + loadSkillContent: vi.fn() + }, + toolService: { buildToolSystemPrompt: vi.fn().mockReturnValue('') }, + assertCurrent: vi.fn(), + isAcpBackedSubagentSession: () => false, + resolveProjectDir: () => null, + logSlowStep: vi.fn() + }, + { + sessionId: 'session-1', + basePrompt: '', + toolDefinitions: [], + commandShell: POSIX_COMMAND_SHELL, + resourceInstance: instance + } + ) + + expect(assembly.sections.find((section) => section.kind === 'skills_metadata')).toMatchObject({ + inclusion: 'omitted', + degradationCodes: ['skill_agent_unavailable'] + }) + expect(assembly.sections.find((section) => section.kind === 'pinned_skills')).toMatchObject({ + inclusion: 'omitted', + degradationCodes: ['skill_agent_unavailable'] + }) + }) }) diff --git a/test/main/agent/deepchat/runtime/deferredExecutionContract.test.ts b/test/main/agent/deepchat/runtime/deferredExecutionContract.test.ts new file mode 100644 index 000000000..cda1b64b7 --- /dev/null +++ b/test/main/agent/deepchat/runtime/deferredExecutionContract.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it, vi } from 'vitest' +import { TOOL_EXECUTION, type MCPToolDefinition } from '@shared/types/core/mcp' +import { + buildExecutionContract, + buildExecutionContractBinding +} from '@/tape/domain/executionContract' +import { createTapeViewManifest } from '@/tape/domain/viewManifest' +import { resolveDeferredExecutionContract } from '@/agent/deepchat/runtime/deferredExecutionContract' + +const RUN_ID = '11111111-1111-4111-8111-111111111111' +const TOOL: MCPToolDefinition = { + type: 'function', + source: 'agent', + execution: TOOL_EXECUTION.write, + function: { + name: 'write_file', + description: 'Write a file', + parameters: { type: 'object', properties: {} } + }, + server: { name: 'agent-filesystem', icons: '', description: 'Agent filesystem' } +} + +function createFixture(permissionMode: 'default' | 'full_access' = 'default') { + const messages = [{ role: 'user' as const, content: 'Write a.txt' }] + const executionContract = buildExecutionContract({ + request: { + sessionId: 'session-1', + messageId: 'message-1', + runId: RUN_ID, + requestSeq: 3 + }, + promptAssembly: { prompt: '', sections: [] }, + providerMessages: messages, + tools: [TOOL], + providerId: 'openai', + modelId: 'gpt-5', + modelConfig: {} as any, + temperature: 0.2, + maxTokens: 100, + workspace: { kind: 'path', path: '/workspace' }, + maxSubagentDepth: 0, + dynamicControlSnapshot: { + permissionMode, + requestAdmitted: true, + cancellationRequested: false + }, + assemblerVersion: 'test-v1' + }) + const manifest = createTapeViewManifest({ + sessionId: 'session-1', + messageId: 'message-1', + requestSeq: 3, + taskType: 'tool_loop', + policy: 'tool_loop_shadow', + policyVersion: null, + contextBuilderVersion: 'legacy-v1', + messages, + tools: [TOOL], + latestEntryId: 7, + anchorEntryIds: [1], + included: [], + excluded: [], + tokenBudget: { + contextLength: 1000, + requestedMaxTokens: 100, + effectiveMaxTokens: 100, + reserveTokens: 100, + toolReserveTokens: 0 + }, + providerId: 'openai', + modelId: 'gpt-5', + summaryCursorOrderSeq: 1, + supportsVision: false, + supportsAudioInput: false, + traceDebugEnabled: false, + executionContract, + assembledAt: 123 + }) + const record = { + sessionId: 'session-1', + messageId: 'message-1', + requestSeq: 3, + entryId: 8, + createdAt: 123, + integrity: 'valid' as const, + manifest + } + return { + executionContract, + manifest, + record, + rawBinding: JSON.stringify(buildExecutionContractBinding(executionContract)) + } +} + +function createReader(records: ReturnType['record'][] = []) { + return { listViewManifestsByMessage: vi.fn(() => records) } +} + +describe('deferred ExecutionContract recovery', () => { + it('uses the exact live projection without reading Tape', () => { + const fixture = createFixture() + const viewManifests = createReader() + + const resolved = resolveDeferredExecutionContract({ + sessionId: 'session-1', + messageId: 'message-1', + rawBinding: fixture.rawBinding, + runtimeContract: fixture.executionContract, + viewManifests + }) + + expect(resolved).toBe(fixture.executionContract) + expect(viewManifests.listViewManifestsByMessage).not.toHaveBeenCalled() + }) + + it('recovers a frozen projection from the single hash-verified v5 View', () => { + const fixture = createFixture() + const storedRecord = JSON.parse(JSON.stringify(fixture.record)) as typeof fixture.record + const viewManifests = createReader([storedRecord]) + + const resolved = resolveDeferredExecutionContract({ + sessionId: 'session-1', + messageId: 'message-1', + rawBinding: fixture.rawBinding, + viewManifests + }) + + expect(resolved).toEqual(fixture.executionContract) + expect(Object.isFrozen(resolved)).toBe(true) + expect(viewManifests.listViewManifestsByMessage).toHaveBeenCalledWith( + 'session-1', + 'message-1' + ) + }) + + it('keeps legacy unbound interactions compatible without reading Tape', () => { + const viewManifests = createReader() + + expect( + resolveDeferredExecutionContract({ + sessionId: 'session-1', + messageId: 'message-1', + rawBinding: undefined, + viewManifests + }) + ).toBeUndefined() + expect(viewManifests.listViewManifestsByMessage).not.toHaveBeenCalled() + }) + + it.each([ + { name: 'missing View', records: () => [] }, + { + name: 'duplicate View', + records: (fixture: ReturnType) => [ + fixture.record, + { ...fixture.record, entryId: fixture.record.entryId + 1 } + ] + }, + { + name: 'invalid manifest hash', + records: (fixture: ReturnType) => [ + { + ...fixture.record, + manifest: { ...fixture.manifest, viewId: 'tampered' } + } + ] + }, + { + name: 'conflicting contract', + records: () => [createFixture('full_access').record] + } + ])('fails closed on $name', (scenario) => { + const fixture = createFixture() + const records = scenario.records(fixture) as typeof fixture.record[] + + expect(() => + resolveDeferredExecutionContract({ + sessionId: 'session-1', + messageId: 'message-1', + rawBinding: fixture.rawBinding, + viewManifests: createReader(records) + }) + ).toThrow(expect.objectContaining({ code: 'invalid_contract' })) + }) + + it('rejects a binding for another message before reading Tape', () => { + const fixture = createFixture() + const binding = buildExecutionContractBinding(fixture.executionContract) + const viewManifests = createReader([fixture.record]) + + expect(() => + resolveDeferredExecutionContract({ + sessionId: 'session-1', + messageId: 'other-message', + rawBinding: JSON.stringify(binding), + viewManifests + }) + ).toThrow(expect.objectContaining({ code: 'identity_mismatch' })) + expect(viewManifests.listViewManifestsByMessage).not.toHaveBeenCalled() + }) + + it.each(['{', 'x'.repeat(4097)])('rejects malformed or oversized binding data', (rawBinding) => { + const viewManifests = createReader() + + expect(() => + resolveDeferredExecutionContract({ + sessionId: 'session-1', + messageId: 'message-1', + rawBinding, + viewManifests + }) + ).toThrow(expect.objectContaining({ code: 'invalid_contract' })) + expect(viewManifests.listViewManifestsByMessage).not.toHaveBeenCalled() + }) +}) diff --git a/test/main/agent/deepchat/runtime/deferredToolExecutor.test.ts b/test/main/agent/deepchat/runtime/deferredToolExecutor.test.ts index 5cc263417..6d969e8a0 100644 --- a/test/main/agent/deepchat/runtime/deferredToolExecutor.test.ts +++ b/test/main/agent/deepchat/runtime/deferredToolExecutor.test.ts @@ -4,6 +4,9 @@ import { type DeferredToolExecutorDependencies } from '@/agent/deepchat/runtime/deferredToolExecutor' import { ExecutionJournalError } from '@/tape/domain/executionJournal' +import { TOOL_EXECUTION, type MCPToolDefinition } from '@shared/types/core/mcp' +import { createOpaquePromptAssembly } from '@/agent/deepchat/resources/promptAssembly' +import { buildExecutionContract } from '@/tape/domain/executionContract' import { GIT_BASH_COMMAND_SHELL, POSIX_COMMAND_SHELL @@ -18,6 +21,49 @@ const TOOL_CALL = { response: '', server_name: 'agent-filesystem' } +const CONTRACT_RUN_ID = '11111111-1111-4111-8111-111111111111' +const TOOL_DEFINITION: MCPToolDefinition = { + type: 'function', + source: 'agent', + execution: TOOL_EXECUTION.write, + function: { + name: TOOL_CALL.name, + description: 'Write a file', + parameters: { type: 'object', properties: {} } + }, + server: { name: 'agent-filesystem', icons: '', description: 'Agent filesystem' } +} + +function buildContract() { + const promptAssembly = createOpaquePromptAssembly('System prompt') + return buildExecutionContract({ + request: { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + runId: CONTRACT_RUN_ID, + requestSeq: 3 + }, + promptAssembly, + providerMessages: [ + { role: 'system', content: promptAssembly.prompt }, + { role: 'user', content: 'Write a.txt' } + ], + tools: [TOOL_DEFINITION], + providerId: 'openai', + modelId: 'gpt-5', + modelConfig: {} as any, + temperature: 0.2, + maxTokens: 100, + workspace: { kind: 'path', path: '/workspace' }, + maxSubagentDepth: 0, + dynamicControlSnapshot: { + permissionMode: 'default', + requestAdmitted: true, + cancellationRequested: false + }, + assemblerVersion: 'test-v1' + }) +} type ToolExecutionOptions = Parameters< DeferredToolExecutorDependencies['toolExecutionPort']['execute'] @@ -94,14 +140,7 @@ function createHarness( fitBatch: vi.fn() }, toolResolver: { - loadToolDefinitionsForSession: vi.fn(async () => [ - { - type: 'function', - source: 'agent', - function: { name: 'write_file' }, - server: { name: 'agent-filesystem' } - } - ]), + loadToolDefinitionsForSession: vi.fn(async () => [TOOL_DEFINITION]), getDisabledAgentTools: vi.fn(() => []), resolveAgentExtensionPolicy: vi.fn(async () => ({ enabledMcpServerIds: [] })), resolveActiveSkillNamesForToolProfile: vi.fn(async () => []), @@ -142,6 +181,7 @@ function createHarness( MESSAGE_ID, TOOL_CALL, onToolCallStarted, + undefined, 'posix' ), executionJournal, @@ -202,6 +242,33 @@ describe('DeferredToolExecutor Execution Journal', () => { }) }) + it('uses the originating provider View identity while journaling a distinct deferred run', async () => { + const { dependencies, executionJournal, executor } = createHarness() + const executionContract = buildContract() + + await executor.execute( + SESSION_ID, + MESSAGE_ID, + TOOL_CALL, + undefined, + executionContract, + 'posix' + ) + + expect(dependencies.toolExecutionPort.execute).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + runId: CONTRACT_RUN_ID, + messageId: MESSAGE_ID, + requestSeq: 3, + executionContract + }) + ) + const deferredRunId = executionJournal.commitRunStarted.mock.calls[0][0].runId + expect(deferredRunId).not.toBe(CONTRACT_RUN_ID) + expect(executionJournal.commitDispatch.mock.calls[0][0].operation.runId).toBe(deferredRunId) + }) + it('resolves a stored shell profile instead of the current preference', async () => { const { dependencies, executor } = createHarness() @@ -210,6 +277,7 @@ describe('DeferredToolExecutor Execution Journal', () => { MESSAGE_ID, TOOL_CALL, undefined, + undefined, 'git-bash', 'command-grant-deferred' ) @@ -229,7 +297,14 @@ describe('DeferredToolExecutor Execution Journal', () => { const { dependencies, executor } = createHarness() await expect( - executor.execute(SESSION_ID, MESSAGE_ID, TOOL_CALL, undefined, 'unknown-shell' as never) + executor.execute( + SESSION_ID, + MESSAGE_ID, + TOOL_CALL, + undefined, + undefined, + 'unknown-shell' as never + ) ).resolves.toMatchObject({ isError: true, invoked: false }) expect(dependencies.commandShell.resolveProfile).not.toHaveBeenCalled() @@ -261,7 +336,7 @@ describe('DeferredToolExecutor Execution Journal', () => { const { server_name: _serverName, ...unboundToolCall } = TOOL_CALL await expect( - executor.execute(SESSION_ID, MESSAGE_ID, unboundToolCall, undefined, 'posix') + executor.execute(SESSION_ID, MESSAGE_ID, unboundToolCall, undefined, undefined, 'posix') ).resolves.toMatchObject({ responseText: 'Deferred tool execution is missing its server identity.', isError: true, @@ -308,6 +383,7 @@ describe('DeferredToolExecutor Execution Journal', () => { { ...TOOL_CALL, name: 'echo', server_name: 'mcp-server' }, undefined, undefined, + undefined, 'command-grant-without-profile' ) ).resolves.toMatchObject({ diff --git a/test/main/agent/deepchat/runtime/process.test.ts b/test/main/agent/deepchat/runtime/process.test.ts index cfd797770..ab70563c2 100644 --- a/test/main/agent/deepchat/runtime/process.test.ts +++ b/test/main/agent/deepchat/runtime/process.test.ts @@ -16,7 +16,10 @@ import { createToolExecutionPort, createToolResultPort } from '@/agent/deepchat/runtime/toolAdapters' -import { createLoopRun } from '@/agent/deepchat/loop/loopRun' +import { + bindActiveRequestContract, + createLoopRun +} from '@/agent/deepchat/loop/loopRun' import type { DeepChatLoopNotification } from '@/agent/deepchat/loop/ports' import { toAppSessionId } from '@/agent/shared/agentSessionIds' import { resolveToolOffloadPath } from '@/agent/shared/storage/sessionPaths' @@ -43,6 +46,11 @@ import { resolveProviderTerminalDecision } from '@/agent/deepchat/runtime/process' import { TRUNCATED_TOOL_CALL_ERROR } from '@/agent/deepchat/runtime/dispatch' +import { createOpaquePromptAssembly } from '@/agent/deepchat/resources/promptAssembly' +import { + buildExecutionContract, + buildExecutionContractBinding +} from '@/tape/domain/executionContract' function expectDeepchatEvent(eventName: string, payload: Record): void { expect(publishDeepchatEventMock).toHaveBeenCalledWith(eventName, expect.objectContaining(payload)) @@ -918,6 +926,125 @@ describe('processStream', () => { }) }) + it('carries the active request contract to tool dispatch by exact reference', async () => { + const tools = [makeTool('action')] + const run = createLoopRun({ + runId: RUN_ID, + sessionId: toAppSessionId('s1'), + messageId: 'm1', + abortController: new AbortController(), + messages: [{ role: 'user', content: 'Hello' }], + streamState: createState(), + resources: { + toolDefinitions: tools, + activeSkillNames: [], + commandShell: POSIX_COMMAND_SHELL + }, + initialRequestSeq: 1 + }) + const executionContract = { + request: { + sessionId: run.sessionId, + messageId: run.messageId, + runId: run.runId, + requestSeq: 1 + } + } as any + bindActiveRequestContract(run, 1, executionContract) + const toolService = createMockToolService({ action: 'ok' }) + + await processStream( + createParams({ + run, + coreStream: createToolThenCompleteStream('action'), + toolExecution: createToolExecutionPort(toolService), + tools + }) + ) + + expect(toolService.callTool).toHaveBeenCalled() + expect((toolService.callTool as ReturnType).mock.calls[0][1]).toMatchObject({ + runId: RUN_ID, + messageId: 'm1', + requestSeq: 1, + executionContract + }) + expect( + (toolService.callTool as ReturnType).mock.calls[0][1].executionContract + ).toBe(executionContract) + }) + + it('keeps the exact request contract and a durable View binding across permission pause', async () => { + const tools = [{ ...makeTool('action'), source: 'agent' as const }] + const run = createLoopRun({ + runId: RUN_ID, + sessionId: toAppSessionId('s1'), + messageId: 'm1', + abortController: new AbortController(), + messages: [{ role: 'user', content: 'Hello' }], + streamState: createState(), + resources: { + toolDefinitions: tools, + activeSkillNames: [], + commandShell: POSIX_COMMAND_SHELL + }, + initialRequestSeq: 1 + }) + const promptAssembly = createOpaquePromptAssembly('System prompt') + const executionContract = buildExecutionContract({ + request: { + sessionId: run.sessionId, + messageId: run.messageId, + runId: run.runId, + requestSeq: 1 + }, + promptAssembly, + providerMessages: [ + { role: 'system', content: promptAssembly.prompt }, + { role: 'user', content: 'Hello' } + ], + tools, + providerId: 'openai', + modelId: 'gpt-4', + modelConfig: {} as any, + temperature: 0.7, + maxTokens: 4096, + workspace: { kind: 'runtime_default' }, + maxSubagentDepth: 0, + dynamicControlSnapshot: { + permissionMode: 'default', + requestAdmitted: true, + cancellationRequested: false + }, + assemblerVersion: 'test-v1' + }) + bindActiveRequestContract(run, 1, executionContract) + + const result = await processStream( + createParams({ + run, + coreStream: createToolRoundStream('action'), + toolExecution: createToolExecutionPort(createPostCallPermissionToolService()), + tools, + permissionMode: 'default' + }) + ) + + expect(result.status).toBe('paused') + expect(result.toolBatchExecutionState?.executionContract).toBe(executionContract) + const finalPauseCall = messageStore.updateAssistantContent.mock.calls.findLast( + (call) => typeof call[2] === 'string' + ) + expect(finalPauseCall).toBeDefined() + const permissionBlock = finalPauseCall?.[1].find( + (block) => block.action_type === 'tool_call_permission' + ) + expect(permissionBlock).toBeDefined() + expect(JSON.parse(permissionBlock?.extra?.executionContractBinding as string)).toEqual( + buildExecutionContractBinding(executionContract) + ) + }) + it('counts a post-call permission tool before persisting pause', async () => { const toolService = createPostCallPermissionToolService() @@ -934,11 +1061,16 @@ describe('processStream', () => { status: 'paused', pendingInteractions: [expect.objectContaining({ origin: 'post-call-permission' })] }) + expect(result.toolBatchExecutionState?.executionContract).toBeUndefined() expect(toolService.callTool).toHaveBeenCalledOnce() const finalPauseCall = messageStore.updateAssistantContent.mock.calls.findLast( (call) => typeof call[2] === 'string' ) expect(finalPauseCall).toBeDefined() + const permissionBlock = finalPauseCall?.[1].find( + (block) => block.action_type === 'tool_call_permission' + ) + expect(permissionBlock?.extra?.executionContractBinding).toBeUndefined() expect(JSON.parse(finalPauseCall?.[2])).toMatchObject({ providerRounds: 1, toolCalls: 1, @@ -2515,7 +2647,7 @@ describe('processStream', () => { const resolveTools = vi .fn() .mockResolvedValue([makeTool('skill_view'), makeTool('deepchat_settings_set_theme')]) - const refreshSystemPrompt = vi.fn().mockResolvedValue('refreshed skill prompt') + const refreshSystemPrompt = vi.fn().mockResolvedValue(' refreshed skill prompt\n') const coreStream = vi.fn( function (messages, _modelId, _modelConfig, _temperature, _maxTokens, tools) { @@ -2537,7 +2669,7 @@ describe('processStream', () => { })() } if (callCount === 2) { - expect(messages[0]).toEqual({ role: 'system', content: 'refreshed skill prompt' }) + expect(messages[0]).toEqual({ role: 'system', content: ' refreshed skill prompt\n' }) expect(tools.map((tool) => tool.function.name)).toEqual([ 'skill_view', 'deepchat_settings_set_theme' @@ -2595,6 +2727,15 @@ describe('processStream', () => { ) expect(coreStream).toHaveBeenCalledTimes(3) expect(toolService.callTool).toHaveBeenCalledTimes(2) + expect(params.run.resources.promptAssembly).toMatchObject({ + prompt: ' refreshed skill prompt\n', + sections: [ + expect.objectContaining({ + kind: 'effective_system_prompt', + degradationCodes: ['legacy_prompt_provenance'] + }) + ] + }) }) it('does not refresh tools after linked-file skill_view reads', async () => { diff --git a/test/main/agent/deepchat/runtime/promptAssemblyService.test.ts b/test/main/agent/deepchat/runtime/promptAssemblyService.test.ts index c47c3ed56..c003571cc 100644 --- a/test/main/agent/deepchat/runtime/promptAssemblyService.test.ts +++ b/test/main/agent/deepchat/runtime/promptAssemblyService.test.ts @@ -12,8 +12,23 @@ const SESSION_ID = 'session' const buildSystemPromptWithSkills = vi.hoisted(() => vi.fn(async () => 'assembled system prompt') ) +const buildSystemPromptAssemblyWithSkills = vi.hoisted(() => + vi.fn(async () => ({ + prompt: 'assembled system prompt', + sections: [ + { + kind: 'configured_prompt', + sourceRef: 'session:generation-settings.system-prompt', + inclusion: 'included', + contentHash: 'a'.repeat(64), + content: 'assembled system prompt' + } + ] + })) +) vi.mock('@/agent/deepchat/resources/systemPromptBuilder', () => ({ + buildSystemPromptAssemblyWithSkills, buildSystemPromptWithSkills })) @@ -113,6 +128,31 @@ describe('PromptAssemblyService', () => { expect(input.toolDefinitions).not.toBe(toolDefinitions) }) + it('returns structured provenance through the bound assembler without sharing mutable inputs', async () => { + const { runtime, service } = createHarness() + buildSystemPromptAssemblyWithSkills.mockClear() + const instance = runtime.getOrHydrate(toAppSessionId('structured')) + const toolDefinitions = [] as never[] + const activeSkillNames = ['skill-a'] + + const assembled = await service.createBasePromptAssembler(instance).assembleWithProvenance({ + sessionId: SESSION_ID, + configuredPrompt: 'base', + toolDefinitions, + activeSkillNames + }) + + expect(assembled).toMatchObject({ + prompt: 'assembled system prompt', + sections: [{ kind: 'configured_prompt', inclusion: 'included' }] + }) + const input = buildSystemPromptAssemblyWithSkills.mock.calls[0][1] as any + expect(input.resourceInstance).toBe(instance) + expect(input.activeSkillNamesOverride).toEqual(activeSkillNames) + expect(input.activeSkillNamesOverride).not.toBe(activeSkillNames) + expect(input.toolDefinitions).not.toBe(toolDefinitions) + }) + it('reports memory inclusion from the contributed content', async () => { const withMemory = createHarness('recalled memory') await expect( diff --git a/test/main/agent/deepchat/runtime/sessionIdentityService.test.ts b/test/main/agent/deepchat/runtime/sessionIdentityService.test.ts index 1de76e074..3d0fe0443 100644 --- a/test/main/agent/deepchat/runtime/sessionIdentityService.test.ts +++ b/test/main/agent/deepchat/runtime/sessionIdentityService.test.ts @@ -59,6 +59,14 @@ describe('SessionIdentityService', () => { expect(missing.identity.isAcpBackedSubagentSession(SESSION_ID, 'acp')).toBe(false) }) + it('reads the persisted Session kind without hydrating runtime state', () => { + const subagent = createHarness({ session_kind: 'subagent' }) + expect(subagent.identity.getSessionKind(SESSION_ID)).toBe('subagent') + expect(subagent.runtime.getHydrated(toAppSessionId(SESSION_ID))).toBeUndefined() + + expect(createHarness(undefined).identity.getSessionKind(SESSION_ID)).toBeNull() + }) + it('falls back to the hydrated runtime provider when no provider is supplied', () => { const { identity, runtime } = createHarness({ session_kind: 'subagent' }) runtime.getOrHydrate(toAppSessionId(SESSION_ID)).setRuntimeState({ diff --git a/test/main/orchestration/liveDelegationMigration.test.ts b/test/main/orchestration/liveDelegationMigration.test.ts index 5c06b6c54..5954203cf 100644 --- a/test/main/orchestration/liveDelegationMigration.test.ts +++ b/test/main/orchestration/liveDelegationMigration.test.ts @@ -9,11 +9,13 @@ const liveDelegationsModule = Database ? await import('@/orchestration/data/tables/liveDelegations').catch(() => null) : null const MainDatabaseCtor = mainDatabaseModule?.MainDatabase! -const ORCHESTRATION_DATABASE_SCHEMA_VERSION = - liveDelegationsModule?.ORCHESTRATION_DATABASE_SCHEMA_VERSION +const LATEST_DATABASE_SCHEMA_VERSION = + liveDelegationsModule?.LIVE_DELEGATION_EVALUATION_DATABASE_SCHEMA_VERSION +const CONTRACT_DATABASE_SCHEMA_VERSION = + liveDelegationsModule?.LIVE_DELEGATION_CONTRACT_DATABASE_SCHEMA_VERSION const DatabaseCtor = Database! const describeIfSqlite = nativeSqliteDescribeIf( - Boolean(MainDatabaseCtor && ORCHESTRATION_DATABASE_SCHEMA_VERSION), + Boolean(MainDatabaseCtor && LATEST_DATABASE_SCHEMA_VERSION && CONTRACT_DATABASE_SCHEMA_VERSION), 'Live delegation migration modules are unavailable' ) @@ -44,7 +46,7 @@ describeIfSqlite('live delegation schema migration', () => { bootstrap.close() const migrated = new MainDatabaseCtor(databasePath) - expect(migrated.getLatestSchemaVersion()).toBe(ORCHESTRATION_DATABASE_SCHEMA_VERSION) + expect(migrated.getLatestSchemaVersion()).toBe(LATEST_DATABASE_SCHEMA_VERSION) migrated.close() const verification = new DatabaseCtor(databasePath) @@ -66,7 +68,7 @@ describeIfSqlite('live delegation schema migration', () => { expect( verification.prepare('SELECT MAX(version) AS version FROM schema_versions').get() ).toEqual({ - version: ORCHESTRATION_DATABASE_SCHEMA_VERSION + version: LATEST_DATABASE_SCHEMA_VERSION }) verification.close() }) @@ -99,7 +101,7 @@ describeIfSqlite('live delegation schema migration', () => { bootstrap.close() const migrated = new MainDatabaseCtor(databasePath) - expect(migrated.getLatestSchemaVersion()).toBe(ORCHESTRATION_DATABASE_SCHEMA_VERSION) + expect(migrated.getLatestSchemaVersion()).toBe(LATEST_DATABASE_SCHEMA_VERSION) migrated.close() const verification = new DatabaseCtor(databasePath) @@ -114,7 +116,7 @@ describeIfSqlite('live delegation schema migration', () => { ).toEqual({ effect_state: 'none', effect_evidence_json: null }) expect( verification.prepare('SELECT MAX(version) AS version FROM schema_versions').get() - ).toEqual({ version: ORCHESTRATION_DATABASE_SCHEMA_VERSION }) + ).toEqual({ version: LATEST_DATABASE_SCHEMA_VERSION }) verification.close() }) @@ -149,7 +151,7 @@ describeIfSqlite('live delegation schema migration', () => { bootstrap.close() const migrated = new MainDatabaseCtor(databasePath) - expect(migrated.getLatestSchemaVersion()).toBe(ORCHESTRATION_DATABASE_SCHEMA_VERSION) + expect(migrated.getLatestSchemaVersion()).toBe(LATEST_DATABASE_SCHEMA_VERSION) migrated.close() const verification = new DatabaseCtor(databasePath) @@ -164,7 +166,7 @@ describeIfSqlite('live delegation schema migration', () => { ).toEqual({ result_summary: 'Done.', result_ref_json: null }) expect( verification.prepare('SELECT MAX(version) AS version FROM schema_versions').get() - ).toEqual({ version: ORCHESTRATION_DATABASE_SCHEMA_VERSION }) + ).toEqual({ version: LATEST_DATABASE_SCHEMA_VERSION }) verification.close() }) @@ -201,7 +203,124 @@ describeIfSqlite('live delegation schema migration', () => { expect(columns.some((column) => column.name === 'result_ref_json')).toBe(true) expect( verification.prepare('SELECT MAX(version) AS version FROM schema_versions').get() - ).toEqual({ version: ORCHESTRATION_DATABASE_SCHEMA_VERSION }) + ).toEqual({ version: LATEST_DATABASE_SCHEMA_VERSION }) + verification.close() + }) + + it('repairs missing contract projections at the current schema version without losing rows', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'deepchat-live-contract-repair-')) + tempDirectories.push(directory) + const databasePath = path.join(directory, 'agent.db') + const current = new MainDatabaseCtor(databasePath) + current.close() + + const bootstrap = new DatabaseCtor(databasePath) + bootstrap.exec(` + INSERT INTO new_sessions (id, agent_id, title, created_at, updated_at) + VALUES ('parent', 'agent-1', 'Parent', 100, 100); + INSERT INTO live_delegations ( + delegation_id, parent_session_id, slot_id, target_agent_id, title, status, + last_turn_seq, created_at, updated_at + ) VALUES ( + 'delegation-1', 'parent', 'reviewer', 'agent-1', 'Review', 'running', 1, 100, 110 + ); + INSERT INTO live_delegation_turns ( + turn_id, delegation_id, seq, kind, prompt, status, effect_state, + created_at, started_at, updated_at + ) VALUES ( + 'turn-1', 'delegation-1', 1, 'initial', 'Review it.', 'running', 'none', + 100, 110, 110 + ); + INSERT INTO live_delegation_events ( + delegation_id, parent_session_id, direction, kind, content, created_at + ) VALUES ( + 'delegation-1', 'parent', 'parent_to_child', 'message', 'Continue.', 110 + ); + ALTER TABLE live_delegation_turns DROP COLUMN evaluation_ref_json; + ALTER TABLE live_delegation_turns DROP COLUMN evaluation_json; + ALTER TABLE live_delegation_turns DROP COLUMN inherited_task_contract_ref_json; + ALTER TABLE live_delegation_turns DROP COLUMN task_contract_ref_json; + ALTER TABLE live_delegation_turns DROP COLUMN task_contract_json; + ALTER TABLE live_delegation_events DROP COLUMN evaluation_ref_json; + ALTER TABLE live_delegation_events DROP COLUMN evaluation_json; + `) + bootstrap.close() + + const repaired = new MainDatabaseCtor(databasePath) + const missingColumns = [ + ['live_delegation_turns', 'task_contract_json'], + ['live_delegation_turns', 'task_contract_ref_json'], + ['live_delegation_turns', 'inherited_task_contract_ref_json'], + ['live_delegation_turns', 'evaluation_json'], + ['live_delegation_turns', 'evaluation_ref_json'], + ['live_delegation_events', 'evaluation_json'], + ['live_delegation_events', 'evaluation_ref_json'] + ] as const + const diagnosis = await repaired.diagnoseSchema() + + for (const [table, name] of missingColumns) { + expect(diagnosis.issues).toContainEqual( + expect.objectContaining({ kind: 'missing_column', table, name, repairable: true }) + ) + } + + const repairReport = await repaired.repairSchema() + expect(repairReport.status).toBe('repaired') + expect(repairReport.remainingIssues).toEqual([]) + + const repeatedRepair = await repaired.repairSchema() + expect(repeatedRepair.status).toBe('healthy') + expect(repeatedRepair.repairedIssues).toEqual([]) + repaired.close() + + const verification = new DatabaseCtor(databasePath) + const turnColumns = new Set( + ( + verification.prepare('PRAGMA table_info(live_delegation_turns)').all() as Array<{ + name: string + }> + ).map((column) => column.name) + ) + const eventColumns = new Set( + ( + verification.prepare('PRAGMA table_info(live_delegation_events)').all() as Array<{ + name: string + }> + ).map((column) => column.name) + ) + + for (const [table, name] of missingColumns) { + expect(table === 'live_delegation_turns' ? turnColumns : eventColumns).toContain(name) + } + expect( + verification + .prepare( + `SELECT prompt, task_contract_json, task_contract_ref_json, + inherited_task_contract_ref_json, evaluation_json, evaluation_ref_json + FROM live_delegation_turns + WHERE turn_id = 'turn-1'` + ) + .get() + ).toEqual({ + prompt: 'Review it.', + task_contract_json: null, + task_contract_ref_json: null, + inherited_task_contract_ref_json: null, + evaluation_json: null, + evaluation_ref_json: null + }) + expect( + verification + .prepare( + `SELECT content, evaluation_json, evaluation_ref_json + FROM live_delegation_events + WHERE delegation_id = 'delegation-1'` + ) + .get() + ).toEqual({ content: 'Continue.', evaluation_json: null, evaluation_ref_json: null }) + expect( + verification.prepare('SELECT MAX(version) AS version FROM schema_versions').get() + ).toEqual({ version: LATEST_DATABASE_SCHEMA_VERSION }) verification.close() }) @@ -228,7 +347,7 @@ describeIfSqlite('live delegation schema migration', () => { bootstrap.close() const migrated = new MainDatabaseCtor(databasePath) - expect(migrated.getLatestSchemaVersion()).toBe(ORCHESTRATION_DATABASE_SCHEMA_VERSION) + expect(migrated.getLatestSchemaVersion()).toBe(LATEST_DATABASE_SCHEMA_VERSION) migrated.close() const verification = new DatabaseCtor(databasePath) @@ -244,7 +363,64 @@ describeIfSqlite('live delegation schema migration', () => { ).toEqual([]) expect( verification.prepare('SELECT MAX(version) AS version FROM schema_versions').get() - ).toEqual({ version: ORCHESTRATION_DATABASE_SCHEMA_VERSION }) + ).toEqual({ version: LATEST_DATABASE_SCHEMA_VERSION }) + verification.close() + }) + + it('adds nullable mailbox evaluation projections to the v65 schema without losing events', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'deepchat-live-evaluation-migration-')) + tempDirectories.push(directory) + const databasePath = path.join(directory, 'agent.db') + const current = new MainDatabaseCtor(databasePath) + current.close() + + const bootstrap = new DatabaseCtor(databasePath) + bootstrap.exec(` + ALTER TABLE live_delegation_events DROP COLUMN evaluation_ref_json; + ALTER TABLE live_delegation_events DROP COLUMN evaluation_json; + INSERT INTO new_sessions (id, agent_id, title, created_at, updated_at) + VALUES ('parent', 'agent-1', 'Parent', 100, 100); + INSERT INTO live_delegations ( + delegation_id, parent_session_id, slot_id, target_agent_id, title, status, + last_turn_seq, created_at, updated_at + ) VALUES ( + 'delegation-1', 'parent', 'reviewer', 'agent-1', 'Review', 'idle', 1, 100, 120 + ); + INSERT INTO live_delegation_turns ( + turn_id, delegation_id, seq, kind, prompt, status, result_summary, + effect_state, created_at, started_at, updated_at, completed_at + ) VALUES ( + 'turn-1', 'delegation-1', 1, 'initial', 'Review it.', 'completed', 'Done.', + 'none', 100, 110, 120, 120 + ); + INSERT INTO live_delegation_events ( + delegation_id, parent_session_id, direction, kind, content, related_turn_id, created_at + ) VALUES ( + 'delegation-1', 'parent', 'child_to_parent', 'turn_completed', 'Done.', 'turn-1', 120 + ); + DELETE FROM schema_versions; + INSERT INTO schema_versions (version, applied_at) + VALUES (${CONTRACT_DATABASE_SCHEMA_VERSION}, 100); + `) + bootstrap.close() + + const migrated = new MainDatabaseCtor(databasePath) + expect(migrated.getLatestSchemaVersion()).toBe(LATEST_DATABASE_SCHEMA_VERSION) + migrated.close() + + const verification = new DatabaseCtor(databasePath) + expect( + verification + .prepare( + `SELECT content, evaluation_json, evaluation_ref_json + FROM live_delegation_events + WHERE related_turn_id = 'turn-1'` + ) + .get() + ).toEqual({ content: 'Done.', evaluation_json: null, evaluation_ref_json: null }) + expect( + verification.prepare('SELECT MAX(version) AS version FROM schema_versions').get() + ).toEqual({ version: LATEST_DATABASE_SCHEMA_VERSION }) verification.close() }) }) diff --git a/test/main/orchestration/liveDelegationRepository.test.ts b/test/main/orchestration/liveDelegationRepository.test.ts index d1002a0fd..fef255a7e 100644 --- a/test/main/orchestration/liveDelegationRepository.test.ts +++ b/test/main/orchestration/liveDelegationRepository.test.ts @@ -1,6 +1,12 @@ import { createHash } from 'node:crypto' import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { DeepChatLegacyTaskEvaluation } from '@shared/types/task-contract' import { Database, nativeSqliteDescribeIf } from '../nativeSqliteHarness' +import { + createLegacyLiveDelegationTaskContractInput, + createLiveDelegationTaskContractInput +} from '@/orchestration/liveDelegationTaskContract' +import { canonicalJsonStringifyData, hashJsonData } from '@/tape/domain/canonicalJson' const databaseModule = Database ? await import('@/orchestration/data/database').catch(() => null) @@ -17,6 +23,15 @@ const eventsModule = Database const repositoryModule = Database ? await import('@/orchestration/liveDelegationRepository').catch(() => null) : null +const tapeStoreModule = Database + ? await import('@/tape/infrastructure/sqlite/tapeEntryStore').catch(() => null) + : null +const taskContractServiceModule = Database + ? await import('@/tape/application/taskContractService').catch(() => null) + : null +const taskEvaluationServiceModule = Database + ? await import('@/tape/application/taskEvaluationService').catch(() => null) + : null const DatabaseCtor = Database! const LiveDelegationDatabaseCtor = databaseModule?.LiveDelegationDatabase! @@ -24,13 +39,22 @@ const LiveDelegationsTableCtor = delegationsModule?.LiveDelegationsTable! const LiveDelegationTurnsTableCtor = turnsModule?.LiveDelegationTurnsTable! const LiveDelegationEventsTableCtor = eventsModule?.LiveDelegationEventsTable! const LiveDelegationRepositoryCtor = repositoryModule?.LiveDelegationRepository! +const LiveDelegationTaskContractErrorCtor = repositoryModule?.LiveDelegationTaskContractError! +const DeepChatContractStoreCtor = tapeStoreModule?.DeepChatContractStore! +const TaskContractServiceCtor = taskContractServiceModule?.TaskContractService! +const TaskEvaluationServiceCtor = taskEvaluationServiceModule?.TaskEvaluationService! +const CONTRACT_SCHEMA_VERSION = delegationsModule?.LIVE_DELEGATION_CONTRACT_DATABASE_SCHEMA_VERSION! const describeIfSqlite = nativeSqliteDescribeIf( Boolean( LiveDelegationDatabaseCtor && LiveDelegationsTableCtor && LiveDelegationTurnsTableCtor && LiveDelegationEventsTableCtor && - LiveDelegationRepositoryCtor + LiveDelegationRepositoryCtor && + LiveDelegationTaskContractErrorCtor && + DeepChatContractStoreCtor && + TaskContractServiceCtor && + TaskEvaluationServiceCtor ), 'Live delegation persistence modules are unavailable' ) @@ -38,6 +62,7 @@ const describeIfSqlite = nativeSqliteDescribeIf( describeIfSqlite('LiveDelegationRepository', () => { let db: InstanceType | null let repository: InstanceType + let contractStore: InstanceType beforeEach(() => { db = new DatabaseCtor(':memory:') @@ -52,8 +77,12 @@ describeIfSqlite('LiveDelegationRepository', () => { new LiveDelegationsTableCtor(db).createTable() new LiveDelegationTurnsTableCtor(db).createTable() new LiveDelegationEventsTableCtor(db).createTable() + contractStore = new DeepChatContractStoreCtor(db) + contractStore.createTable() repository = new LiveDelegationRepositoryCtor( - new LiveDelegationDatabaseCtor({ getDatabase: () => db! }) + new LiveDelegationDatabaseCtor({ getDatabase: () => db! }), + new TaskContractServiceCtor(() => contractStore), + new TaskEvaluationServiceCtor(() => contractStore) ) addSession('parent') }) @@ -81,10 +110,65 @@ describeIfSqlite('LiveDelegationRepository', () => { targetAgentId: 'agent-1', title: 'Review architecture', prompt: 'Review module boundaries.', + taskContract: createLiveDelegationTaskContractInput(null), now: 100 }) } + function completeFormattedAnswer(): string { + return [ + '## Handoff', + 'Use the reviewed conclusion.', + '## Result', + 'The boundary is sound.', + '## Evidence', + 'Repository and Tape facts agree.', + '## Changed Files', + 'None.', + '## Validation', + 'Focused tests passed.', + '## Unresolved', + 'None.' + ].join('\n') + } + + function buildPreviousHeadEvaluation( + taskContractHash: string, + candidateResult: string, + executionStatus: DeepChatLegacyTaskEvaluation['executionStatus'] = 'completed' + ): DeepChatLegacyTaskEvaluation { + const draft = { + schemaVersion: 1 as const, + hashVersion: 1 as const, + evaluatorVersion: 'task-contract-v1' as const, + turnId: 'turn-1', + taskContractHash, + candidate: { + kind: 'answer' as const, + sha256: createHash('sha256').update(candidateResult, 'utf8').digest('hex'), + utf8Bytes: Buffer.byteLength(candidateResult, 'utf8') + }, + executionStatus, + verdict: 'passed' as const, + disposition: 'accepted' as const, + reasonCodes: [], + records: [ + { + requirementId: 'live-delegation-required-sections', + requirementKind: 'required_sections' as const, + outcome: 'passed' as const, + code: 'required_sections_present' as const, + section: null, + instancePath: null, + keyword: null, + additionalEvidenceCount: 0 + } + ], + omittedRecordCount: 0 + } + return { ...draft, evaluationHash: hashJsonData(draft) } + } + it('persists the thread and initial turn before child binding', () => { const created = createDelegation() @@ -105,6 +189,30 @@ describeIfSqlite('LiveDelegationRepository', () => { } }) expect(repository.listActiveTurns()).toHaveLength(1) + expect(created.turn).toMatchObject({ + taskContract: { + taskDescription: { + delegationId: 'delegation-1', + turnId: 'turn-1', + prompt: 'Review module boundaries.' + } + }, + taskContractRef: { + sessionId: 'parent', + contractHash: created.turn.taskContract?.contractHash + }, + inheritedTaskContractRef: null + }) + const frozenFacts = contractStore + .getBySession('parent') + .filter((row) => row.name === 'contract/task_frozen') + expect(frozenFacts).toHaveLength(1) + expect(JSON.parse(frozenFacts[0]!.payload_json).data.contract).toEqual( + created.turn.taskContract + ) + expect(created.turn.taskContractRef?.entryId).toBe(frozenFacts[0]!.entry_id) + expect(Object.isFrozen(created.turn.taskContract)).toBe(true) + expect(Object.isFrozen(created.turn.taskContract?.taskHarness.acceptance)).toBe(true) expect(() => repository.create({ id: 'orphan', @@ -113,7 +221,8 @@ describeIfSqlite('LiveDelegationRepository', () => { slotId: 'reviewer', targetAgentId: 'agent-1', title: 'Orphan', - prompt: 'Do work.' + prompt: 'Do work.', + taskContract: createLiveDelegationTaskContractInput(null) }) ).toThrow('parent session does not exist') }) @@ -135,6 +244,7 @@ describeIfSqlite('LiveDelegationRepository', () => { targetAgentId: 'agent-1', title: 'Commit before mutation', prompt: 'Verify transaction ownership.', + taskContract: createLiveDelegationTaskContractInput(null), now: 101 }, commitReceipt('receipt-success') @@ -150,6 +260,7 @@ describeIfSqlite('LiveDelegationRepository', () => { targetAgentId: 'agent-1', title: 'Duplicate mutation', prompt: 'Fail after the receipt commits.', + taskContract: createLiveDelegationTaskContractInput(null), now: 102 }, commitReceipt('receipt-before-failure') @@ -174,7 +285,8 @@ describeIfSqlite('LiveDelegationRepository', () => { slotId: 'reviewer', targetAgentId: 'agent-1', title: 'Orphan', - prompt: 'Do work.' + prompt: 'Do work.', + taskContract: createLiveDelegationTaskContractInput(null) }, beforeMutation ) @@ -182,6 +294,155 @@ describeIfSqlite('LiveDelegationRepository', () => { expect(beforeMutation).not.toHaveBeenCalled() }) + it('rolls back the parent fact and runtime rows when contract freeze cannot complete', () => { + const strictWriter = new TaskContractServiceCtor(() => contractStore) + const failingRepository = new LiveDelegationRepositoryCtor( + new LiveDelegationDatabaseCtor({ getDatabase: () => db! }), + { + freezeParentTaskContract: ( + input: Parameters[0] + ) => { + strictWriter.freezeParentTaskContract(input) + throw new Error('projection write failed') + }, + ensureParentTaskContract: (input) => strictWriter.ensureParentTaskContract(input), + ensureChildTaskContract: (input) => strictWriter.ensureChildTaskContract(input) + }, + new TaskEvaluationServiceCtor(() => contractStore) + ) + + expect(() => + failingRepository.create({ + id: 'delegation-rollback', + initialTurnId: 'turn-rollback', + parentSessionId: 'parent', + slotId: 'reviewer', + targetAgentId: 'agent-1', + title: 'Rollback contract', + prompt: 'Do not leave a partial fact.', + taskContract: createLiveDelegationTaskContractInput(null), + now: 100 + }) + ).toThrow('projection write failed') + expect(failingRepository.get('delegation-rollback')).toBeNull() + expect(contractStore.getBySession('parent')).toEqual([]) + }) + + it('rejects a canonical TaskContract projection bound to different turn content', () => { + createDelegation() + db! + .prepare( + "UPDATE live_delegation_turns SET prompt = 'Corrupted prompt' WHERE turn_id = 'turn-1'" + ) + .run() + + expect(() => repository.requireTurn('turn-1')).toThrow(LiveDelegationTaskContractErrorCtor) + expect(() => repository.requireTurn('turn-1')).toThrow(/misbound TaskContract projection/u) + }) + + it('classifies malformed stored TaskContract JSON as a recoverable contract error', () => { + createDelegation() + db!.pragma('ignore_check_constraints = ON') + try { + db! + .prepare( + "UPDATE live_delegation_turns SET task_contract_json = '{' WHERE turn_id = 'turn-1'" + ) + .run() + } finally { + db!.pragma('ignore_check_constraints = OFF') + } + + expect(() => repository.requireTurn('turn-1')).toThrow(LiveDelegationTaskContractErrorCtor) + expect(() => repository.requireTurn('turn-1')).toThrow( + /Stored live delegation TaskContract is malformed/u + ) + }) + + it('migrates nullable contract projections from the orchestration v64 schema', () => { + const legacyDb = new DatabaseCtor(':memory:') + try { + legacyDb.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE schema_versions (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL); + INSERT INTO schema_versions (version, applied_at) VALUES (64, 1); + CREATE TABLE new_sessions ( + id TEXT PRIMARY KEY, + session_kind TEXT NOT NULL DEFAULT 'regular', + parent_session_id TEXT + ); + `) + const delegations = new LiveDelegationsTableCtor(legacyDb) + const turns = new LiveDelegationTurnsTableCtor(legacyDb) + delegations.createTable() + turns.createTable() + legacyDb.prepare("INSERT INTO new_sessions (id) VALUES ('parent')").run() + legacyDb + .prepare( + `INSERT INTO live_delegations ( + delegation_id, parent_session_id, child_session_id, slot_id, target_agent_id, title, + status, last_turn_seq, last_summary, last_error, created_at, updated_at, revision + ) VALUES ('legacy', 'parent', NULL, 'reviewer', 'agent-1', 'Legacy', + 'queued', 1, NULL, NULL, 1, 1, 0)` + ) + .run() + legacyDb + .prepare( + `INSERT INTO live_delegation_turns ( + turn_id, delegation_id, seq, kind, prompt, status, created_at, updated_at + ) VALUES ('legacy-turn', 'legacy', 1, 'initial', 'Legacy task', 'queued', 1, 1)` + ) + .run() + + const beforeColumns = new Set( + ( + legacyDb.prepare('PRAGMA table_info(live_delegation_turns)').all() as Array<{ + name: string + }> + ).map((column) => column.name) + ) + expect(beforeColumns.has('task_contract_json')).toBe(false) + const migration = turns.getMigrationSQL(CONTRACT_SCHEMA_VERSION) + expect(migration).toBeTruthy() + legacyDb.exec(migration!) + + const afterColumns = new Set( + ( + legacyDb.prepare('PRAGMA table_info(live_delegation_turns)').all() as Array<{ + name: string + }> + ).map((column) => column.name) + ) + expect( + [ + 'task_contract_json', + 'task_contract_ref_json', + 'inherited_task_contract_ref_json', + 'evaluation_json', + 'evaluation_ref_json' + ].every((column) => afterColumns.has(column)) + ).toBe(true) + expect( + legacyDb + .prepare( + `SELECT task_contract_json, task_contract_ref_json, + inherited_task_contract_ref_json, evaluation_json, evaluation_ref_json + FROM live_delegation_turns WHERE turn_id = 'legacy-turn'` + ) + .get() + ).toEqual({ + task_contract_json: null, + task_contract_ref_json: null, + inherited_task_contract_ref_json: null, + evaluation_json: null, + evaluation_ref_json: null + }) + expect(turns.getMigrationSQL(CONTRACT_SCHEMA_VERSION)).toContain('already present') + } finally { + legacyDb.close() + } + }) + it('enforces parent active capacity atomically for initial and follow-up turns', () => { const createActive = (index: number) => repository.create({ @@ -192,6 +453,7 @@ describeIfSqlite('LiveDelegationRepository', () => { targetAgentId: 'agent-1', title: `Review ${index}`, prompt: `Inspect boundary ${index}.`, + taskContract: createLiveDelegationTaskContractInput(null), now: 100 + index }) @@ -214,6 +476,7 @@ describeIfSqlite('LiveDelegationRepository', () => { replacement.delegation.id, 'turn-follow-up', 'Continue the review.', + createLiveDelegationTaskContractInput(null), 140 ) ).toThrow('at most 5 active live delegations') @@ -232,6 +495,174 @@ describeIfSqlite('LiveDelegationRepository', () => { ) }) + it('inherits one canonical TaskContract into the bound child idempotently', () => { + const created = createDelegation() + addSession('child-1', 'parent') + repository.bindChild(created.delegation.id, 'child-1', 110) + + const first = repository.ensureInheritedTaskContract(created.turn.id, 'child-1', 120) + const second = repository.ensureInheritedTaskContract(created.turn.id, 'child-1', 130) + const projected = repository.requireTurn(created.turn.id) + const childFacts = contractStore + .getBySession('child-1') + .filter((row) => row.name === 'contract/task_frozen') + + expect(second).toEqual(first) + expect(first.contract).toEqual(created.turn.taskContract) + expect(first.localRef.sessionId).toBe('child-1') + expect(projected.inheritedTaskContractRef).toEqual(first.localRef) + expect(childFacts).toHaveLength(1) + expect(JSON.parse(childFacts[0]!.payload_json).data).toMatchObject({ + delivery: 'child_inherited', + contract: first.contract, + originRef: projected.taskContractRef, + supersedesRef: null + }) + expect(repository.prepareActiveTaskContractContext('child-1', 140)).toEqual(first) + expect(repository.prepareActiveTaskContractContext('unbound-child', 140)).toBeNull() + }) + + it('re-anchors parent and child references independently after Tape reset', () => { + const created = createDelegation() + addSession('child-1', 'parent') + repository.bindChild(created.delegation.id, 'child-1', 110) + repository.ensureInheritedTaskContract(created.turn.id, 'child-1', 120) + const original = repository.requireTurn(created.turn.id) + + contractStore.runInTransaction(() => { + db!.prepare('DELETE FROM deepchat_tape_entries WHERE session_id = ?').run('parent') + contractStore.ensureBootstrapAnchor('parent') + }) + repository.ensureInheritedTaskContract(created.turn.id, 'child-1', 130) + const parentRecovered = repository.requireTurn(created.turn.id) + + expect(parentRecovered.taskContractRef?.tapeIdentity).not.toBe( + original.taskContractRef?.tapeIdentity + ) + expect(parentRecovered.inheritedTaskContractRef).toEqual(original.inheritedTaskContractRef) + expect( + JSON.parse( + contractStore.getBySession('parent').find((row) => row.name === 'contract/task_frozen')! + .payload_json + ).data + ).toMatchObject({ + delivery: 'projection_recovery', + supersedesRef: original.taskContractRef + }) + + contractStore.runInTransaction(() => { + db!.prepare('DELETE FROM deepchat_tape_entries WHERE session_id = ?').run('child-1') + contractStore.ensureBootstrapAnchor('child-1') + }) + repository.ensureInheritedTaskContract(created.turn.id, 'child-1', 140) + const childRecovered = repository.requireTurn(created.turn.id) + const recoveredChildFact = contractStore + .getBySession('child-1') + .find((row) => row.name === 'contract/task_frozen')! + + expect(childRecovered.taskContractRef).toEqual(parentRecovered.taskContractRef) + expect(childRecovered.inheritedTaskContractRef?.tapeIdentity).not.toBe( + original.inheritedTaskContractRef?.tapeIdentity + ) + expect(JSON.parse(recoveredChildFact.payload_json).data).toMatchObject({ + delivery: 'projection_recovery', + originRef: parentRecovered.taskContractRef, + supersedesRef: original.inheritedTaskContractRef + }) + }) + + it('rolls back a child fact when its runtime reference cannot be projected', () => { + const created = createDelegation() + addSession('child-1', 'parent') + repository.bindChild(created.delegation.id, 'child-1', 110) + const strictWriter = new TaskContractServiceCtor(() => contractStore) + const failingRepository = new LiveDelegationRepositoryCtor( + new LiveDelegationDatabaseCtor({ getDatabase: () => db! }), + { + freezeParentTaskContract: (input) => strictWriter.freezeParentTaskContract(input), + ensureParentTaskContract: (input) => strictWriter.ensureParentTaskContract(input), + ensureChildTaskContract: (input) => { + strictWriter.ensureChildTaskContract(input) + throw new Error('projection write failed') + } + }, + new TaskEvaluationServiceCtor(() => contractStore) + ) + + expect(() => + failingRepository.ensureInheritedTaskContract(created.turn.id, 'child-1', 120) + ).toThrow(/Failed to inherit/u) + expect(contractStore.getBySession('child-1')).toEqual([]) + expect(repository.requireTurn(created.turn.id).inheritedTaskContractRef).toBeNull() + }) + + it('fails closed when the child-local origin fact is corrupted', () => { + const created = createDelegation() + addSession('child-1', 'parent') + repository.bindChild(created.delegation.id, 'child-1', 110) + repository.ensureInheritedTaskContract(created.turn.id, 'child-1', 120) + const fact = contractStore + .getBySession('child-1') + .find((row) => row.name === 'contract/task_frozen')! + const payload = JSON.parse(fact.payload_json) + payload.data.originRef.contractHash = 'f'.repeat(64) + db! + .prepare( + `UPDATE deepchat_tape_entries SET payload_json = ? + WHERE session_id = ? AND entry_id = ?` + ) + .run(JSON.stringify(payload), fact.session_id, fact.entry_id) + + expect(() => repository.ensureInheritedTaskContract(created.turn.id, 'child-1', 130)).toThrow( + /Failed to inherit/u + ) + }) + + it('fails closed when the runtime projection names another child-local entry', () => { + const created = createDelegation() + addSession('child-1', 'parent') + repository.bindChild(created.delegation.id, 'child-1', 110) + repository.ensureInheritedTaskContract(created.turn.id, 'child-1', 120) + const currentRef = repository.requireTurn(created.turn.id).inheritedTaskContractRef! + db! + .prepare( + `UPDATE live_delegation_turns SET inherited_task_contract_ref_json = ? + WHERE turn_id = ?` + ) + .run(JSON.stringify({ ...currentRef, entryId: currentRef.entryId + 1 }), created.turn.id) + + expect(() => repository.ensureInheritedTaskContract(created.turn.id, 'child-1', 130)).toThrow( + /Failed to inherit/u + ) + }) + + it('freezes an explicit compatibility contract for a legacy active turn', () => { + const created = createDelegation() + contractStore.runInTransaction(() => { + db!.prepare('DELETE FROM deepchat_tape_entries WHERE session_id = ?').run('parent') + db! + .prepare( + `UPDATE live_delegation_turns + SET task_contract_json = NULL, task_contract_ref_json = NULL + WHERE turn_id = ?` + ) + .run(created.turn.id) + }) + + const recovered = repository.freezeLegacyTaskContract( + created.turn.id, + createLegacyLiveDelegationTaskContractInput('/repo'), + 120 + ) + + expect(recovered.turn.taskContract).toMatchObject({ + taskConfig: { creationReason: 'legacy_recovery' }, + taskHarness: { acceptance: [] } + }) + expect(recovered.turn.taskContractRef?.sessionId).toBe('parent') + expect(recovered.turn.inheritedTaskContractRef).toBeNull() + }) + it('rejects unrelated children and removes owned history with the parent session', () => { createDelegation() addSession('other-parent') @@ -267,13 +698,21 @@ describeIfSqlite('LiveDelegationRepository', () => { 'delegation-1', 'turn-2', 'Re-evaluate the conclusion.', + createLiveDelegationTaskContractInput(null), 130 ) expect(followUp.turn).toMatchObject({ seq: 2, kind: 'follow_up', status: 'queued' }) expect(followUp.turn.prompt).toContain('Check the cache boundary.') expect(followUp.turn.prompt).toContain('Re-evaluate the conclusion.') expect(() => - repository.createFollowUp('parent', 'delegation-1', 'turn-3', 'Overlap', 140) + repository.createFollowUp( + 'parent', + 'delegation-1', + 'turn-3', + 'Overlap', + createLiveDelegationTaskContractInput(null), + 140 + ) ).toThrow('already has an active turn') }) @@ -351,6 +790,7 @@ describeIfSqlite('LiveDelegationRepository', () => { 'delegation-1', 'turn-2', 'Continue with the bounded evidence.', + createLiveDelegationTaskContractInput(null), 130 ) @@ -372,7 +812,14 @@ describeIfSqlite('LiveDelegationRepository', () => { repository.createMessage('parent', 'delegation-1', 'Keep this evidence.') expect(() => - repository.createFollowUp('parent', 'delegation-1', 'turn-2', 'x'.repeat(64 * 1024), 120) + repository.createFollowUp( + 'parent', + 'delegation-1', + 'turn-2', + 'x'.repeat(64 * 1024), + createLiveDelegationTaskContractInput(null), + 120 + ) ).toThrow('leaves no room for queued messages or their recovery notice') expect(repository.listTurns('delegation-1')).toHaveLength(1) expect( @@ -496,15 +943,25 @@ describeIfSqlite('LiveDelegationRepository', () => { handoffTruncated: false }, tapeReceipt: receipt, + candidateResult: 'Architecture is sound.', now: 120 }) const retry = repository.finishTurn({ turnId: 'turn-1', - status: 'failed', - error: 'late error', + status: 'completed', + candidateResult: 'Architecture is sound.', now: 130 }) + expect(() => + repository.finishTurn({ + turnId: 'turn-1', + status: 'failed', + error: 'late error', + now: 140 + }) + ).toThrow('Terminal evaluation retry conflicts') + expect(first.delegation.status).toBe('idle') expect(first.turn.resultRef).toMatchObject({ childSessionId: 'child-1', @@ -521,4 +978,299 @@ describeIfSqlite('LiveDelegationRepository', () => { ]) expect(repository.listEvents('parent', { after: 1 })).toEqual([]) }) + + it('atomically projects one canonical evaluation after re-anchoring a reset parent Tape', () => { + const created = createDelegation() + repository.markTurnStarted(created.turn.id, 110) + const originalContractRef = created.turn.taskContractRef! + contractStore.runInTransaction(() => { + db!.prepare('DELETE FROM deepchat_tape_entries WHERE session_id = ?').run('parent') + contractStore.ensureBootstrapAnchor('parent') + }) + + const settled = repository.finishTurn({ + turnId: created.turn.id, + status: 'completed', + summary: 'Use the reviewed conclusion.', + candidateResult: completeFormattedAnswer(), + now: 120 + }) + const event = repository.listEvents('parent')[0]! + const facts = contractStore.getBySession('parent') + const frozenFact = facts.find((entry) => entry.name === 'contract/task_frozen')! + const evaluatedFact = facts.find((entry) => entry.name === 'contract/evaluated')! + const evaluatedPayload = JSON.parse(evaluatedFact.payload_json).data + + expect(settled.delegation.status).toBe('idle') + expect(settled.turn).toMatchObject({ + status: 'completed', + evaluation: { + evaluationKind: 'handoff_format', + formatStatus: 'valid', + executionStatus: 'completed' + } + }) + expect(settled.turn.taskContractRef?.tapeIdentity).not.toBe(originalContractRef.tapeIdentity) + expect(settled.turn.taskContractRef?.entryId).toBe(frozenFact.entry_id) + expect(settled.turn.evaluationRef).toMatchObject({ + sessionId: 'parent', + tapeIdentity: settled.turn.taskContractRef?.tapeIdentity, + entryId: evaluatedFact.entry_id, + evaluationHash: settled.turn.evaluation?.evaluationHash + }) + expect(evaluatedPayload).toEqual({ + schemaVersion: 1, + evaluation: settled.turn.evaluation, + taskContractRef: settled.turn.taskContractRef + }) + expect(event.evaluation).toEqual(settled.turn.evaluation) + expect(event.evaluationRef).toEqual(settled.turn.evaluationRef) + }) + + it('reads and retries previous-head evaluations without rewriting their Tape binding', () => { + const created = createDelegation() + const answer = completeFormattedAnswer() + const taskContract = created.turn.taskContract! + const taskContractRef = created.turn.taskContractRef! + const evaluation = buildPreviousHeadEvaluation(taskContract.contractHash, answer) + const evaluationRow = contractStore.runInTransaction(() => + contractStore.appendContractEvent({ + sessionId: 'parent', + name: 'contract/evaluated', + source: { type: 'subagent', id: created.turn.id, seq: created.turn.seq }, + provenanceKey: `contract:evaluated:v1:${created.turn.id}`, + data: { schemaVersion: 1, evaluation, taskContractRef }, + meta: { protocolVersion: 1 }, + createdAt: 120, + idempotent: false + }) + ) + const evaluationRef = { + schemaVersion: 1 as const, + sessionId: 'parent', + tapeIdentity: taskContractRef.tapeIdentity, + entryId: evaluationRow.entry_id, + evaluationHash: evaluation.evaluationHash + } + const evaluationJson = canonicalJsonStringifyData(evaluation) + const evaluationRefJson = canonicalJsonStringifyData(evaluationRef) + db!.transaction(() => { + db! + .prepare( + `UPDATE live_delegation_turns + SET status = 'completed', result_summary = ?, evaluation_json = ?, + evaluation_ref_json = ?, updated_at = 120, completed_at = 120 + WHERE turn_id = ?` + ) + .run('Use the reviewed conclusion.', evaluationJson, evaluationRefJson, created.turn.id) + db! + .prepare( + `UPDATE live_delegations + SET status = 'idle', last_summary = ?, updated_at = 120, revision = revision + 1 + WHERE delegation_id = ?` + ) + .run('Use the reviewed conclusion.', created.delegation.id) + db! + .prepare( + `INSERT INTO live_delegation_events ( + delegation_id, parent_session_id, direction, kind, content, related_turn_id, + evaluation_json, evaluation_ref_json, created_at + ) VALUES (?, 'parent', 'child_to_parent', 'turn_completed', ?, ?, ?, ?, 120)` + ) + .run( + created.delegation.id, + 'Use the reviewed conclusion.', + created.turn.id, + evaluationJson, + evaluationRefJson + ) + })() + + const before = { + turn: db! + .prepare( + `SELECT evaluation_json, evaluation_ref_json + FROM live_delegation_turns WHERE turn_id = ?` + ) + .get(created.turn.id), + event: db! + .prepare( + `SELECT evaluation_json, evaluation_ref_json + FROM live_delegation_events WHERE related_turn_id = ?` + ) + .get(created.turn.id), + fact: contractStore.getByProvenanceKey('parent', `contract:evaluated:v1:${created.turn.id}`) + } + + expect(repository.requireTurn(created.turn.id)).toMatchObject({ + status: 'completed', + evaluation, + evaluationRef + }) + expect(repository.listEvents('parent')).toEqual([ + expect.objectContaining({ evaluation, evaluationRef }) + ]) + expect( + repository.finishTurn({ + turnId: created.turn.id, + status: 'completed', + candidateResult: answer, + now: 130 + }).turn + ).toMatchObject({ evaluation, evaluationRef }) + expect(() => + repository.finishTurn({ + turnId: created.turn.id, + status: 'failed', + candidateResult: answer, + now: 140 + }) + ).toThrow('Terminal evaluation retry conflicts') + expect(() => + repository.finishTurn({ + turnId: created.turn.id, + status: 'completed', + candidateResult: `${answer}\nchanged`, + now: 150 + }) + ).toThrow('Terminal evaluation retry conflicts') + + const after = { + turn: db! + .prepare( + `SELECT evaluation_json, evaluation_ref_json + FROM live_delegation_turns WHERE turn_id = ?` + ) + .get(created.turn.id), + event: db! + .prepare( + `SELECT evaluation_json, evaluation_ref_json + FROM live_delegation_events WHERE related_turn_id = ?` + ) + .get(created.turn.id), + fact: contractStore.getByProvenanceKey('parent', `contract:evaluated:v1:${created.turn.id}`) + } + expect(after).toEqual(before) + expect( + contractStore.getBySession('parent').filter((row) => row.name === 'contract/evaluated') + ).toHaveLength(1) + expect(repository.listEvents('parent')).toHaveLength(1) + + const followUp = repository.createFollowUp( + 'parent', + created.delegation.id, + 'turn-2', + 'Check the remaining edge case.', + createLiveDelegationTaskContractInput(null), + 160 + ) + expect(followUp.turn.taskContract?.taskConfig.predecessorEvaluationRef).toEqual(evaluationRef) + }) + + it('rolls back evaluation fact, terminal projection, and mailbox event together', () => { + const created = createDelegation() + repository.markTurnStarted(created.turn.id, 110) + const strictEvaluationWriter = new TaskEvaluationServiceCtor(() => contractStore) + const failingRepository = new LiveDelegationRepositoryCtor( + new LiveDelegationDatabaseCtor({ getDatabase: () => db! }), + new TaskContractServiceCtor(() => contractStore), + { + commitTaskEvaluation: (input) => { + strictEvaluationWriter.commitTaskEvaluation(input) + throw new Error('terminal projection failed') + } + } + ) + + expect(() => + failingRepository.finishTurn({ + turnId: created.turn.id, + status: 'completed', + candidateResult: completeFormattedAnswer(), + now: 120 + }) + ).toThrow('terminal projection failed') + + expect(repository.require(created.delegation.id).status).toBe('running') + expect(repository.requireTurn(created.turn.id)).toMatchObject({ + status: 'running', + evaluation: null, + evaluationRef: null + }) + expect(repository.listEvents('parent')).toEqual([]) + expect( + contractStore.getBySession('parent').filter((entry) => entry.name === 'contract/evaluated') + ).toEqual([]) + }) + + it('rejects an evaluation reference from another parent Tape incarnation', () => { + const created = createDelegation() + repository.markTurnStarted(created.turn.id, 110) + const settled = repository.finishTurn({ + turnId: created.turn.id, + status: 'completed', + candidateResult: completeFormattedAnswer(), + now: 120 + }) + const evaluationRef = settled.turn.evaluationRef! + const conflictingTapeIdentity = `${evaluationRef.tapeIdentity === '0'.repeat(64) ? '1' : '0'}${evaluationRef.tapeIdentity.slice(1)}` + db! + .prepare( + `UPDATE live_delegation_turns + SET evaluation_ref_json = ? + WHERE turn_id = ?` + ) + .run( + JSON.stringify({ ...evaluationRef, tapeIdentity: conflictingTapeIdentity }), + created.turn.id + ) + + expect(() => repository.requireTurn(created.turn.id)).toThrow( + 'has a misbound evaluation projection' + ) + }) + + it('rejects a terminal contract projection that has no evaluation', () => { + const created = createDelegation() + db! + .prepare( + `UPDATE live_delegation_turns + SET status = 'completed', completed_at = 120, updated_at = 120 + WHERE turn_id = ?` + ) + .run(created.turn.id) + + expect(() => + repository.finishTurn({ + turnId: created.turn.id, + status: 'completed', + candidateResult: completeFormattedAnswer(), + now: 130 + }) + ).toThrow('has no Task evaluation') + }) + + it('binds a follow-up contract to the immediately preceding evaluation', () => { + const created = createDelegation() + repository.markTurnStarted(created.turn.id, 110) + const settled = repository.finishTurn({ + turnId: created.turn.id, + status: 'completed', + candidateResult: completeFormattedAnswer(), + now: 120 + }) + + const followUp = repository.createFollowUp( + 'parent', + created.delegation.id, + 'turn-2', + 'Check the remaining edge case.', + createLiveDelegationTaskContractInput(null), + 130 + ) + + expect(followUp.turn.taskContract?.taskConfig.predecessorEvaluationRef).toEqual( + settled.turn.evaluationRef + ) + }) }) diff --git a/test/main/orchestration/liveDelegationService.test.ts b/test/main/orchestration/liveDelegationService.test.ts index 5541614af..a4321dab3 100644 --- a/test/main/orchestration/liveDelegationService.test.ts +++ b/test/main/orchestration/liveDelegationService.test.ts @@ -3,13 +3,15 @@ import { AgentInvocationAdmission } from '@/agent/invocationAdmission' import { TOOL_EXECUTION } from '@shared/types/mcp' import { LIVE_DELEGATION_MAX_ACTIVE_PER_PARENT, - LIVE_DELEGATION_MAX_MESSAGE_BYTES + LIVE_DELEGATION_MAX_MESSAGE_BYTES, + LiveDelegationDetailSchema } from '@shared/orchestration/liveDelegation' import type { ConversationSessionInfo } from '@/tool/runtimePorts' import type { SessionRuntimeUpdate } from '@/session/runtimeEvents' import { SessionDeletionGate } from '@/session/deletionGate' import { LiveDelegationConsentAuthority } from '@/orchestration/liveDelegationConsent' import { Database, nativeSqliteDescribeIf } from '../nativeSqliteHarness' +import { createLiveDelegationTaskContractInput } from '@/orchestration/liveDelegationTaskContract' const databaseModule = Database ? await import('@/orchestration/data/database').catch(() => null) @@ -29,6 +31,15 @@ const repositoryModule = Database const serviceModule = Database ? await import('@/orchestration/liveDelegationService').catch(() => null) : null +const tapeStoreModule = Database + ? await import('@/tape/infrastructure/sqlite/tapeEntryStore').catch(() => null) + : null +const taskContractServiceModule = Database + ? await import('@/tape/application/taskContractService').catch(() => null) + : null +const taskEvaluationServiceModule = Database + ? await import('@/tape/application/taskEvaluationService').catch(() => null) + : null const DatabaseCtor = Database! const LiveDelegationDatabaseCtor = databaseModule?.LiveDelegationDatabase! @@ -36,7 +47,11 @@ const LiveDelegationsTableCtor = delegationsModule?.LiveDelegationsTable! const LiveDelegationTurnsTableCtor = turnsModule?.LiveDelegationTurnsTable! const LiveDelegationEventsTableCtor = eventsModule?.LiveDelegationEventsTable! const LiveDelegationRepositoryCtor = repositoryModule?.LiveDelegationRepository! +const LiveDelegationTaskContractErrorCtor = repositoryModule?.LiveDelegationTaskContractError! const LiveDelegationServiceCtor = serviceModule?.LiveDelegationService! +const DeepChatContractStoreCtor = tapeStoreModule?.DeepChatContractStore! +const TaskContractServiceCtor = taskContractServiceModule?.TaskContractService! +const TaskEvaluationServiceCtor = taskEvaluationServiceModule?.TaskEvaluationService! const describeIfSqlite = nativeSqliteDescribeIf( Boolean( LiveDelegationDatabaseCtor && @@ -44,7 +59,11 @@ const describeIfSqlite = nativeSqliteDescribeIf( LiveDelegationTurnsTableCtor && LiveDelegationEventsTableCtor && LiveDelegationRepositoryCtor && - LiveDelegationServiceCtor + LiveDelegationTaskContractErrorCtor && + LiveDelegationServiceCtor && + DeepChatContractStoreCtor && + TaskContractServiceCtor && + TaskEvaluationServiceCtor ), 'Live delegation lifecycle modules are unavailable' ) @@ -52,6 +71,7 @@ const describeIfSqlite = nativeSqliteDescribeIf( describeIfSqlite('LiveDelegationService', () => { let db: InstanceType | null let repository: InstanceType + let contractStore: InstanceType let service: InstanceType let harness: ReturnType let deletionGate: SessionDeletionGate @@ -72,8 +92,12 @@ describeIfSqlite('LiveDelegationService', () => { new LiveDelegationsTableCtor(db).createTable() new LiveDelegationTurnsTableCtor(db).createTable() new LiveDelegationEventsTableCtor(db).createTable() + contractStore = new DeepChatContractStoreCtor(db) + contractStore.createTable() repository = new LiveDelegationRepositoryCtor( - new LiveDelegationDatabaseCtor({ getDatabase: () => db! }) + new LiveDelegationDatabaseCtor({ getDatabase: () => db! }), + new TaskContractServiceCtor(() => contractStore), + new TaskEvaluationServiceCtor(() => contractStore) ) harness = createSessionHarness(db) deletionGate = new SessionDeletionGate() @@ -112,18 +136,28 @@ describeIfSqlite('LiveDelegationService', () => { harness.publishAnswer(childId, '## Handoff\nThe boundary is sound.\0', 200) harness.publish({ sessionId: childId, kind: 'status', updatedAt: 201, status: 'idle' }) - await expect(waiting).resolves.toMatchObject({ + const waitResult = await waiting + expect(waitResult).toMatchObject({ timedOut: false, events: [ expect.objectContaining({ delegationId, kind: 'turn_completed', contentPreview: '## Handoff\nThe boundary is sound.�', - contentTruncated: false + contentTruncated: false, + evaluation: expect.objectContaining({ + evaluationKind: 'handoff_format', + formatStatus: 'invalid', + reasonCodes: ['required_sections_missing'] + }) }) ] }) expect(repository.require(delegationId).status).toBe('idle') + expect(repository.requireTurn(detail.turns[0]!.id)).toMatchObject({ + status: 'completed', + evaluation: { evaluationKind: 'handoff_format', formatStatus: 'invalid' } + }) expect(harness.sessions.linkSubagentTape).toHaveBeenCalledWith( expect.objectContaining({ parentSessionId: 'parent', @@ -134,6 +168,123 @@ describeIfSqlite('LiveDelegationService', () => { ) }) + it('surfaces one valid Handoff format evaluation through wait, inspect, and read_result', async () => { + const detail = await service.spawn('parent', { + slotId: 'reviewer', + title: 'Review formatted result', + prompt: 'Return every required result section.' + }) + await vi.waitFor(() => expect(harness.sessions.sendConversationMessage).toHaveBeenCalledOnce()) + const childId = repository.require(detail.delegation.id).childSessionId! + const answer = completeFormattedAnswer() + harness.publishAnswer(childId, answer, 200) + harness.publish({ sessionId: childId, kind: 'status', updatedAt: 201, status: 'idle' }) + + const waited = await service.wait('parent', { after: 0, timeoutMs: 1_000 }) + const waitedEvaluation = waited.events[0]!.evaluation! + expect(waitedEvaluation).toMatchObject({ + evaluationKind: 'handoff_format', + formatStatus: 'valid', + reasonCodes: [], + evidence: [] + }) + + const inspected = service.inspect('parent', detail.delegation.id) + expect(inspected.turns[0]!.evaluation).toEqual(waitedEvaluation) + expect(() => LiveDelegationDetailSchema.parse(inspected)).not.toThrow() + expect(inspected.turns[0]).not.toHaveProperty('taskContract') + expect(inspected.turns[0]).not.toHaveProperty('taskContractRef') + expect(inspected.turns[0]).not.toHaveProperty('inheritedTaskContractRef') + + const page = await service.readResult('parent', detail.delegation.id, { + turnId: inspected.turns[0]!.id + }) + expect(page.evaluation).toEqual(waitedEvaluation) + expect(page.text).toBe(answer) + expect(page.done).toBe(true) + }) + + it('makes the inherited TaskContract durable before crossing the child Handoff boundary', async () => { + let observedDurableContract = false + expect(service.prepareTaskContractContext('generic-child')).toBeNull() + harness.sessions.sendConversationMessage.mockImplementationOnce( + async (childSessionId: string) => { + const active = repository.listActiveTurns()[0]! + const inheritedRef = active.turn.inheritedTaskContractRef + const fact = contractStore + .getBySession(childSessionId) + .find((row) => row.name === 'contract/task_frozen') + expect(inheritedRef).toMatchObject({ + sessionId: childSessionId, + contractHash: active.turn.taskContract?.contractHash + }) + expect(fact?.entry_id).toBe(inheritedRef?.entryId) + expect(service.prepareTaskContractContext(childSessionId)).toEqual({ + contract: active.turn.taskContract, + localRef: inheritedRef + }) + observedDurableContract = true + } + ) + + await service.spawn('parent', { + slotId: 'reviewer', + title: 'Verify contract delivery', + prompt: 'Check the child inheritance boundary.' + }) + await vi.waitFor(() => expect(observedDurableContract).toBe(true)) + }) + + it('keeps a TaskContract persistence failure recoverable without dispatching the child', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + vi.spyOn(repository, 'ensureInheritedTaskContract').mockImplementationOnce(() => { + throw new LiveDelegationTaskContractErrorCtor('contract store unavailable') + }) + + const created = await service.spawn('parent', { + slotId: 'reviewer', + title: 'Park failed inheritance', + prompt: 'Do not dispatch without a durable contract.' + }) + const turnId = repository.listTurns(created.delegation.id)[0]!.id + await vi.waitFor(() => + expect(errorSpy).toHaveBeenCalledWith( + '[LiveDelegationService] TaskContract boundary remains recoverable:', + expect.objectContaining({ turnId }) + ) + ) + + const childSessionId = repository.require(created.delegation.id).childSessionId! + expect(repository.requireTurn(turnId).status).toBe('queued') + expect(harness.sessions.sendConversationMessage).not.toHaveBeenCalled() + expect(admission.snapshot().active).toBe(0) + expect(repository.listEvents('parent')).toEqual([]) + expect(() => service.prepareTaskContractContext(childSessionId)).toThrow( + /no admitted live-delegation runtime/u + ) + + await service.stop() + harness.sessions.sendConversationMessage.mockClear() + admission = new AgentInvocationAdmission(2, 10) + service = new LiveDelegationServiceCtor({ + repository, + sessions: harness.sessions, + safety: harness.safety, + consent: consentAuthority, + admission, + deletionGate + }) + service.start() + + await vi.waitFor(() => + expect(harness.sessions.sendConversationMessage).toHaveBeenCalledWith( + childSessionId, + expect.stringContaining('Park failed inheritance') + ) + ) + expect(repository.requireTurn(turnId).status).toBe('running') + }) + it('requires a matching explicit-user receipt at the service boundary', async () => { harness.parent.orchestrationPolicy = 'explicit' const input = { @@ -235,7 +386,8 @@ describeIfSqlite('LiveDelegationService', () => { slotId: 'reviewer', targetAgentId: 'deepchat', title: `Occupied slot ${index}`, - prompt: 'Keep this capacity slot occupied.' + prompt: 'Keep this capacity slot occupied.', + taskContract: createLiveDelegationTaskContractInput(null) }) } const receipt = consentAuthority.issue({ @@ -379,10 +531,10 @@ describeIfSqlite('LiveDelegationService', () => { expect(page.done).toBe(true) }) - it('settles as failed when durable result persistence rejects the reference', async () => { + it('keeps contract settlement recoverable when result persistence rejects the reference', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const finishTurn = repository.finishTurn.bind(repository) - vi.spyOn(repository, 'finishTurn').mockImplementation((input) => { + const finishTurnSpy = vi.spyOn(repository, 'finishTurn').mockImplementation((input) => { if (input.resultRef) throw new Error('result reference storage failed') return finishTurn(input) }) @@ -393,22 +545,59 @@ describeIfSqlite('LiveDelegationService', () => { }) await vi.waitFor(() => expect(harness.sessions.sendConversationMessage).toHaveBeenCalledOnce()) const childId = repository.require(detail.delegation.id).childSessionId! - harness.publishAnswer(childId, '## Handoff\nKeep this bounded conclusion.', 200) - harness.publish({ sessionId: childId, kind: 'status', updatedAt: 201, status: 'idle' }) + const startedAt = repository.listTurns(detail.delegation.id, 1)[0]!.startedAt! + harness.publishAnswer(childId, '## Handoff\nKeep this bounded conclusion.', startedAt + 1) + harness.publish({ + sessionId: childId, + kind: 'status', + updatedAt: startedAt + 2, + status: 'idle' + }) - await vi.waitFor(() => expect(repository.require(detail.delegation.id).status).toBe('failed')) + await vi.waitFor(() => + expect(errorSpy).toHaveBeenCalledWith( + '[LiveDelegationService] Contract-bearing settlement remains recoverable:', + expect.objectContaining({ turnId: detail.turns[0]!.id }) + ) + ) const turn = repository.listTurns(detail.delegation.id, 1)[0]! expect(turn).toMatchObject({ - status: 'failed', - resultSummary: '## Handoff\nKeep this bounded conclusion.', + status: 'running', + resultSummary: null, resultRef: null, - error: expect.stringContaining('Failed to persist child result') + error: null, + tapeReceipt: null, + evaluation: null, + evaluationRef: null }) - expect(turn.tapeReceipt).not.toBeNull() + expect(repository.listEvents('parent')).toEqual([]) + expect( + contractStore.getBySession('parent').filter((entry) => entry.name === 'contract/evaluated') + ).toEqual([]) expect(errorSpy).toHaveBeenCalledWith( '[LiveDelegationService] Failed to settle child turn:', expect.objectContaining({ turnId: turn.id }) ) + + finishTurnSpy.mockRestore() + await service.stop() + service = new LiveDelegationServiceCtor({ + repository, + sessions: harness.sessions, + safety: harness.safety, + consent: consentAuthority, + admission: new AgentInvocationAdmission(2, 10), + deletionGate + }) + service.start() + + const recovered = await service.wait('parent', { after: 0, timeoutMs: 1_000 }) + expect(recovered.events[0]).toMatchObject({ + relatedTurnId: turn.id, + kind: 'turn_completed', + evaluation: { evaluationKind: 'handoff_format', formatStatus: 'invalid' } + }) + expect(repository.require(detail.delegation.id).status).toBe('idle') }) it('pages the complete verified answer without exposing process output', async () => { @@ -497,6 +686,25 @@ describeIfSqlite('LiveDelegationService', () => { ).rejects.toThrow('failed integrity verification') }) + it('falls back from an empty Handoff section to a populated Result section', async () => { + const detail = await service.spawn('parent', { + slotId: 'reviewer', + title: 'Review empty Handoff fallback', + prompt: 'Return the useful conclusion in Result.' + }) + await vi.waitFor(() => expect(harness.sessions.sendConversationMessage).toHaveBeenCalledOnce()) + const childId = repository.require(detail.delegation.id).childSessionId! + harness.publishAnswer(childId, '## Handoff\n\n## Result\nUse this conclusion.', 200) + harness.publish({ sessionId: childId, kind: 'status', updatedAt: 201, status: 'idle' }) + + await vi.waitFor(() => expect(repository.require(detail.delegation.id).status).toBe('idle')) + expect(repository.listTurns(detail.delegation.id, 1)[0]).toMatchObject({ + status: 'completed', + resultSummary: '## Result\nUse this conclusion.', + evaluation: { evaluationKind: 'handoff_format', formatStatus: 'invalid' } + }) + }) + it('bounds the combined mailbox payload when several children finish together', async () => { for (let index = 0; index < 5; index += 1) { const detail = await service.spawn('parent', { @@ -1134,6 +1342,7 @@ describeIfSqlite('LiveDelegationService', () => { targetAgentId: 'agent-1', title: 'Recovered wait', prompt: 'Wait while admission is occupied.', + taskContract: createLiveDelegationTaskContractInput(null), now: 100 }) harness.addChild('child-recovered-wait', created.delegation.id, 'generating') @@ -1608,6 +1817,7 @@ describeIfSqlite('LiveDelegationService', () => { targetAgentId: 'agent-1', title: 'Close waiter race', prompt: 'Complete between the first read and waiter registration.', + taskContract: createLiveDelegationTaskContractInput(null), now: 100 }) const listEvents = repository.listEvents.bind(repository) @@ -1660,7 +1870,12 @@ describeIfSqlite('LiveDelegationService', () => { status: 'failed', resultSummary: null, resultRef: null, - error: 'Child session completed without a final answer.' + error: 'Child session completed without a final answer.', + evaluation: { + evaluationKind: 'handoff_format', + formatStatus: 'indeterminate', + reasonCodes: ['candidate_missing'] + } }) expect(harness.sessions.linkSubagentTape).toHaveBeenCalledWith( expect.objectContaining({ outcome: 'error', resultSummary: null }) @@ -1669,13 +1884,18 @@ describeIfSqlite('LiveDelegationService', () => { events: [ expect.objectContaining({ kind: 'turn_failed', - contentPreview: 'Child session completed without a final answer.' + contentPreview: 'Child session completed without a final answer.', + evaluation: expect.objectContaining({ + evaluationKind: 'handoff_format', + formatStatus: 'indeterminate', + reasonCodes: ['candidate_missing'] + }) }) ] }) }) - it('reconciles an accepted idle child after restart', async () => { + it('reconciles an idle child after restart', async () => { await service.stop() const created = repository.create({ id: 'delegation-recovery', @@ -1685,6 +1905,7 @@ describeIfSqlite('LiveDelegationService', () => { targetAgentId: 'agent-1', title: 'Recover review', prompt: 'Complete before restart.', + taskContract: createLiveDelegationTaskContractInput(null), now: 100 }) harness.addChild('child-recovery', created.delegation.id, 'idle') @@ -1703,6 +1924,7 @@ describeIfSqlite('LiveDelegationService', () => { service.start() const result = await service.wait('parent', { after: 0, timeoutMs: 1_000 }) + expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThanOrEqual(64 * 1024) expect(result.events).toEqual([ expect.objectContaining({ kind: 'turn_completed', @@ -1713,6 +1935,58 @@ describeIfSqlite('LiveDelegationService', () => { expect(repository.require(created.delegation.id).status).toBe('idle') }) + it('freezes and inherits a compatibility contract before resuming a legacy active child', async () => { + await service.stop() + const created = repository.create({ + id: 'delegation-legacy-recovery', + initialTurnId: 'turn-legacy-recovery', + parentSessionId: 'parent', + slotId: 'reviewer', + targetAgentId: 'agent-1', + title: 'Recover legacy task', + prompt: 'Continue with explicit compatibility semantics.', + taskContract: createLiveDelegationTaskContractInput(null), + now: 100 + }) + harness.addChild('child-legacy-recovery', created.delegation.id, 'generating') + repository.bindChild(created.delegation.id, 'child-legacy-recovery', 110) + repository.markTurnStarted(created.turn.id, 120) + contractStore.runInTransaction(() => { + db!.prepare('DELETE FROM deepchat_tape_entries WHERE session_id = ?').run('parent') + db! + .prepare( + `UPDATE live_delegation_turns + SET task_contract_json = NULL, task_contract_ref_json = NULL + WHERE turn_id = ?` + ) + .run(created.turn.id) + }) + + service = new LiveDelegationServiceCtor({ + repository, + sessions: harness.sessions, + safety: harness.safety, + consent: consentAuthority, + admission: new AgentInvocationAdmission(2, 10), + deletionGate + }) + service.start() + await vi.waitFor(() => { + const turn = repository.requireTurn(created.turn.id) + expect(turn.taskContract).toMatchObject({ + taskConfig: { creationReason: 'legacy_recovery' }, + taskHarness: { acceptance: [] } + }) + expect(turn.inheritedTaskContractRef?.sessionId).toBe('child-legacy-recovery') + }) + + const context = service.prepareTaskContractContext('child-legacy-recovery') + expect(context?.contract.taskConfig.creationReason).toBe('legacy_recovery') + expect(context?.localRef).toEqual( + repository.requireTurn(created.turn.id).inheritedTaskContractRef + ) + }) + it('does not reuse an older child answer while recovering a later turn', async () => { await service.stop() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) @@ -1724,6 +1998,7 @@ describeIfSqlite('LiveDelegationService', () => { targetAgentId: 'agent-1', title: 'Recover without answer', prompt: 'Complete after an older child answer.', + taskContract: createLiveDelegationTaskContractInput(null), now: 100 }) harness.addChild('child-stale-result', created.delegation.id, 'idle') @@ -1764,6 +2039,7 @@ describeIfSqlite('LiveDelegationService', () => { targetAgentId: 'agent-1', title: 'Recover effect boundary', prompt: 'Continue after restart.', + taskContract: createLiveDelegationTaskContractInput(null), now: 100 }) harness.addChild('child-effect-recovery', created.delegation.id, 'generating') @@ -1790,6 +2066,113 @@ describeIfSqlite('LiveDelegationService', () => { expect(repository.requireTurn(created.turn.id).effectState).toBe('read') }) + it('quarantines a generating child when TaskContract reconciliation fails', async () => { + await service.stop() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const created = repository.create({ + id: 'delegation-contract-quarantine', + initialTurnId: 'turn-contract-quarantine', + parentSessionId: 'parent', + slotId: 'reviewer', + targetAgentId: 'agent-1', + title: 'Quarantine invalid contract', + prompt: 'Do not continue with invalid lineage.', + taskContract: createLiveDelegationTaskContractInput(null), + now: 100 + }) + harness.addChild('child-contract-quarantine', created.delegation.id, 'generating') + repository.bindChild(created.delegation.id, 'child-contract-quarantine', 110) + repository.markTurnStarted(created.turn.id, 120) + const ensureInheritedTaskContract = vi + .spyOn(repository, 'ensureInheritedTaskContract') + .mockImplementationOnce(() => { + throw new LiveDelegationTaskContractErrorCtor('contract lineage is unavailable') + }) + let rejectCancellation!: (error: Error) => void + harness.sessions.cancelConversation.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectCancellation = reject + }) + ) + + service = new LiveDelegationServiceCtor({ + repository, + sessions: harness.sessions, + safety: harness.safety, + consent: consentAuthority, + admission: new AgentInvocationAdmission(2, 10), + deletionGate + }) + service.start() + await vi.waitFor(() => + expect(harness.sessions.cancelConversation).toHaveBeenCalledWith('child-contract-quarantine') + ) + + expect(repository.requireTurn(created.turn.id).status).toBe('running') + expect(repository.listEvents('parent')).toEqual([]) + expect(() => service.prepareTaskContractContext('child-contract-quarantine')).toThrow( + /is quarantined after TaskContract reconciliation failed/u + ) + await expect( + service.beforeToolExecution({ + conversationId: 'child-contract-quarantine', + toolCallId: 'call-after-contract-failure', + toolName: 'read', + source: 'agent', + reviewedExecution: TOOL_EXECUTION.read.parallel + }) + ).rejects.toThrow(/is quarantined after TaskContract reconciliation failed/u) + expect(ensureInheritedTaskContract).toHaveBeenCalledOnce() + expect(repository.requireTurn(created.turn.id).effectState).toBe('none') + rejectCancellation(new Error('cancel failed')) + await vi.waitFor(() => + expect(warnSpy).toHaveBeenCalledWith( + '[LiveDelegationService] Failed to cancel quarantined child session:', + expect.objectContaining({ turnId: created.turn.id }) + ) + ) + + for (const [index, status] of (['idle', 'error'] as const).entries()) { + harness.publish({ + sessionId: 'child-contract-quarantine', + kind: 'status', + updatedAt: 130 + index, + status + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(repository.requireTurn(created.turn.id).status).toBe('running') + expect(repository.listEvents('parent')).toEqual([]) + } + expect(errorSpy).toHaveBeenCalledWith( + '[LiveDelegationService] Failed to reconcile child turn:', + expect.objectContaining({ turnId: created.turn.id }) + ) + + await service.stop() + expect(() => service.prepareTaskContractContext('child-contract-quarantine')).toThrow( + /is quarantined after TaskContract reconciliation failed/u + ) + await expect( + service.beforeToolExecution({ + conversationId: 'child-contract-quarantine', + toolCallId: 'call-after-service-stop', + toolName: 'read', + source: 'agent', + reviewedExecution: TOOL_EXECUTION.read.parallel + }) + ).rejects.toThrow(/is quarantined after TaskContract reconciliation failed/u) + expect(repository.requireTurn(created.turn.id).status).toBe('running') + expect(repository.listEvents('parent')).toEqual([]) + + service.start() + await vi.waitFor(() => expect(ensureInheritedTaskContract).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(repository.requireTurn(created.turn.id).status).toBe('failed')) + expect(repository.listEvents('parent')).toEqual([ + expect.objectContaining({ relatedTurnId: created.turn.id, kind: 'turn_failed' }) + ]) + }) + it('does not revive a turn interrupted while restart reconciliation is awaiting the child', async () => { await service.stop() const created = repository.create({ @@ -1800,6 +2183,7 @@ describeIfSqlite('LiveDelegationService', () => { targetAgentId: 'agent-1', title: 'Interrupt recovery', prompt: 'Remain stopped after interruption.', + taskContract: createLiveDelegationTaskContractInput(null), now: 100 }) harness.addChild('child-interrupt-recovery', created.delegation.id, 'generating') @@ -1848,7 +2232,7 @@ describeIfSqlite('LiveDelegationService', () => { expect(repository.requireTurn(created.turn.id).effectState).toBe('none') }) - it('treats an idle child without accepted handoff evidence as interrupted', async () => { + it('resumes an idle contract-bearing child when handoff intent was not recorded', async () => { await service.stop() const created = repository.create({ id: 'delegation-crash-window', @@ -1858,6 +2242,7 @@ describeIfSqlite('LiveDelegationService', () => { targetAgentId: 'agent-1', title: 'Crash window', prompt: 'May not have been sent.', + taskContract: createLiveDelegationTaskContractInput(null), now: 100 }) harness.addChild('child-crash-window', created.delegation.id, 'idle') @@ -1871,9 +2256,15 @@ describeIfSqlite('LiveDelegationService', () => { deletionGate }) service.start() - const result = await service.wait('parent', { after: 0, timeoutMs: 1_000 }) + await vi.waitFor(() => + expect(harness.sessions.sendConversationMessage).toHaveBeenCalledWith( + 'child-crash-window', + expect.stringContaining('Crash window') + ) + ) - expect(result.events).toEqual([expect.objectContaining({ kind: 'turn_interrupted' })]) + expect(repository.requireTurn(created.turn.id).status).toBe('running') + expect(repository.listEvents('parent')).toEqual([]) expect(harness.sessions.linkSubagentTape).not.toHaveBeenCalled() }) @@ -1888,6 +2279,7 @@ describeIfSqlite('LiveDelegationService', () => { targetAgentId: 'agent-1', title: 'Failed lookup', prompt: 'This child lookup fails.', + taskContract: createLiveDelegationTaskContractInput(null), now: 100 }) const healthy = repository.create({ @@ -1898,6 +2290,7 @@ describeIfSqlite('LiveDelegationService', () => { targetAgentId: 'agent-1', title: 'Healthy lookup', prompt: 'This child should still recover.', + taskContract: createLiveDelegationTaskContractInput(null), now: 110 }) harness.addChild('child-recovery-failed', failed.delegation.id, 'idle') @@ -1950,8 +2343,91 @@ describeIfSqlite('LiveDelegationService', () => { expect.objectContaining({ delegationId: failed.delegation.id }) ) }) + + it('isolates a corrupt active projection without blocking healthy restart recovery', async () => { + await service.stop() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const corrupted = repository.create({ + id: 'delegation-corrupt-projection', + initialTurnId: 'turn-corrupt-projection', + parentSessionId: 'parent', + slotId: 'reviewer', + targetAgentId: 'agent-1', + title: 'Corrupt projection', + prompt: 'This projection must be quarantined.', + taskContract: createLiveDelegationTaskContractInput(null), + now: 100 + }) + const healthy = repository.create({ + id: 'delegation-healthy-projection', + initialTurnId: 'turn-healthy-projection', + parentSessionId: 'parent', + slotId: 'reviewer', + targetAgentId: 'agent-1', + title: 'Healthy projection', + prompt: 'This projection must still recover.', + taskContract: createLiveDelegationTaskContractInput(null), + now: 200 + }) + harness.addChild('child-corrupt-projection', corrupted.delegation.id, 'generating') + harness.addChild('child-healthy-projection', healthy.delegation.id, 'idle') + repository.bindChild(corrupted.delegation.id, 'child-corrupt-projection', 110) + repository.bindChild(healthy.delegation.id, 'child-healthy-projection', 210) + repository.markTurnStarted(corrupted.turn.id, 120) + db! + .prepare( + "UPDATE live_delegation_turns SET task_contract_json = '{}' WHERE turn_id = 'turn-corrupt-projection'" + ) + .run() + + service = new LiveDelegationServiceCtor({ + repository, + sessions: harness.sessions, + safety: harness.safety, + consent: consentAuthority, + admission: new AgentInvocationAdmission(2, 10), + deletionGate + }) + + expect(() => service.start()).not.toThrow() + await vi.waitFor(() => + expect(harness.sessions.cancelConversation).toHaveBeenCalledWith('child-corrupt-projection') + ) + await vi.waitFor(() => + expect(harness.sessions.sendConversationMessage).toHaveBeenCalledWith( + 'child-healthy-projection', + expect.stringContaining('Healthy projection') + ) + ) + + expect(() => service.prepareTaskContractContext('child-corrupt-projection')).toThrow( + /is quarantined after TaskContract reconciliation failed/u + ) + expect(repository.requireTurn(healthy.turn.id).status).toBe('running') + expect(errorSpy).toHaveBeenCalledWith( + '[LiveDelegationService] Failed to reconcile child turn:', + expect.objectContaining({ turnId: corrupted.turn.id }) + ) + }) }) +function completeFormattedAnswer(): string { + return [ + '## Handoff', + 'Use the reviewed conclusion.', + '## Result', + 'The boundary is sound.', + '## Evidence', + 'Repository and Tape facts agree.', + '## Changed Files', + 'None.', + '## Validation', + 'Focused tests passed.', + '## Unresolved', + 'None.' + ].join('\n') +} + function createSessionHarness(db: InstanceType) { const listeners = new Set<(update: SessionRuntimeUpdate) => void>() const children = new Map() diff --git a/test/main/session/data/tapeFork.test.ts b/test/main/session/data/tapeFork.test.ts index 5dd1a9ec3..91520223c 100644 --- a/test/main/session/data/tapeFork.test.ts +++ b/test/main/session/data/tapeFork.test.ts @@ -12,6 +12,7 @@ import { createTapeTableMock, createRecord } from './tapeTestHarness' +import { DeepChatContractStore } from '@/tape/infrastructure/sqlite/tapeEntryStore' describe('SessionTape forks', () => { it('keeps fork writes isolated until merge and discards fork entries on discard', () => { @@ -398,11 +399,12 @@ describe('SessionTape forks', () => { } }) - itIfSqlite('does not copy Execution Journal facts from a fork', () => { + itIfSqlite('does not copy strict audit facts from a fork', () => { const db = new DatabaseCtor(':memory:') try { const table = new DeepChatTapeEntriesTable(db) const journalStore = new DeepChatExecutionJournalStore(db) + const contractStore = new DeepChatContractStore(db) table.createTable() const service = new SessionTape({ deepchatTapeEntriesTable: table, @@ -416,15 +418,29 @@ describe('SessionTape forks', () => { name: 'execution/run_started', data: { marker: 'must-not-merge' } }) + contractStore.appendContractEvent({ + sessionId: fork.forkSessionId, + name: 'contract/task_frozen', + data: { marker: 'must-not-merge' } + }) expect( table .getBySession(fork.forkSessionId) .some((entry) => entry.name === 'execution/run_started') ).toBe(true) + expect( + table + .getBySession(fork.forkSessionId) + .some((entry) => entry.name === 'contract/task_frozen') + ).toBe(true) expect(service.mergeFork('parent', 'journal-isolation')).toBe(0) expect( - table.getBySession('parent').filter((entry) => entry.name?.startsWith('execution/')) + table + .getBySession('parent') + .filter( + (entry) => entry.name?.startsWith('execution/') || entry.name?.startsWith('contract/') + ) ).toEqual([]) } finally { db.close() diff --git a/test/main/session/data/tapeViewManifest.test.ts b/test/main/session/data/tapeViewManifest.test.ts index 544e607d6..2304dac75 100644 --- a/test/main/session/data/tapeViewManifest.test.ts +++ b/test/main/session/data/tapeViewManifest.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' +import { ModelType } from '@shared/model' import type { ChatMessageRecord } from '@shared/types/agent-interface' import { TOOL_EXECUTION, type MCPToolDefinitionBase } from '@shared/types/core/mcp' +import { hashJsonData } from '@/tape/domain/canonicalJson' +import { + buildExecutionContract, + isDeepChatExecutionContract +} from '@/tape/domain/executionContract' import { buildIncludedRefs, buildRequestRefs, @@ -28,6 +34,75 @@ function createRecord(overrides: Partial): ChatMessageRecord } } +function createV5Fixture(permissionMode: 'default' | 'auto_approve' = 'default') { + const messages = [{ role: 'user' as const, content: 'hello' }] + const tools = [] + const executionContract = buildExecutionContract({ + request: { + sessionId: 's1', + messageId: 'a1', + runId: '11111111-1111-4111-8111-111111111111', + requestSeq: 1 + }, + promptAssembly: { prompt: '', sections: [] }, + providerMessages: messages, + tools, + providerId: 'openai', + modelId: 'gpt-4o', + modelConfig: { + maxTokens: 100, + contextLength: 1000, + vision: false, + functionCall: true, + reasoning: false, + type: ModelType.Chat, + conversationId: 's1' + }, + temperature: 0.2, + maxTokens: 100, + workspace: { kind: 'runtime_default' }, + maxSubagentDepth: 0, + dynamicControlSnapshot: { + permissionMode, + requestAdmitted: true, + cancellationRequested: false + }, + assemblerVersion: 'deepchat-view-v1' + }) + return { + executionContract, + input: { + sessionId: 's1', + messageId: 'a1', + requestSeq: 1, + taskType: 'chat' as const, + policy: 'legacy_context_v1' as const, + policyVersion: 1, + messages, + tools, + latestEntryId: 7, + anchorEntryIds: [1], + included: [], + excluded: [], + tokenBudget: { + contextLength: 1000, + requestedMaxTokens: 100, + effectiveMaxTokens: 100, + reserveTokens: 100, + toolReserveTokens: 0 + }, + providerId: 'openai', + modelId: 'gpt-4o', + summaryCursorOrderSeq: 1, + supportsVision: false, + supportsAudioInput: false, + traceDebugEnabled: false, + executionContract, + assembledAt: 123 + } + } +} + describe('tapeViewManifest', () => { it('hashes JSON with stable object key ordering', () => { expect(hashJson({ b: 1, a: { d: 4, c: 3 } })).toBe(hashJson({ a: { c: 3, d: 4 }, b: 1 })) @@ -116,6 +191,120 @@ describe('tapeViewManifest', () => { expect(JSON.stringify(first)).not.toContain('secret prompt content') }) + it('embeds a matching execution contract in a schema-v5 manifest', () => { + const { input, executionContract } = createV5Fixture() + const manifest = createTapeViewManifest(input) + + expect(manifest.schemaVersion).toBe(5) + expect(manifest.hashVersion).toBe(3) + expect(manifest.executionContract).toBe(executionContract) + expect(manifest.hashes.promptHash).toBe(executionContract.provenance.promptHash) + expect(manifest.hashes.toolDefinitionsHash).toBe( + executionContract.provenance.providerVisibleToolDefinitionsHash + ) + expect(verifyTapeViewManifestHash(manifest)).toBe('valid') + expect(verifyTapeViewManifestHash({ ...manifest, viewId: 'view_tampered' })).toBe('invalid') + expect(normalizeStoredTapeViewManifest(JSON.parse(JSON.stringify(manifest)), 's1')).toEqual( + manifest + ) + }) + + it('rejects execution contracts that do not match the View request or provider payload', () => { + const { input, executionContract } = createV5Fixture() + const agentTool = { + source: 'agent' as const, + execution: TOOL_EXECUTION.read.parallel, + type: 'function' as const, + function: { + name: 'inspect', + description: 'Inspect a resource', + parameters: { type: 'object', properties: {} } + }, + server: { name: 'agent-filesystem', icons: '', description: 'Agent tools' } + } + + expect(() => createTapeViewManifest({ ...input, messageId: 'other-message' })).toThrow( + /request identity/ + ) + expect(() => createTapeViewManifest({ ...input, sessionId: 'other-session' })).toThrow( + /request identity/ + ) + expect(() => createTapeViewManifest({ ...input, requestSeq: 2 })).toThrow(/request identity/) + expect(() => createTapeViewManifest({ ...input, providerId: 'other-provider' })).toThrow( + /provider identity/ + ) + expect(() => createTapeViewManifest({ ...input, modelId: 'other-model' })).toThrow( + /provider identity/ + ) + expect(() => + createTapeViewManifest({ + ...input, + messages: [...input.messages, { role: 'user', content: 'changed' }] + }) + ).toThrow(/provider-message hash/) + expect(() => createTapeViewManifest({ ...input, tools: [agentTool] })).toThrow( + /tool-definition hash/ + ) + expect(() => + createTapeViewManifest({ + ...input, + executionContract: { ...executionContract, contractHash: '0'.repeat(64) } + }) + ).toThrow(/invalid hash/) + const mutableContract = JSON.parse(JSON.stringify(executionContract)) + expect(isDeepChatExecutionContract(mutableContract)).toBe(true) + expect(() => createTapeViewManifest({ ...input, executionContract: mutableContract })).toThrow( + /immutable/ + ) + }) + + it('binds the execution contract into v5 identity but excludes assembledAt', () => { + const first = createV5Fixture('default') + const changedControl = createV5Fixture('auto_approve') + const early = createTapeViewManifest(first.input) + const late = createTapeViewManifest({ ...first.input, assembledAt: 999 }) + const changedContract = createTapeViewManifest(changedControl.input) + + expect(early.hashes.manifestHash).toBe(late.hashes.manifestHash) + expect(early.viewId).toBe(late.viewId) + expect(early.hashes.manifestHash).not.toBe(changedContract.hashes.manifestHash) + expect(early.viewId).not.toBe(changedContract.viewId) + }) + + it('rejects malformed v5 contracts even when their contract hash is self-consistent', () => { + const { input, executionContract } = createV5Fixture() + const malformed = JSON.parse(JSON.stringify(executionContract)) + malformed.provenance.promptSections = [ + { + kind: 'configured_prompt', + sourceRef: 'test:prompt', + inclusion: 'included', + contentHash: 'a'.repeat(64), + degradationCodes: [] + } + ] + const { contractHash: _, ...draft } = malformed + malformed.contractHash = hashJsonData(draft) + + expect(isDeepChatExecutionContract(malformed)).toBe(false) + expect( + normalizeStoredTapeViewManifest( + { ...createTapeViewManifest(input), executionContract: malformed }, + 's1' + ) + ).toBeNull() + }) + + it('rejects missing v5 contracts without changing legacy manifest reads', () => { + const { input } = createV5Fixture() + const v5 = createTapeViewManifest(input) + const withoutContract = { ...v5 } as Record + delete withoutContract.executionContract + + expect(normalizeStoredTapeViewManifest(withoutContract, 's1')).toBeNull() + expect(createTapeViewManifest({ ...input, executionContract: undefined }).schemaVersion).toBe(4) + }) + it('keeps execution metadata out of provider-view tool hashes', () => { const baseTool: MCPToolDefinitionBase = { type: 'function', diff --git a/test/main/session/runtimeIntegration.test.ts b/test/main/session/runtimeIntegration.test.ts index ab4c0c301..7acb31ec4 100644 --- a/test/main/session/runtimeIntegration.test.ts +++ b/test/main/session/runtimeIntegration.test.ts @@ -1,5 +1,5 @@ -import { AppSessionService } from '@/agent/shared/appSessionService' import * as fs from 'node:fs' +import { AppSessionService } from '@/agent/shared/appSessionService' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createDeepChatAgentHarness, type DeepChatAgentHarness } from '@/agent/deepchat/harness' import { estimateMessagesTokens } from '@/agent/deepchat/runtime/contextBuilder' @@ -45,7 +45,9 @@ vi.mock('@/events', async (importOriginal) => { }) beforeEach(() => { - vi.mocked(fs.promises.readFile).mockResolvedValue('') + vi.mocked(fs.promises.readFile).mockRejectedValue( + Object.assign(new Error('AGENTS.md does not exist'), { code: 'ENOENT' }) + ) }) afterEach(() => { @@ -827,6 +829,9 @@ function createRuntimeDependencies() { summary: { status: 'ready' as const, issues: [], suggestedActions: [] } })) }, + taskContractContext: { + prepare: vi.fn().mockReturnValue(null) + }, commandShell: { resolveForTurn: vi.fn().mockResolvedValue(POSIX_COMMAND_SHELL), resolveProfile: vi.fn().mockResolvedValue(POSIX_COMMAND_SHELL) diff --git a/test/main/tape/canonicalJson.test.ts b/test/main/tape/canonicalJson.test.ts index f47871030..229d1f3bf 100644 --- a/test/main/tape/canonicalJson.test.ts +++ b/test/main/tape/canonicalJson.test.ts @@ -12,6 +12,19 @@ describe('strict canonical JSON', () => { expect(hashJsonData(prototypeShaped)).not.toBe(hashJsonData({ value: 1 })) }) + it('can omit undefined object properties without weakening strict array checks', () => { + expect( + canonicalJsonStringifyData( + { nested: { retained: true, omitted: undefined } }, + { omitUndefinedProperties: true } + ) + ).toBe('{"nested":{"retained":true}}') + expect(() => + canonicalJsonStringifyData([undefined], { omitUndefinedProperties: true }) + ).toThrow(TypeError) + expect(() => canonicalJsonStringifyData({ omitted: undefined })).toThrow(TypeError) + }) + it('rejects values that cannot be represented as stable JSON data', () => { const circular: Record = {} circular.self = circular diff --git a/test/main/tape/executionContract.test.ts b/test/main/tape/executionContract.test.ts new file mode 100644 index 000000000..b3cd3ec4c --- /dev/null +++ b/test/main/tape/executionContract.test.ts @@ -0,0 +1,701 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { ModelType } from '@shared/model' +import { TOOL_EXECUTION, type MCPToolDefinition } from '@shared/types/core/mcp' +import { + assemblePromptSections, + createPromptAssemblySection +} from '@/agent/deepchat/resources/promptAssembly' +import { hashJsonData } from '@/tape/domain/canonicalJson' +import { + ExecutionContractError, + MAX_EXECUTION_CONTRACT_PROMPT_SECTIONS, + MAX_EXECUTION_CONTRACT_BINDING_BYTES, + MAX_EXECUTION_CONTRACT_TOOLS, + assertExecutionContractAllowsDispatch, + buildEffectiveGenerationConfigHash, + buildExecutionContract, + buildExecutionContractBinding, + buildProviderVisibleToolDefinitionsHash, + executionContractMatchesBinding, + isDeepChatExecutionContract, + isDeepChatExecutionContractBinding, + isToolEffectWithinCeiling, + meetToolEffects, + parseExecutionContractBinding, + verifyExecutionContractHash, + type BuildExecutionContractInput +} from '@/tape/domain/executionContract' +import { buildTaskContract } from '@/tape/domain/taskContract' + +const RUN_ID = '11111111-1111-4111-8111-111111111111' +const SERVER_ID = '22222222-2222-4222-8222-222222222222' +const BINDING_HASH = 'a'.repeat(64) + +function agentTool( + name: string, + execution: MCPToolDefinition['execution'] = TOOL_EXECUTION.read.parallel +): MCPToolDefinition { + return { + source: 'agent', + execution, + type: 'function', + function: { + name, + description: `${name} description`, + parameters: { type: 'object', properties: {} } + }, + server: { + name: 'agent-filesystem', + icons: '', + description: 'Agent tools' + } + } +} + +function mcpTool( + overrides: { + name?: string + serverId?: string + execution?: MCPToolDefinition['execution'] + } = {} +): MCPToolDefinition { + const name = overrides.name ?? 'remote_read' + return { + source: 'mcp', + execution: overrides.execution ?? TOOL_EXECUTION.write, + type: 'function', + function: { + name, + description: `${name} description`, + parameters: { type: 'object', properties: { optional: undefined } } + }, + server: { + name: 'remote', + icons: '', + description: 'Remote tools', + id: overrides.serverId ?? SERVER_ID, + configGeneration: 3, + bindingHash: BINDING_HASH + }, + raw: { + name: 'read', + inputSchema: { type: 'object', properties: {} } + } + } +} + +function buildInput( + overrides: Partial = {} +): BuildExecutionContractInput { + const promptAssembly = assemblePromptSections([ + createPromptAssemblySection({ + kind: 'configured_prompt', + sourceRef: 'session:generation-settings.system-prompt', + content: 'Keep this secret body out of the contract.' + }), + createPromptAssemblySection({ + kind: 'agents_instructions', + sourceRef: 'workspace:AGENTS.md', + content: '', + freshness: 'missing', + degradationCodes: ['agents_file_missing'] + }) + ]) + return { + request: { + sessionId: 'session-1', + messageId: 'message-1', + runId: RUN_ID, + requestSeq: 2 + }, + promptAssembly, + providerMessages: [ + { role: 'system', content: promptAssembly.prompt }, + { role: 'user', content: 'Hello' } + ], + tools: [mcpTool(), agentTool('read')], + providerId: 'provider-1', + modelId: 'model-1', + modelConfig: { + maxTokens: 4096, + contextLength: 32_768, + vision: false, + functionCall: true, + reasoning: false, + type: ModelType.Chat, + conversationId: 'session-1' + }, + temperature: 0.2, + maxTokens: 2048, + workspace: { kind: 'path', path: '/workspace/project/' }, + maxSubagentDepth: 1, + dynamicControlSnapshot: { + permissionMode: 'default', + requestAdmitted: true, + cancellationRequested: false + }, + assemblerVersion: 'deepchat-view-v1', + ...overrides + } +} + +function buildTaskContext( + overrides: { + sessionId?: string + workspace?: string + maxToolEffect?: 'read' | 'write' + maxSubagentDepth?: number + contractHash?: string + } = {} +) { + const contract = buildTaskContract({ + delegationId: 'delegation-1', + turnId: 'turn-1', + turnSeq: 1, + turnKind: 'initial', + parentSessionId: 'parent-1', + slotId: 'reviewer', + targetAgentId: 'agent-1', + title: 'Review boundaries', + prompt: 'Inspect the contract boundary.', + workspace: { kind: 'path', path: overrides.workspace ?? path.resolve('task-workspace') }, + handoffFormat: [], + maxToolEffect: overrides.maxToolEffect ?? 'write', + maxSubagentDepth: overrides.maxSubagentDepth ?? 1 + }) + return { + contract, + localRef: { + schemaVersion: 1 as const, + sessionId: overrides.sessionId ?? 'session-1', + tapeIdentity: 'c'.repeat(64), + entryId: 2, + contractHash: overrides.contractHash ?? contract.contractHash + } + } +} + +describe('ExecutionContract domain', () => { + it('builds a bounded immutable contract without persisting prompt bodies', () => { + const contract = buildExecutionContract(buildInput()) + const serialized = JSON.stringify(contract) + + expect(contract).toMatchObject({ + schemaVersion: 1, + hashVersion: 1, + request: { runId: RUN_ID, requestSeq: 2 }, + ceilings: { + workspace: { kind: 'path', path: '/workspace/project/' }, + maxSubagentDepth: 1 + }, + provenance: { + promptSections: [ + { + kind: 'configured_prompt', + contentHash: expect.stringMatching(/^[0-9a-f]{64}$/) + }, + { + kind: 'agents_instructions', + inclusion: 'omitted', + freshness: 'missing', + degradationCodes: ['agents_file_missing'] + } + ], + taskContractRef: null + }, + contractHash: expect.stringMatching(/^[0-9a-f]{64}$/) + }) + expect(serialized).not.toContain('Keep this secret body') + expect(contract.ceilings.tools.map((tool) => tool.target.providerVisibleName)).toEqual([ + 'remote_read', + 'read' + ]) + expect( + contract.ceilings.tools.find((tool) => tool.target.source === 'agent')?.target + ).toMatchObject({ + source: 'agent', + serverId: null, + configGeneration: null, + bindingHash: null, + originalName: 'read' + }) + expect(Object.isFrozen(contract)).toBe(true) + expect(Object.isFrozen(contract.ceilings.tools[0].target)).toBe(true) + expect(Object.isFrozen(contract.provenance.promptSections)).toBe(true) + expect(verifyExecutionContractHash(contract)).toBe(true) + }) + + it('binds a child-local TaskContract and rejects View ceiling expansion', () => { + const taskWorkspace = path.resolve('task-workspace') + const context = buildTaskContext({ workspace: taskWorkspace }) + const contract = buildExecutionContract( + buildInput({ + tools: [agentTool('read')], + workspace: { kind: 'path', path: path.join(taskWorkspace, 'child') }, + maxSubagentDepth: 1, + taskContractContext: context + }) + ) + + expect(contract.provenance.taskContractRef).toEqual(context.localRef) + expect(Object.isFrozen(contract.provenance.taskContractRef)).toBe(true) + expect(isDeepChatExecutionContract(contract)).toBe(true) + + const crossSession = JSON.parse(JSON.stringify(contract)) + crossSession.provenance.taskContractRef.sessionId = 'another-child' + const { contractHash: _, ...crossSessionDraft } = crossSession + crossSession.contractHash = hashJsonData(crossSessionDraft) + expect(isDeepChatExecutionContract(crossSession)).toBe(false) + + expect(() => + buildExecutionContract( + buildInput({ + tools: [mcpTool()], + workspace: { kind: 'path', path: taskWorkspace }, + maxSubagentDepth: 0, + taskContractContext: buildTaskContext({ + workspace: taskWorkspace, + maxToolEffect: 'read' + }) + }) + ) + ).toThrow(/effect ceiling/u) + expect(() => + buildExecutionContract( + buildInput({ + tools: [agentTool('read')], + workspace: { kind: 'path', path: path.resolve('outside-task-workspace') }, + maxSubagentDepth: 0, + taskContractContext: context + }) + ) + ).toThrow(/workspace ceiling/u) + expect(() => + buildExecutionContract( + buildInput({ + tools: [agentTool('read')], + workspace: { kind: 'path', path: taskWorkspace }, + maxSubagentDepth: 1, + taskContractContext: buildTaskContext({ + workspace: taskWorkspace, + maxSubagentDepth: 0 + }) + }) + ) + ).toThrow(/Subagent ceiling/u) + expect(() => + buildExecutionContract( + buildInput({ + tools: [agentTool('read')], + workspace: { kind: 'path', path: taskWorkspace }, + maxSubagentDepth: 0, + taskContractContext: buildTaskContext({ + sessionId: 'another-child', + workspace: taskWorkspace + }) + }) + ) + ).toThrow(/provider request Session/u) + }) + + it('hashes the exact provider order while canonicalizing the enforcement projection', () => { + const first = buildExecutionContract(buildInput({ tools: [mcpTool(), agentTool('read')] })) + const reversed = buildExecutionContract(buildInput({ tools: [agentTool('read'), mcpTool()] })) + + expect(first.ceilings.tools).toEqual(reversed.ceilings.tools) + expect(first.provenance.internalExecutionPolicyHash).toBe( + reversed.provenance.internalExecutionPolicyHash + ) + expect(first.provenance.providerVisibleToolDefinitionsHash).not.toBe( + reversed.provenance.providerVisibleToolDefinitionsHash + ) + expect(first.contractHash).not.toBe(reversed.contractHash) + }) + + it('excludes execution policy from the provider-visible hash and includes it internally', () => { + const read = buildExecutionContract( + buildInput({ tools: [agentTool('inspect', TOOL_EXECUTION.read.sequential)] }) + ) + const write = buildExecutionContract( + buildInput({ tools: [agentTool('inspect', TOOL_EXECUTION.write)] }) + ) + + expect(read.provenance.providerVisibleToolDefinitionsHash).toBe( + write.provenance.providerVisibleToolDefinitionsHash + ) + expect(read.provenance.internalExecutionPolicyHash).not.toBe( + write.provenance.internalExecutionPolicyHash + ) + + const otherWorkspace = buildExecutionContract( + buildInput({ + tools: [agentTool('inspect', TOOL_EXECUTION.read.sequential)], + workspace: { kind: 'path', path: '/workspace/other' } + }) + ) + expect(read.provenance.internalExecutionPolicyHash).not.toBe( + otherWorkspace.provenance.internalExecutionPolicyHash + ) + }) + + it('deduplicates identical targets and rejects ambiguous target or policy mappings', () => { + const duplicate = agentTool('read') + expect( + buildExecutionContract(buildInput({ tools: [duplicate, duplicate] })).ceilings.tools + ).toHaveLength(1) + + expect(() => + buildExecutionContract( + buildInput({ + tools: [ + agentTool('read', TOOL_EXECUTION.read.parallel), + agentTool('read', TOOL_EXECUTION.write) + ] + }) + ) + ).toThrow(/conflicting execution policies/) + expect(() => + buildExecutionContract( + buildInput({ + tools: [mcpTool(), mcpTool({ serverId: '33333333-3333-4333-8333-333333333333' })] + }) + ) + ).toThrow(/resolves to conflicting targets/) + + const changedDefinition = agentTool('read') + changedDefinition.function.description = 'A different provider-visible definition' + expect(() => + buildExecutionContract(buildInput({ tools: [agentTool('read'), changedDefinition] })) + ).toThrow(/conflicting provider definitions/) + }) + + it('requires stable MCP bindings and matching prompt provenance', () => { + const missingBinding = mcpTool() + delete missingBinding.server.bindingHash + expect(() => buildExecutionContract(buildInput({ tools: [missingBinding] }))).toThrow( + /server.bindingHash/ + ) + + const input = buildInput() + expect(() => + buildExecutionContract({ + ...input, + providerMessages: [{ role: 'system', content: 'different prompt' }] + }) + ).toThrow(/does not match the provider-visible system message/) + + const corruptedSection = { + ...input.promptAssembly.sections[0], + contentHash: '0'.repeat(64) + } + expect(() => + buildExecutionContract({ + ...input, + promptAssembly: { + ...input.promptAssembly, + sections: [corruptedSection] + } + }) + ).toThrow(/contentHash does not match/) + }) + + it('omits conversation identity from the generation hash without mutating config', () => { + const firstConfig = buildInput().modelConfig + const secondConfig = { ...firstConfig, conversationId: 'session-2' } + + expect( + buildEffectiveGenerationConfigHash({ + modelConfig: firstConfig, + temperature: 0.2, + maxTokens: 2048 + }) + ).toBe( + buildEffectiveGenerationConfigHash({ + modelConfig: secondConfig, + temperature: 0.2, + maxTokens: 2048 + }) + ) + expect(firstConfig.conversationId).toBe('session-1') + }) + + it('preserves prototype-shaped schema keys in provider-visible tool hashes', () => { + const tool = mcpTool() + Object.defineProperty(tool.function.parameters.properties, '__proto__', { + value: { type: 'string' }, + enumerable: true, + configurable: true + }) + const baseline = mcpTool() + + expect(buildProviderVisibleToolDefinitionsHash([tool])).not.toBe( + buildProviderVisibleToolDefinitionsHash([baseline]) + ) + }) + + it('enforces tool and prompt-section count limits before canonical construction', () => { + expect(() => + buildExecutionContract( + buildInput({ + tools: Array.from({ length: MAX_EXECUTION_CONTRACT_TOOLS + 1 }, (_, index) => + agentTool(`tool_${index}`) + ) + }) + ) + ).toThrow(ExecutionContractError) + + expect(() => buildExecutionContract(buildInput({ maxSubagentDepth: 2 }))).toThrow( + /V1 limit of 1/ + ) + + const promptAssembly = assemblePromptSections( + Array.from({ length: MAX_EXECUTION_CONTRACT_PROMPT_SECTIONS }, (_, index) => + createPromptAssemblySection({ + kind: 'tooling', + sourceRef: `runtime:tooling:${index}`, + content: `section ${index}` + }) + ) + ) + const extraSection = createPromptAssemblySection({ + kind: 'tooling', + sourceRef: 'runtime:tooling:extra', + content: 'extra' + }) + expect(() => + buildExecutionContract( + buildInput({ + promptAssembly: { + prompt: promptAssembly.prompt, + sections: [...promptAssembly.sections, extraSection] + }, + providerMessages: [{ role: 'system', content: promptAssembly.prompt }] + }) + ) + ).toThrow(ExecutionContractError) + }) + + it('rejects a canonical contract above the persistence byte budget', () => { + const promptAssembly = assemblePromptSections( + Array.from({ length: 40 }, (_, index) => + createPromptAssemblySection({ + kind: 'tooling', + sourceRef: `runtime:${index}:${'x'.repeat(1_900)}`, + content: `section ${index}` + }) + ) + ) + + expect(() => + buildExecutionContract( + buildInput({ + promptAssembly, + providerMessages: [{ role: 'system', content: promptAssembly.prompt }] + }) + ) + ).toThrow(/exceeds 65536 UTF-8 bytes/) + }) + + it('detects hash tampering and applies the declared effect ordering', () => { + const contract = buildExecutionContract(buildInput()) + const tampered = { + ...contract, + dynamicControlSnapshot: { ...contract.dynamicControlSnapshot, permissionMode: 'full_access' } + } as typeof contract + + expect(verifyExecutionContractHash(tampered)).toBe(false) + expect(isToolEffectWithinCeiling('read', 'write')).toBe(true) + expect(isToolEffectWithinCeiling('write', 'read')).toBe(false) + expect(meetToolEffects('read', 'write')).toBe('read') + expect(meetToolEffects('write', 'write')).toBe('write') + }) + + it('allows only the exact provider View identity and stable tool target', () => { + const currentTool = mcpTool() + const contract = buildExecutionContract(buildInput({ tools: [currentTool] })) + const dispatchInput = { + request: contract.request, + currentTool, + currentWorkspace: { kind: 'path' as const, path: '/workspace/project' }, + currentMaxSubagentDepth: 1, + requestedSubagentDepth: 0 + } + + expect(() => assertExecutionContractAllowsDispatch(contract, dispatchInput)).not.toThrow() + expect(() => + assertExecutionContractAllowsDispatch(contract, { + ...dispatchInput, + request: { ...contract.request, requestSeq: contract.request.requestSeq + 1 } + }) + ).toThrow(expect.objectContaining({ code: 'identity_mismatch' })) + + const tampered = { + ...contract, + dynamicControlSnapshot: { ...contract.dynamicControlSnapshot, permissionMode: 'full_access' } + } as typeof contract + expect(() => assertExecutionContractAllowsDispatch(tampered, dispatchInput)).toThrow( + expect.objectContaining({ code: 'invalid_contract' }) + ) + + expect(() => + assertExecutionContractAllowsDispatch(contract, { + ...dispatchInput, + currentTool: mcpTool({ name: 'new_tool' }) + }) + ).toThrow(expect.objectContaining({ code: 'tool_not_allowed' })) + expect(() => + assertExecutionContractAllowsDispatch(contract, { + ...dispatchInput, + currentTool: mcpTool({ serverId: '33333333-3333-4333-8333-333333333333' }) + }) + ).toThrow(expect.objectContaining({ code: 'target_mismatch' })) + }) + + it('binds paused execution to the complete provider View identity and contract hash', () => { + const contract = buildExecutionContract(buildInput()) + const binding = buildExecutionContractBinding(contract) + + expect(binding).toEqual({ + schemaVersion: 1, + request: contract.request, + contractHash: contract.contractHash + }) + expect(Object.isFrozen(binding)).toBe(true) + expect(isDeepChatExecutionContractBinding(binding)).toBe(true) + expect(executionContractMatchesBinding(contract, binding)).toBe(true) + expect( + executionContractMatchesBinding(contract, { + ...binding, + request: { ...binding.request, requestSeq: binding.request.requestSeq + 1 } + }) + ).toBe(false) + expect(isDeepChatExecutionContractBinding({ ...binding, extra: true })).toBe(false) + expect(parseExecutionContractBinding(JSON.stringify(binding))).toEqual(binding) + expect(parseExecutionContractBinding('{')).toBeNull() + expect( + parseExecutionContractBinding('x'.repeat(MAX_EXECUTION_CONTRACT_BINDING_BYTES + 1)) + ).toBeNull() + }) + + it('meets frozen effect, execution mode, workspace, and nesting ceilings', () => { + const frozenRead = agentTool('inspect', TOOL_EXECUTION.read.sequential) + const readContract = buildExecutionContract(buildInput({ tools: [frozenRead] })) + const dispatchInput = { + request: readContract.request, + currentTool: frozenRead, + currentWorkspace: { kind: 'path' as const, path: '/workspace/project' }, + currentMaxSubagentDepth: 1, + requestedSubagentDepth: 0 + } + + expect(() => + assertExecutionContractAllowsDispatch(readContract, { + ...dispatchInput, + currentTool: agentTool('inspect', TOOL_EXECUTION.write) + }) + ).toThrow(expect.objectContaining({ code: 'effect_exceeds_ceiling' })) + expect(() => + assertExecutionContractAllowsDispatch(readContract, { + ...dispatchInput, + currentTool: agentTool('inspect', TOOL_EXECUTION.read.parallel) + }) + ).toThrow(expect.objectContaining({ code: 'execution_mode_mismatch' })) + + const frozenWrite = agentTool('inspect', TOOL_EXECUTION.write) + const writeContract = buildExecutionContract(buildInput({ tools: [frozenWrite] })) + expect(() => + assertExecutionContractAllowsDispatch(writeContract, { + ...dispatchInput, + request: writeContract.request, + currentTool: agentTool('inspect', TOOL_EXECUTION.read.sequential) + }) + ).not.toThrow() + + expect(() => + assertExecutionContractAllowsDispatch(readContract, { + ...dispatchInput, + currentWorkspace: { kind: 'path', path: '/workspace/other' } + }) + ).toThrow(expect.objectContaining({ code: 'workspace_mismatch' })) + const defaultWorkspaceContract = buildExecutionContract( + buildInput({ tools: [frozenRead], workspace: { kind: 'runtime_default' } }) + ) + expect(() => + assertExecutionContractAllowsDispatch(defaultWorkspaceContract, { + ...dispatchInput, + request: defaultWorkspaceContract.request, + currentWorkspace: { kind: 'runtime_default' } + }) + ).not.toThrow() + + const delegationTool = agentTool('deepchat_subagents', TOOL_EXECUTION.write) + const noNestingContract = buildExecutionContract( + buildInput({ tools: [delegationTool], maxSubagentDepth: 0 }) + ) + const nestingInput = { + request: noNestingContract.request, + currentTool: delegationTool, + currentWorkspace: { kind: 'path' as const, path: '/workspace/project' }, + currentMaxSubagentDepth: 1, + requestedSubagentDepth: 1 + } + expect(() => assertExecutionContractAllowsDispatch(noNestingContract, nestingInput)).toThrow( + expect.objectContaining({ code: 'subagent_depth_exceeded' }) + ) + + const nestingContract = buildExecutionContract( + buildInput({ tools: [delegationTool], maxSubagentDepth: 1 }) + ) + expect(() => + assertExecutionContractAllowsDispatch(nestingContract, { + ...nestingInput, + request: nestingContract.request, + currentMaxSubagentDepth: 0 + }) + ).toThrow(expect.objectContaining({ code: 'subagent_depth_exceeded' })) + }) + + it('validates canonical workspace paths independently of the replay host platform', () => { + const stored = JSON.parse(JSON.stringify(buildExecutionContract(buildInput()))) + stored.ceilings.workspace = { kind: 'path', path: 'C:\\workspace\\project\\' } + stored.provenance.internalExecutionPolicyHash = hashJsonData(stored.ceilings) + const { contractHash: _, ...draft } = stored + stored.contractHash = hashJsonData(draft) + + expect(isDeepChatExecutionContract(stored)).toBe(true) + + const taskContext = buildTaskContext({ workspace: 'C:/workspace/project/' }) + const contract = buildExecutionContract( + buildInput({ + tools: [agentTool('read')], + workspace: { kind: 'path', path: 'C:/workspace/project/child/' }, + maxSubagentDepth: 0, + taskContractContext: taskContext + }) + ) + expect(contract.ceilings.workspace).toEqual({ + kind: 'path', + path: 'C:\\workspace\\project\\child\\' + }) + expect(() => + assertExecutionContractAllowsDispatch(contract, { + request: contract.request, + currentTool: agentTool('read'), + currentWorkspace: { kind: 'path', path: 'C:/workspace/project/child/' }, + currentMaxSubagentDepth: 0, + requestedSubagentDepth: 0 + }) + ).not.toThrow() + expect(() => + buildExecutionContract( + buildInput({ + tools: [agentTool('read')], + workspace: { kind: 'path', path: 'C:/workspace/other/' }, + maxSubagentDepth: 0, + taskContractContext: taskContext + }) + ) + ).toThrow(/workspace ceiling/u) + }) +}) diff --git a/test/main/tape/taskContract.test.ts b/test/main/tape/taskContract.test.ts new file mode 100644 index 000000000..ab63af70e --- /dev/null +++ b/test/main/tape/taskContract.test.ts @@ -0,0 +1,300 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { + MAX_TASK_CONTRACT_REQUIREMENTS, + type DeepChatHandoffFormatRequirement +} from '@shared/types/task-contract' +import { + TaskContractError, + buildTaskContract, + isDeepChatTaskContract, + restoreTaskContract, + restoreTaskContractRef, + serializeTaskContract, + serializeTaskContractRef, + type BuildTaskContractInput +} from '@/tape/domain/taskContract' + +const TEST_WORKSPACE_PATH = path.resolve('project scope ') + +function buildInput(overrides: Partial = {}): BuildTaskContractInput { + return { + delegationId: 'delegation-1', + turnId: 'turn-1', + turnSeq: 1, + turnKind: 'initial', + parentSessionId: 'parent-1', + slotId: 'reviewer', + targetAgentId: 'agent-1', + title: 'Review boundaries', + prompt: 'Inspect the contract boundary.', + workspace: { kind: 'path', path: TEST_WORKSPACE_PATH }, + handoffFormat: [ + { + id: 'sections', + kind: 'required_sections', + level: 2, + sections: ['Validation', 'Handoff'] + }, + { + id: 'details', + kind: 'required_sections', + level: 2, + sections: ['Evidence', 'Result'] + } + ], + predecessorEvaluationRef: null, + maxToolEffect: 'write', + maxSubagentDepth: 0, + ...overrides + } +} + +describe('TaskContract domain', () => { + it('canonicalizes semantically unordered inputs into one immutable identity', () => { + const first = buildTaskContract(buildInput()) + const second = buildTaskContract( + buildInput({ + handoffFormat: [ + { + id: 'details', + kind: 'required_sections', + level: 2, + sections: ['Result', 'Evidence'] + }, + { + id: 'sections', + kind: 'required_sections', + level: 2, + sections: ['Handoff', 'Validation'] + } + ] + }) + ) + + expect(first).toEqual(second) + expect(first.contractHash).toMatch(/^[0-9a-f]{64}$/u) + expect(first.taskHarness.acceptance.map((requirement) => requirement.id)).toEqual([ + 'details', + 'sections' + ]) + expect(first.taskHarness.ceilings.workspace).toEqual({ + kind: 'path', + path: TEST_WORKSPACE_PATH + }) + expect(Object.isFrozen(first)).toBe(true) + expect(Object.isFrozen(first.taskHarness.acceptance)).toBe(true) + expect(isDeepChatTaskContract(JSON.parse(serializeTaskContract(first)))).toBe(true) + }) + + it('preserves the persisted v1 identity for required Handoff sections', () => { + const contract = buildTaskContract({ + delegationId: 'delegation-golden', + turnId: 'turn-golden', + turnSeq: 1, + turnKind: 'initial', + parentSessionId: 'parent-golden', + slotId: 'reviewer', + targetAgentId: 'deepchat', + title: 'Review format', + prompt: 'Return the fixed Handoff.', + workspace: { kind: 'runtime_default' }, + handoffFormat: [ + { + id: 'sections', + kind: 'required_sections', + level: 2, + sections: ['Validation', 'Handoff'] + } + ], + predecessorEvaluationRef: null, + maxToolEffect: 'write', + maxSubagentDepth: 0 + }) + const persisted = JSON.parse(serializeTaskContract(contract)) + + expect(persisted).toEqual({ + schemaVersion: 1, + hashVersion: 1, + taskSchema: { + input: { kind: 'text', maxBytes: 64 * 1024 }, + output: { kind: 'markdown' } + }, + taskConfig: { + completionMode: 'single_response', + retryMode: 'parent_follow_up', + creationReason: 'delegation_created', + predecessorEvaluationRef: null + }, + taskDescription: { + delegationId: 'delegation-golden', + turnId: 'turn-golden', + turnSeq: 1, + turnKind: 'initial', + parentSessionId: 'parent-golden', + slotId: 'reviewer', + targetAgentId: 'deepchat', + title: 'Review format', + prompt: 'Return the fixed Handoff.' + }, + taskHarness: { + acceptance: [ + { + id: 'sections', + kind: 'required_sections', + level: 2, + sections: ['Handoff', 'Validation'] + } + ], + ceilings: { + maxToolEffect: 'write', + workspace: { kind: 'runtime_default' }, + maxSubagentDepth: 0 + } + }, + contractHash: 'ed681c28bfaf7a4aa788a4ebfe9b75674f35cdd76d0702b2997dcb3820b76d76' + }) + expect(restoreTaskContract(persisted)).toEqual(contract) + }) + + it('detects content and hash tampering during recovery', () => { + const contract = buildTaskContract(buildInput()) + const tampered = { + ...contract, + taskDescription: { ...contract.taskDescription, title: 'Different task' } + } + + expect(isDeepChatTaskContract(tampered)).toBe(false) + expect(restoreTaskContract(tampered)).toBeNull() + expect(restoreTaskContract(JSON.parse(JSON.stringify(contract)))).toEqual(contract) + expect(() => + buildTaskContract(buildInput({ creationReason: 'unknown' as 'delegation_created' })) + ).toThrow(/creationReason is invalid/u) + }) + + it('keeps canonical workspace paths portable across host platforms', () => { + const contract = buildTaskContract( + buildInput({ workspace: { kind: 'path', path: 'C:/workspace/project/' } }) + ) + + expect(contract.taskHarness.ceilings.workspace).toEqual({ + kind: 'path', + path: 'C:\\workspace\\project\\' + }) + expect(restoreTaskContract(JSON.parse(serializeTaskContract(contract)))).toEqual(contract) + }) + + it('rejects predecessor evaluations from another parent Session', () => { + const predecessorEvaluationRef = { + schemaVersion: 1 as const, + sessionId: 'parent-1', + tapeIdentity: 'a'.repeat(64), + entryId: 3, + evaluationHash: 'b'.repeat(64) + } + + expect( + buildTaskContract(buildInput({ turnKind: 'follow_up', turnSeq: 2, predecessorEvaluationRef })) + .taskConfig.predecessorEvaluationRef + ).toEqual(predecessorEvaluationRef) + expect(() => + buildTaskContract( + buildInput({ + turnKind: 'follow_up', + turnSeq: 2, + parentSessionId: 'different-parent', + predecessorEvaluationRef + }) + ) + ).toThrow(/must belong to the parent Session/u) + }) + + it('rejects duplicate sections and bounded-input overflow', () => { + expect(() => + buildTaskContract( + buildInput({ + handoffFormat: [ + { + id: 'sections', + kind: 'required_sections', + level: 2, + sections: ['Handoff', 'handoff'] + } + ] + }) + ) + ).toThrow(TaskContractError) + expect(() => + buildTaskContract( + buildInput({ + handoffFormat: Array.from( + { length: MAX_TASK_CONTRACT_REQUIREMENTS + 1 }, + (_, index): DeepChatHandoffFormatRequirement => ({ + id: `section-${index}`, + kind: 'required_sections', + level: 2, + sections: [`Section ${index}`] + }) + ) + }) + ) + ).toThrow(/exceeds 64 requirements/u) + }) + + it('serializes only complete, normalized physical references', () => { + expect( + JSON.parse( + serializeTaskContractRef({ + schemaVersion: 1, + sessionId: 'parent-1', + tapeIdentity: 'a'.repeat(64), + entryId: 3, + contractHash: 'b'.repeat(64) + }) + ) + ).toEqual({ + schemaVersion: 1, + sessionId: 'parent-1', + tapeIdentity: 'a'.repeat(64), + entryId: 3, + contractHash: 'b'.repeat(64) + }) + expect(() => + serializeTaskContractRef({ + schemaVersion: 1, + sessionId: ' parent-1', + tapeIdentity: 'a'.repeat(64), + entryId: 3, + contractHash: 'b'.repeat(64) + }) + ).toThrow(/invalid/u) + expect( + restoreTaskContractRef({ + schemaVersion: 1, + sessionId: ' parent-1', + tapeIdentity: 'a'.repeat(64), + entryId: 3, + contractHash: 'b'.repeat(64) + }) + ).toBeNull() + expect( + restoreTaskContractRef({ + schemaVersion: 1, + sessionId: 'parent-1', + tapeIdentity: 'a'.repeat(64), + entryId: 3, + contractHash: 'b'.repeat(64), + unexpected: true + }) + ).toBeNull() + }) + + it('keeps canonical identity fields within their persisted character bounds', () => { + expect(() => buildTaskContract(buildInput({ delegationId: 'd'.repeat(257) }))).toThrow( + TaskContractError + ) + expect(() => buildTaskContract(buildInput({ title: 't'.repeat(161) }))).toThrow( + TaskContractError + ) + }) +}) diff --git a/test/main/tape/taskContractPersistence.test.ts b/test/main/tape/taskContractPersistence.test.ts new file mode 100644 index 000000000..9fc4737ce --- /dev/null +++ b/test/main/tape/taskContractPersistence.test.ts @@ -0,0 +1,217 @@ +import { expect, it } from 'vitest' +import { Database, nativeSqliteItIf } from '../nativeSqliteHarness' +import { buildTaskContract } from '@/tape/domain/taskContract' +import { buildTaskEvaluation } from '@/tape/domain/taskEvaluation' +import { buildEffectiveTapeView } from '@/tape/domain/effectiveView' + +const tapeStoreModule = Database + ? await import('@/tape/infrastructure/sqlite/tapeEntryStore').catch(() => null) + : null +const serviceModule = Database + ? await import('@/tape/application/taskContractService').catch(() => null) + : null +const evaluationServiceModule = Database + ? await import('@/tape/application/taskEvaluationService').catch(() => null) + : null + +const DatabaseCtor = Database! +const DeepChatTapeEntriesTableCtor = tapeStoreModule?.DeepChatTapeEntriesTable! +const DeepChatContractStoreCtor = tapeStoreModule?.DeepChatContractStore! +const TaskContractServiceCtor = serviceModule?.TaskContractService! +const TaskEvaluationServiceCtor = evaluationServiceModule?.TaskEvaluationService! +const itIfSqlite = nativeSqliteItIf( + Boolean( + DeepChatTapeEntriesTableCtor && + DeepChatContractStoreCtor && + TaskContractServiceCtor && + TaskEvaluationServiceCtor + ), + 'TaskContract persistence modules are unavailable' +) + +function contract(title = 'Review boundaries') { + return buildTaskContract({ + delegationId: 'delegation-1', + turnId: 'turn-1', + turnSeq: 1, + turnKind: 'initial', + parentSessionId: 'parent-1', + slotId: 'reviewer', + targetAgentId: 'agent-1', + title, + prompt: 'Inspect the contract boundary.', + workspace: { kind: 'runtime_default' }, + handoffFormat: [ + { + id: 'sections', + kind: 'required_sections', + level: 2, + sections: ['Handoff'] + } + ] + }) +} + +itIfSqlite( + 'keeps contract facts strict, transaction-aware, and out of default Context Views', + () => { + const db = new DatabaseCtor(':memory:') + try { + const genericStore = new DeepChatTapeEntriesTableCtor(db) + genericStore.createTable() + const contractStore = new DeepChatContractStoreCtor(db) + const service = new TaskContractServiceCtor(() => contractStore) + const evaluationService = new TaskEvaluationServiceCtor(() => contractStore) + + expect(() => + genericStore.appendEvent({ + sessionId: 'parent-1', + name: 'contract/task_frozen', + data: {} + }) + ).toThrow(/reserved for the strict Contract writer/u) + expect(() => + contractStore.append({ + sessionId: 'parent-1', + kind: 'event', + name: 'execution/run_started', + payload: {} + }) + ).toThrow(/reserved for the strict Execution Journal writer/u) + expect('appendExecutionJournalEvent' in contractStore).toBe(false) + expect(() => + service.freezeParentTaskContract({ parentSessionId: 'parent-1', contract: contract() }) + ).toThrow(/requires the live-delegation host transaction/u) + + expect(() => + contractStore.runInTransaction(() => { + service.freezeParentTaskContract({ parentSessionId: 'parent-1', contract: contract() }) + throw new Error('roll back host mutation') + }) + ).toThrow('roll back host mutation') + expect(contractStore.getBySession('parent-1')).toEqual([]) + + const first = contractStore.runInTransaction(() => + service.freezeParentTaskContract({ + parentSessionId: 'parent-1', + contract: contract(), + createdAt: 100 + }) + ) + const retry = contractStore.runInTransaction(() => + service.freezeParentTaskContract({ + parentSessionId: 'parent-1', + contract: contract(), + createdAt: 200 + }) + ) + expect(first).toMatchObject({ created: true, ref: { sessionId: 'parent-1', entryId: 2 } }) + expect(retry).toMatchObject({ created: false, ref: first.ref }) + expect(() => + contractStore.runInTransaction(() => + service.freezeParentTaskContract({ + parentSessionId: 'parent-1', + contract: contract('Conflicting title') + }) + ) + ).toThrow(/conflicts with turn turn-1/u) + + const evaluation = buildTaskEvaluation({ + contract: contract(), + executionStatus: 'completed', + candidateResult: '## Handoff\nDone.' + }) + expect(() => + evaluationService.commitTaskEvaluation({ + parentSessionId: 'parent-1', + turnSeq: 1, + evaluation, + taskContractRef: first.ref + }) + ).toThrow(/requires the live-delegation host transaction/u) + expect(() => + contractStore.runInTransaction(() => + evaluationService.commitTaskEvaluation({ + parentSessionId: 'parent-1', + turnSeq: 1, + evaluation, + taskContractRef: { ...first.ref, entryId: first.ref.entryId + 1 } + }) + ) + ).toThrow(/TaskContract reference does not resolve/u) + const evaluated = contractStore.runInTransaction(() => + evaluationService.commitTaskEvaluation({ + parentSessionId: 'parent-1', + turnSeq: 1, + evaluation, + taskContractRef: first.ref, + createdAt: 300 + }) + ) + const evaluatedRetry = contractStore.runInTransaction(() => + evaluationService.commitTaskEvaluation({ + parentSessionId: 'parent-1', + turnSeq: 1, + evaluation, + taskContractRef: first.ref, + createdAt: 400 + }) + ) + expect(evaluated).toMatchObject({ + created: true, + ref: { sessionId: 'parent-1', entryId: 3, evaluationHash: evaluation.evaluationHash } + }) + expect(evaluatedRetry).toMatchObject({ created: false, ref: evaluated.ref }) + expect(() => + contractStore.runInTransaction(() => + evaluationService.commitTaskEvaluation({ + parentSessionId: 'parent-1', + turnSeq: 1, + evaluation: buildTaskEvaluation({ + contract: contract(), + executionStatus: 'failed', + candidateResult: '## Handoff\nDone.' + }), + taskContractRef: first.ref + }) + ) + ).toThrow(/conflicts with turn turn-1/u) + + const rows = contractStore.getBySession('parent-1') + expect(rows.filter((row) => row.name === 'contract/task_frozen')).toHaveLength(1) + expect(rows.filter((row) => row.name === 'contract/evaluated')).toHaveLength(1) + const defaultViewNames = buildEffectiveTapeView(rows).rows.map((row) => row.name) + expect(defaultViewNames).not.toContain('contract/task_frozen') + expect(defaultViewNames).not.toContain('contract/evaluated') + expect( + buildEffectiveTapeView(rows, { includeAuditEvents: true }).rows.map((row) => row.name) + ).toEqual(expect.arrayContaining(['contract/task_frozen', 'contract/evaluated'])) + + db.prepare( + `INSERT INTO deepchat_tape_entries ( + session_id, entry_id, kind, name, payload_json, meta_json, created_at + ) VALUES ('parent-1', 4, 'event', 'contract/future_fact', + '{"marker":"future-contract-marker"}', '{}', 500)` + ).run() + const rowsWithFutureFact = contractStore.getBySession('parent-1') + expect(buildEffectiveTapeView(rowsWithFutureFact).rows.map((row) => row.name)).not.toContain( + 'contract/future_fact' + ) + expect( + contractStore.searchEffectiveSourcesAtHeads( + [{ sessionId: 'parent-1', maxEntryId: 4 }], + 'future-contract-marker' + ) + ).toEqual([]) + expect( + contractStore.getEffectiveContextRowsAtHead({ sessionId: 'parent-1', maxEntryId: 4 }, [4], { + before: 0, + after: 0, + limit: 10 + }) + ).toEqual([]) + } finally { + db.close() + } + } +) diff --git a/test/main/tape/taskEvaluation.test.ts b/test/main/tape/taskEvaluation.test.ts new file mode 100644 index 000000000..ed756444e --- /dev/null +++ b/test/main/tape/taskEvaluation.test.ts @@ -0,0 +1,387 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { + DEEPCHAT_TASK_EVALUATION_REASON_CODES, + DeepChatTaskEvaluationProjectionSchema, + DeepChatTaskEvaluationSummarySchema, + MAX_TASK_EVALUATION_CANDIDATE_BYTES, + type DeepChatLegacyTaskEvaluation, + type DeepChatHandoffFormatRequirement, + type DeepChatTaskEvaluationExecutionStatus +} from '@shared/types/task-contract' +import { hashJsonData } from '@/tape/domain/canonicalJson' +import { buildTaskContract } from '@/tape/domain/taskContract' +import { + buildTaskEvaluation, + projectTaskEvaluationSummary, + restoreStoredTaskEvaluation, + restoreTaskEvaluation, + serializeTaskEvaluation +} from '@/tape/domain/taskEvaluation' + +const PREVIOUS_HEAD_EVALUATION_JSON = + '{"candidate":{"kind":"answer","sha256":"ddc3016ae0a6c8cee3ad58eb31c7b2dd5ce301adebb35d7118083625458d513e","utf8Bytes":39},"disposition":"accepted","evaluationHash":"9733f212f7a14b8330797eac534eed15fd5b329ad1e313fc55813aa5d64190ad","evaluatorVersion":"task-contract-v1","executionStatus":"completed","hashVersion":1,"omittedRecordCount":0,"reasonCodes":[],"records":[{"additionalEvidenceCount":0,"code":"required_sections_present","instancePath":null,"keyword":null,"outcome":"passed","requirementId":"sections","requirementKind":"required_sections","section":null}],"schemaVersion":1,"taskContractHash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","turnId":"turn-1","verdict":"passed"}' + +const DEFAULT_HANDOFF_FORMAT: readonly DeepChatHandoffFormatRequirement[] = [ + { + id: 'sections', + kind: 'required_sections', + level: 2, + sections: ['Handoff', 'Validation'] + } +] + +function createContract( + handoffFormat: readonly DeepChatHandoffFormatRequirement[] = DEFAULT_HANDOFF_FORMAT +) { + return buildTaskContract({ + delegationId: 'delegation-1', + turnId: 'turn-1', + turnSeq: 1, + turnKind: 'initial', + parentSessionId: 'parent-1', + slotId: 'reviewer', + targetAgentId: 'agent-1', + title: 'Review boundaries', + prompt: 'Inspect the contract boundary.', + workspace: { kind: 'path', path: path.resolve('project') }, + handoffFormat, + predecessorEvaluationRef: null, + maxToolEffect: 'write', + maxSubagentDepth: 0 + }) +} + +function evaluate( + candidateResult: string | null, + executionStatus: DeepChatTaskEvaluationExecutionStatus = 'completed', + handoffFormat: readonly DeepChatHandoffFormatRequirement[] = DEFAULT_HANDOFF_FORMAT +) { + return buildTaskEvaluation({ + contract: createContract(handoffFormat), + executionStatus, + candidateResult + }) +} + +function finalizeLegacyEvaluation( + draft: Omit +): DeepChatLegacyTaskEvaluation { + return { ...draft, evaluationHash: hashJsonData(draft) } +} + +describe('Task evaluation domain', () => { + it('validates required Handoff sections without treating their contents as task success', () => { + const candidate = [ + '```markdown', + '## Handoff', + 'This heading is fenced and must not count.', + '```', + '## Handoff', + 'This claim is untrusted task evidence.', + '## Validation', + 'The required section has a body.' + ].join('\n') + + const first = evaluate(candidate) + const second = evaluate(candidate) + + expect(first).toEqual(second) + expect(first).toMatchObject({ + schemaVersion: 2, + hashVersion: 1, + evaluatorVersion: 'handoff-format-v1', + evaluationKind: 'handoff_format', + formatStatus: 'valid', + reasonCodes: [], + records: [{ requirementId: 'sections', code: 'required_sections_present', outcome: 'valid' }] + }) + expect(first).not.toHaveProperty('verdict') + expect(first).not.toHaveProperty('disposition') + expect(first.evaluationHash).toMatch(/^[0-9a-f]{64}$/u) + expect(Object.isFrozen(first)).toBe(true) + expect(restoreTaskEvaluation(JSON.parse(serializeTaskEvaluation(first)))).toEqual(first) + + const mutableRef = { + schemaVersion: 1, + sessionId: 'parent-1', + tapeIdentity: 'a'.repeat(64), + entryId: 3, + evaluationHash: first.evaluationHash + } as const + const summary = projectTaskEvaluationSummary(first, mutableRef) + expect(summary).toMatchObject({ + evaluationKind: 'handoff_format', + formatStatus: 'valid', + evidence: [], + omittedEvidenceCount: 0 + }) + expect(summary.evaluationRef).not.toBe(mutableRef) + expect(Object.isFrozen(mutableRef)).toBe(false) + }) + + it('reads previous-head evaluations without admitting them to the current writer', () => { + const legacy = JSON.parse(PREVIOUS_HEAD_EVALUATION_JSON) as DeepChatLegacyTaskEvaluation + const restored = restoreStoredTaskEvaluation(legacy) + + expect(restoreTaskEvaluation(legacy)).toBeNull() + expect(restored).toEqual(legacy) + expect(Object.isFrozen(restored)).toBe(true) + expect(Object.isFrozen(restored?.records)).toBe(true) + + const summary = projectTaskEvaluationSummary(legacy, { + schemaVersion: 1, + sessionId: 'parent-1', + tapeIdentity: 'b'.repeat(64), + entryId: 5, + evaluationHash: legacy.evaluationHash + }) + expect(summary).toEqual({ + evaluationKind: 'handoff_format', + formatStatus: 'valid', + reasonCodes: [], + candidate: legacy.candidate, + evidence: [], + evaluationRef: { + schemaVersion: 1, + sessionId: 'parent-1', + tapeIdentity: 'b'.repeat(64), + entryId: 5, + evaluationHash: legacy.evaluationHash + }, + omittedEvidenceCount: 0 + }) + expect(summary).not.toHaveProperty('verdict') + expect(summary).not.toHaveProperty('disposition') + }) + + it('projects failed and indeterminate previous-head evidence into current summaries', () => { + const legacy = JSON.parse(PREVIOUS_HEAD_EVALUATION_JSON) as DeepChatLegacyTaskEvaluation + const { evaluationHash: _evaluationHash, ...legacyDraft } = legacy + const failed = finalizeLegacyEvaluation({ + ...legacyDraft, + verdict: 'failed', + disposition: 'parked', + reasonCodes: ['required_sections_missing'], + records: [ + { + ...legacy.records[0], + outcome: 'failed', + code: 'required_sections_missing', + section: 'Validation' + } + ] + }) + const indeterminate = finalizeLegacyEvaluation({ + ...legacyDraft, + executionStatus: 'cancelled', + verdict: 'indeterminate', + disposition: 'parked', + reasonCodes: ['execution_cancelled'], + records: [ + { + requirementId: null, + requirementKind: null, + outcome: 'indeterminate', + code: 'execution_cancelled', + section: null, + instancePath: null, + keyword: null, + additionalEvidenceCount: 0 + } + ] + }) + + expect( + projectTaskEvaluationSummary(failed, { + schemaVersion: 1, + sessionId: 'parent-1', + tapeIdentity: 'b'.repeat(64), + entryId: 6, + evaluationHash: failed.evaluationHash + }) + ).toMatchObject({ + formatStatus: 'invalid', + reasonCodes: ['required_sections_missing'], + evidence: [{ outcome: 'invalid', code: 'required_sections_missing' }] + }) + expect( + projectTaskEvaluationSummary(indeterminate, { + schemaVersion: 1, + sessionId: 'parent-1', + tapeIdentity: 'b'.repeat(64), + entryId: 7, + evaluationHash: indeterminate.evaluationHash + }) + ).toMatchObject({ + formatStatus: 'indeterminate', + reasonCodes: ['execution_cancelled'], + evidence: [{ outcome: 'indeterminate', code: 'execution_cancelled' }] + }) + }) + + it('enforces previous writer state and rejects dormant result-schema evaluations', () => { + const legacy = JSON.parse(PREVIOUS_HEAD_EVALUATION_JSON) as DeepChatLegacyTaskEvaluation + expect(restoreStoredTaskEvaluation({ ...legacy, verdict: 'failed' })).toBeNull() + + const { evaluationHash: _impossibleHash, ...impossibleDraft } = legacy + const impossibleEvaluation = finalizeLegacyEvaluation({ + ...impossibleDraft, + candidate: { kind: 'absent' } + }) + expect(restoreStoredTaskEvaluation(impossibleEvaluation)).toBeNull() + const candidateMissingEvaluation = finalizeLegacyEvaluation({ + ...impossibleDraft, + candidate: { kind: 'absent' }, + verdict: 'indeterminate', + disposition: 'parked', + reasonCodes: ['candidate_missing'], + records: [ + { + requirementId: null, + requirementKind: null, + outcome: 'indeterminate', + code: 'candidate_missing', + section: null, + instancePath: null, + keyword: null, + additionalEvidenceCount: 0 + } + ] + }) + expect(restoreStoredTaskEvaluation(candidateMissingEvaluation)).toEqual( + candidateMissingEvaluation + ) + + const { evaluationHash: _evaluationHash, ...legacyDraft } = legacy + const resultSchemaDraft = { + ...legacyDraft, + records: [ + { + ...legacy.records[0], + requirementKind: 'result_schema' as const, + code: 'result_schema_valid' as const + } + ] + } + const resultSchemaEvaluation = { + ...resultSchemaDraft, + evaluationHash: hashJsonData(resultSchemaDraft) + } + + expect(restoreStoredTaskEvaluation(resultSchemaEvaluation)).toBeNull() + }) + + it('reports every missing section as bounded format evidence', () => { + const result = evaluate(['## Handoff', 'Review complete.'].join('\n'), 'completed') + + expect(result).toMatchObject({ + formatStatus: 'invalid', + reasonCodes: ['required_sections_missing'] + }) + expect(result.records).toEqual([ + expect.objectContaining({ + requirementId: 'sections', + code: 'required_sections_missing', + section: 'Validation' + }) + ]) + expect( + projectTaskEvaluationSummary( + result, + { + schemaVersion: 1, + sessionId: 'parent-1', + tapeIdentity: 'b'.repeat(64), + entryId: 4, + evaluationHash: result.evaluationHash + }, + 1 + ) + ).toMatchObject({ + evidence: [expect.objectContaining({ requirementId: 'sections' })], + omittedEvidenceCount: 0 + }) + }) + + it('keeps format validity independent from execution failure', () => { + const result = evaluate('## Handoff\nDone.\n## Validation\nChecked.', 'failed') + + expect(result).toMatchObject({ + executionStatus: 'failed', + formatStatus: 'valid', + reasonCodes: [] + }) + }) + + it.each([ + { + name: 'a missing section', + candidate: '## Handoff\nDone.', + section: 'Validation' + }, + { + name: 'an empty section', + candidate: '## Handoff\nDone.\n## Validation\n\n', + section: 'Validation' + } + ])('marks a completed candidate invalid with $name', ({ candidate, section }) => { + const result = evaluate(candidate) + + expect(result).toMatchObject({ formatStatus: 'invalid' }) + expect(result.records[0]).toMatchObject({ code: 'required_sections_missing', section }) + }) + + it.each([ + { status: 'cancelled' as const, candidate: 'answer', code: 'execution_cancelled' }, + { status: 'interrupted' as const, candidate: 'answer', code: 'execution_interrupted' }, + { status: 'completed' as const, candidate: null, code: 'candidate_missing' }, + { + status: 'completed' as const, + candidate: 'x'.repeat(MAX_TASK_EVALUATION_CANDIDATE_BYTES + 1), + code: 'candidate_too_large' + } + ])('records $code as indeterminate', ({ status, candidate, code }) => { + expect(evaluate(candidate, status)).toMatchObject({ + formatStatus: 'indeterminate', + reasonCodes: [code], + records: [{ code, outcome: 'indeterminate' }] + }) + }) + + it('rejects hash-valid projections that violate canonical reason evidence', () => { + const evaluation = evaluate('## Handoff\nDone.\n## Validation\nChecked.') + const { evaluationHash: _evaluationHash, ...draft } = evaluation + const forgedDraft = { + ...draft, + formatStatus: 'invalid' as const, + reasonCodes: ['candidate_missing' as const] + } + const forged = { ...forgedDraft, evaluationHash: hashJsonData(forgedDraft) } + + expect(restoreTaskEvaluation(forged)).toBeNull() + }) + + it('bounds reason-code arrays in full and parent-facing projections', () => { + const evaluation = evaluate(null) + const evaluationRef = { + schemaVersion: 1 as const, + sessionId: 'parent-1', + tapeIdentity: 'a'.repeat(64), + entryId: 1, + evaluationHash: evaluation.evaluationHash + } + const summary = projectTaskEvaluationSummary(evaluation, evaluationRef) + const reasonCodes = Array.from( + { length: DEEPCHAT_TASK_EVALUATION_REASON_CODES.length + 1 }, + () => 'candidate_missing' as const + ) + + expect( + DeepChatTaskEvaluationProjectionSchema.safeParse({ ...evaluation, reasonCodes }).success + ).toBe(false) + expect(DeepChatTaskEvaluationSummarySchema.safeParse({ ...summary, reasonCodes }).success).toBe( + false + ) + }) +}) diff --git a/test/main/tool/agentTools/agentToolDependencies.ts b/test/main/tool/agentTools/agentToolDependencies.ts index 4065bc0fd..c928b7d60 100644 --- a/test/main/tool/agentTools/agentToolDependencies.ts +++ b/test/main/tool/agentTools/agentToolDependencies.ts @@ -10,7 +10,9 @@ export const createAgentToolDependencies = ( resolveConversationWorkdir: overrides.resolveConversationWorkdir ?? vi.fn().mockResolvedValue(null), resolveConversationSessionInfo: - overrides.resolveConversationSessionInfo ?? vi.fn().mockResolvedValue(null) + overrides.resolveConversationSessionInfo ?? vi.fn().mockResolvedValue(null), + resolveConversationExecutionAuthority: + overrides.resolveConversationExecutionAuthority ?? vi.fn().mockResolvedValue(null) }, tape: { getTapeInfo: overrides.getTapeInfo ?? vi.fn(), diff --git a/test/main/tool/toolService.test.ts b/test/main/tool/toolService.test.ts index 9b9372e01..b147c98a2 100644 --- a/test/main/tool/toolService.test.ts +++ b/test/main/tool/toolService.test.ts @@ -22,6 +22,8 @@ import { import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' import { parseChildAgentResultEnvelope } from '@shared/orchestration/resultSafety' import { LiveDelegationConsentAuthority } from '@/orchestration/liveDelegationConsent' +import { createOpaquePromptAssembly } from '@/agent/deepchat/resources/promptAssembly' +import { buildExecutionContract } from '@/tape/domain/executionContract' vi.mock('electron', () => ({ app: { @@ -66,6 +68,55 @@ const buildAgentToolRuntimeMock = (overrides: Record = {}) => ...overrides }) +const CONTRACT_RUN_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + +const buildContractMcpDefinition = (name = 'remote_read'): MCPToolDefinition => { + const definition = buildToolDefinition(name, 'remote') + return { + ...definition, + source: 'mcp', + server: { + ...definition.server, + bindingHash: 'a'.repeat(64) + } + } +} + +const buildToolExecutionContract = ( + tool: MCPToolDefinition, + workspace: string | null = '/workspace', + maxSubagentDepth = 0 +) => { + const promptAssembly = createOpaquePromptAssembly('System prompt') + return buildExecutionContract({ + request: { + sessionId: 'session-1', + messageId: 'message-1', + runId: CONTRACT_RUN_ID, + requestSeq: 1 + }, + promptAssembly, + providerMessages: [ + { role: 'system', content: promptAssembly.prompt }, + { role: 'user', content: 'Use the tool' } + ], + tools: [tool], + providerId: 'provider-1', + modelId: 'model-1', + modelConfig: { contextLength: 1_000 } as any, + temperature: 0.2, + maxTokens: 100, + workspace: workspace ? { kind: 'path', path: workspace } : { kind: 'runtime_default' }, + maxSubagentDepth, + dynamicControlSnapshot: { + permissionMode: 'default', + requestAdmitted: true, + cancellationRequested: false + }, + assemblerVersion: 'test-v1' + }) +} + const cronJobFixture = { id: 'job-1', name: 'Daily summary', @@ -127,6 +178,260 @@ const cronJobRunFixture = { } as any describe('ToolService', () => { + it('meets the frozen execution contract with current authority at dispatch', async () => { + const definition = buildContractMcpDefinition() + const resolveConversationExecutionAuthority = vi.fn(async (sessionId: string) => ({ + sessionId, + agentId: 'agent-1', + projectDir: '/workspace', + sessionKind: 'regular', + disabledAgentTools: [], + subagentCapability: { + available: false, + reason: 'policy_disabled', + cacheKey: 'unavailable' + } + })) + const mcpService = { + getAllToolDefinitions: vi.fn().mockResolvedValue([definition]), + callTool: vi.fn(async () => ({ + content: 'ok', + rawData: { toolCallId: 'call-1', content: 'ok' } + })) + } as any + const toolService = new ToolService({ + skillSettings: { isEnabled: () => false } as any, + mcpService, + agentSettings: { resolveDeepChatAgentConfig: vi.fn(async () => ({})) } as any, + providerSettings: { getModelConfig: vi.fn() } as any, + settings: { get: vi.fn() }, + commandPermissionHandler: new CommandPermissionService(), + agentTools: buildAgentToolRuntimeMock({ resolveConversationExecutionAuthority }) + }) + await toolService.getAllToolDefinitions({ + chatMode: 'agent', + conversationId: 'session-1', + sessionKind: 'regular' + }) + const executionContract = buildToolExecutionContract(definition) + + await expect( + toolService.callTool( + { + id: 'call-1', + type: 'function', + function: { name: definition.function.name, arguments: '{}' }, + conversationId: 'session-1' + }, + { + runId: CONTRACT_RUN_ID, + messageId: 'message-1', + requestSeq: 1, + executionContract, + permissionMode: 'full_access' + } + ) + ).resolves.toMatchObject({ content: 'ok' }) + expect(resolveConversationExecutionAuthority).toHaveBeenCalledTimes(2) + expect(mcpService.callTool).toHaveBeenCalledOnce() + }) + + it.each([ + { + name: 'workspace change', + authorityUpdate: { projectDir: '/other-workspace' }, + expectedCode: 'workspace_mismatch' + }, + { + name: 'workspace change hidden by trailing whitespace', + authorityUpdate: { projectDir: '/workspace ' }, + expectedCode: 'workspace_mismatch' + }, + { + name: 'MCP server revocation', + authorityUpdate: { enabledMcpServerIds: [] }, + expectedCode: 'tool_not_allowed' + } + ])('rejects current $name before crossing tool dispatch', async (scenario) => { + const definition = buildContractMcpDefinition() + const runtimeSession = { + sessionId: 'session-1', + agentId: 'agent-1', + projectDir: '/workspace', + sessionKind: 'regular', + disabledAgentTools: [], + enabledMcpServerIds: [definition.server.id], + subagentCapability: { available: false, reason: 'policy_disabled', cacheKey: 'off' } + } + const resolveConversationExecutionAuthority = vi + .fn() + .mockResolvedValueOnce(runtimeSession) + .mockResolvedValueOnce({ ...runtimeSession, ...scenario.authorityUpdate }) + const mcpService = { + getAllToolDefinitions: vi.fn().mockResolvedValue([definition]), + callTool: vi.fn() + } as any + const toolService = new ToolService({ + skillSettings: { isEnabled: () => false } as any, + mcpService, + agentSettings: { resolveDeepChatAgentConfig: vi.fn(async () => ({})) } as any, + providerSettings: { getModelConfig: vi.fn() } as any, + settings: { get: vi.fn() }, + commandPermissionHandler: new CommandPermissionService(), + agentTools: buildAgentToolRuntimeMock({ resolveConversationExecutionAuthority }) + }) + await toolService.getAllToolDefinitions({ + chatMode: 'agent', + conversationId: 'session-1', + sessionKind: 'regular' + }) + await expect( + toolService.callTool( + { + id: 'call-1', + type: 'function', + function: { name: definition.function.name, arguments: '{}' }, + conversationId: 'session-1' + }, + { + runId: CONTRACT_RUN_ID, + messageId: 'message-1', + requestSeq: 1, + executionContract: buildToolExecutionContract(definition), + permissionMode: 'full_access' + } + ) + ).rejects.toMatchObject({ code: scenario.expectedCode }) + expect(mcpService.callTool).not.toHaveBeenCalled() + }) + + it('rejects an Agent tool disabled by current Session authority', async () => { + const resolveConversationExecutionAuthority = vi.fn(async (sessionId: string) => ({ + sessionId, + agentId: 'agent-1', + projectDir: '/workspace', + sessionKind: 'regular' as const, + disabledAgentTools: ['read'], + subagentCapability: { + available: false as const, + reason: 'policy_disabled' as const, + cacheKey: 'off' + } + })) + const toolService = new ToolService({ + skillSettings: { isEnabled: () => false } as any, + mcpService: { getAllToolDefinitions: vi.fn().mockResolvedValue([]) } as any, + agentSettings: { resolveDeepChatAgentConfig: vi.fn(async () => ({})) } as any, + providerSettings: { getModelConfig: vi.fn() } as any, + settings: { get: vi.fn() }, + commandPermissionHandler: new CommandPermissionService(), + agentTools: buildAgentToolRuntimeMock({ resolveConversationExecutionAuthority }) + }) + const definitions = await toolService.getAllToolDefinitions({ + chatMode: 'agent', + conversationId: 'session-1', + sessionKind: 'regular', + agentWorkspacePath: '/workspace' + }) + const definition = definitions.find((candidate) => candidate.function.name === 'read') + expect(definition).toBeDefined() + + await expect( + toolService.callTool( + { + id: 'call-1', + type: 'function', + function: { name: 'read', arguments: '{}' }, + conversationId: 'session-1' + }, + { + runId: CONTRACT_RUN_ID, + messageId: 'message-1', + requestSeq: 1, + executionContract: buildToolExecutionContract(definition!), + permissionMode: 'full_access' + } + ) + ).rejects.toMatchObject({ code: 'tool_not_allowed' }) + expect(resolveConversationExecutionAuthority).toHaveBeenCalledOnce() + }) + + it('rechecks live delegation authority immediately before Agent dispatch', async () => { + const availableCapability = resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: true, + slots: [ + { + id: 'self', + targetType: 'self', + displayName: 'Self Clone', + description: 'Delegate work.' + } + ] + }) + const baseAuthority = { + sessionId: 'session-1', + agentId: 'agent-1', + projectDir: '/workspace', + sessionKind: 'regular' as const, + disabledAgentTools: [] + } + const resolveConversationExecutionAuthority = vi + .fn() + .mockResolvedValueOnce({ ...baseAuthority, subagentCapability: availableCapability }) + .mockResolvedValueOnce({ + ...baseAuthority, + subagentCapability: { + available: false, + reason: 'policy_disabled', + cacheKey: 'off' + } + }) + const toolService = new ToolService({ + skillSettings: { isEnabled: () => false } as any, + mcpService: { getAllToolDefinitions: vi.fn().mockResolvedValue([]) } as any, + agentSettings: { resolveDeepChatAgentConfig: vi.fn(async () => ({})) } as any, + providerSettings: { getModelConfig: vi.fn() } as any, + settings: { get: vi.fn() }, + commandPermissionHandler: new CommandPermissionService(), + agentTools: buildAgentToolRuntimeMock({ resolveConversationExecutionAuthority }) + }) + const definitions = await toolService.getAllToolDefinitions({ + chatMode: 'agent', + conversationId: 'session-1', + sessionKind: 'regular', + agentWorkspacePath: '/workspace', + subagentCapability: availableCapability + }) + const definition = definitions.find( + (candidate) => candidate.function.name === LIVE_DELEGATION_AGENT_TOOL_NAME + ) + expect(definition).toBeDefined() + + await expect( + toolService.callTool( + { + id: 'call-1', + type: 'function', + function: { + name: LIVE_DELEGATION_AGENT_TOOL_NAME, + arguments: JSON.stringify({ operation: 'list' }) + }, + conversationId: 'session-1' + }, + { + runId: CONTRACT_RUN_ID, + messageId: 'message-1', + requestSeq: 1, + executionContract: buildToolExecutionContract(definition!, '/workspace', 1), + permissionMode: 'full_access' + } + ) + ).rejects.toMatchObject({ code: 'subagent_depth_exceeded' }) + expect(resolveConversationExecutionAuthority).toHaveBeenCalledTimes(2) + }) + it('records effect intent before dispatch and blocks execution when it cannot persist', async () => { const order: string[] = [] const effectObserver = {