From b6cd15e479d7d0ed18b13751cd1dd87184be7384 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sat, 8 Aug 2026 20:26:07 +0800 Subject: [PATCH 01/37] docs(tape): specify contract lineage --- .../tape-contract-lineage/plan.md | 114 ++++++++ .../tape-contract-lineage/spec.md | 268 ++++++++++++++++++ .../tape-contract-lineage/tasks.md | 62 ++++ 3 files changed, 444 insertions(+) create mode 100644 docs/architecture/tape-contract-lineage/plan.md create mode 100644 docs/architecture/tape-contract-lineage/spec.md create mode 100644 docs/architecture/tape-contract-lineage/tasks.md diff --git a/docs/architecture/tape-contract-lineage/plan.md b/docs/architecture/tape-contract-lineage/plan.md new file mode 100644 index 000000000..4f1919670 --- /dev/null +++ b/docs/architecture/tape-contract-lineage/plan.md @@ -0,0 +1,114 @@ +# Tape Contract Lineage Implementation Plan + +## 1. Establish Canonical Contract Domains + +- Add shared, bounded schemas for prompt-section provenance, TaskContract, TaskContract references, + ExecutionContract, evaluation, verdict, and disposition. +- Add main-process canonical builders and versioned hashes using the existing canonical JSON helper. +- Add Ajv as a direct runtime dependency for bounded local result-schema validation; disable remote + loading, `$ref`, custom executable formats, and unbounded error collection. +- 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 ViewManifest writes to schema 5 and the next hash version while preserving v1-v4 readers. +- Include full ExecutionContract content in `view/assembled`; reference the TaskContract by durable + local/origin identity where present. +- Keep interactive writes fail-open with explicit degradation and make contract-bearing 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. +- Validate stable tool target, reviewed effect class, workspace scope, and nesting ceiling before + crossing ToolService dispatch. +- Retain existing live permission, workdir, deletion, and Subagent-authority checks as the current + runtime side of the meet. +- 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 + acceptance, 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. +- Validate bounded local JSON Schema without remote references or code execution. +- Create `passed`, `failed`, or `indeterminate` evaluation with bounded evidence and reason codes. +- 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`, `verdict=failed`, `disposition=parked`, and + `delegationStatus=idle` for contract-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. +- Do not push the branch. diff --git a/docs/architecture/tape-contract-lineage/spec.md b/docs/architecture/tape-contract-lineage/spec.md new file mode 100644 index 000000000..be4767995 --- /dev/null +++ b/docs/architecture/tape-contract-lineage/spec.md @@ -0,0 +1,268 @@ +# Tape Contract Lineage Specification + +## Status + +Proposed for implementation. This architecture extends DeepChat's existing Tape, provider View, +and live-delegation execution planes with explicit task and execution contracts. It does not add a +second scheduler or make Tape an online permission service. + +Last reviewed: 2026-08-08. + +## 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 acceptance 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 valid child result from an execution that +completed but failed its task contract. It 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 DeepChat-owned provider 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 verdict and disposition 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`: acceptance requirements and host-enforced behavioral ceilings. + +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. + +V1 supports two acceptance requirement kinds: + +- `required_sections`: required level-two Markdown section names; +- `result_schema`: a bounded JSON Schema applied to the body of a named Markdown section. + +`result_schema` accepts one JSON value after removing at most one enclosing Markdown code fence. +It uses Ajv strict validation with remote loading disabled, rejects every `$ref`, and stops after a +bounded error set. It does not execute custom formats or schema-provided code. + +Requirements compose conjunctively. A missing required section or schema mismatch is `failed`. +Missing candidate data, cancellation, interruption, unavailable evidence, or evaluator failure is +`indeterminate`. + +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 acceptance requirements; +- one embedded result schema: 32 KiB; +- 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, workspace scope, 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. + +### 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; +- workspace changes must remain within both the frozen and current scopes; +- dynamic controls use the current runtime value and are not frozen ceilings. + +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, contract verdict, and consumer disposition are independent axes: + +```text +executionStatus = completed | failed | cancelled | interrupted +verdict = passed | failed | indeterminate +disposition = accepted | parked +``` + +`accepted` is valid only with `passed`. `failed` and `indeterminate` are `parked`. Parked is an +evaluation disposition, not a new persisted delegation or turn status. A successfully generated +but contract-invalid answer remains `executionStatus=completed` and leaves the delegation `idle`, +so the parent may explicitly start a new follow-up turn. + +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. + +### Parent Visibility + +The Tape fact is historical evidence, not a model-facing delivery mechanism. Existing +`wait`, `inspect`, and `read_result` projections expose: + +- `verdict`; +- `disposition`; +- 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. It 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. +- New writes use ViewManifest schema 5 and a new manifest hash version. +- 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. +- 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. +- 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, workspace, Session lineage, and tool authority immediately + before dispatch. +- Child output remains untrusted even when its contract passes. +- JSON Schema evaluation is bounded by accepted schema size, candidate size, and evaluator work; + remote references and executable formats are forbidden. +- Error projections use bounded reason codes and sanitized messages. + +## Acceptance Criteria + +1. Every new DeepChat-owned provider View has a schema-v5 manifest containing a verifiable + ExecutionContract built from the exact request inputs. +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. Contract failure does not rewrite successful provider execution as an execution failure. +9. Parent-facing orchestration results expose verdict, disposition, 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. diff --git a/docs/architecture/tape-contract-lineage/tasks.md b/docs/architecture/tape-contract-lineage/tasks.md new file mode 100644 index 000000000..c99c8559f --- /dev/null +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -0,0 +1,62 @@ +# 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 + +- [ ] Add canonical contract schemas, builders, hash versions, and domain tests. +- [ ] Return structured prompt sections without changing provider-visible prompt text. +- [ ] Record AGENTS.md freshness/degradation and pinned-skill/tooling omissions. +- [ ] Thread prompt provenance through turn and loop assembly. +- [ ] Review and commit the prompt/contract-domain slice. + +## P0: ViewManifest V5 And Enforcement + +- [ ] Embed ExecutionContract in ViewManifest schema 5 and preserve v1-v4 reads. +- [ ] Keep interactive manifest persistence fail-open and require contract-bearing child manifests. +- [ ] Carry the exact View contract to tool dispatch without Session-global mutable state. +- [ ] Enforce stable tool target, effect, workspace, and nesting ceilings with current authority. +- [ ] Cover retries, tool rounds, revocation, expansion, and contract mismatch. +- [ ] Review and commit the View/enforcement slice. + +## P1: Strict Contract Persistence + +- [ ] Reserve `contract/*` and add a transaction-aware strict Tape capability. +- [ ] Add complete Tape identity and canonical conflict validation. +- [ ] Add nullable live-delegation contract/evaluation projection columns and migration coverage. +- [ ] Atomically freeze parent TaskContract with initial and follow-up turn creation. +- [ ] Re-anchor hash-verified runtime projections after parent Tape reset. +- [ ] Review and commit the parent-freeze/storage slice. + +## P1: Child Inheritance + +- [ ] Strictly append the inherited TaskContract to child Tape before Handoff dispatch. +- [ ] Persist child-local reference and expose active contract context through a narrow port. +- [ ] Re-inherit the contract after child Tape reset before the next provider request. +- [ ] Reconcile legacy active turns with an explicit compatibility contract. +- [ ] Cover restart, repeated inheritance, reset/incarnation conflict, and missing child Tape. +- [ ] Review and commit the child-inheritance slice. + +## P1: Evaluation And Parent Visibility + +- [ ] Implement bounded required-section and result-schema evaluation. +- [ ] Commit evaluation fact, projection, terminal state, and mailbox event atomically. +- [ ] Ensure every contract-bearing terminal path produces evaluation or remains recoverable. +- [ ] Surface evaluation through inspect, wait, read_result, and the untrusted result envelope. +- [ ] Preserve orthogonal execution status, verdict, and disposition semantics. +- [ ] Cover no answer, malformed result, cancellation, interruption, evaluator failure, and + settlement retry/recovery. +- [ ] Review and commit the evaluation/settlement slice. + +## Documentation And Final Validation + +- [ ] Update retained Tape and proactive multi-Agent architecture references. +- [ ] Run format, i18n, lint, Node/web typecheck, focused tests, and relevant main suites. +- [ ] Review the complete `dev...HEAD` diff and fix findings by severity. +- [ ] Confirm every task and acceptance criterion is represented in code or documented as deferred. +- [ ] Confirm the branch has not been pushed. From 760bff070ea1a4045e9354d6c47dcd4911cc5ff4 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sat, 8 Aug 2026 21:03:32 +0800 Subject: [PATCH 02/37] feat(agent): track prompt provenance --- .../tape-contract-lineage/tasks.md | 9 +- src/main/agent/deepchat/loop/loopRun.ts | 6 +- src/main/agent/deepchat/loop/ports.ts | 2 + .../deepchat/resources/promptAssembly.ts | 149 ++++++++++++++ .../resources/systemEnvPromptBuilder.ts | 139 +++++++++---- .../deepchat/resources/systemPromptBuilder.ts | 137 ++++++++++--- .../deepchat/runtime/deepChatLoopRunner.ts | 42 +++- src/main/agent/deepchat/runtime/process.ts | 19 +- .../deepchat/runtime/promptAssemblyService.ts | 27 +++ .../agent/deepchat/runtime/turnCoordinator.ts | 67 +++++-- src/main/agent/deepchat/runtime/types.ts | 3 +- src/shared/types/prompt-assembly.ts | 55 ++++++ .../harness/deepChatAgentHarness.test.ts | 47 ++++- test/main/agent/deepchat/loop/loopRun.test.ts | 26 +++ .../deepchat/resources/promptAssembly.test.ts | 136 +++++++++++++ .../resources/systemEnvPromptBuilder.test.ts | 93 ++++++++- .../resources/systemPromptBuilder.test.ts | 186 +++++++++++++++++- .../agent/deepchat/runtime/process.test.ts | 13 +- .../runtime/promptAssemblyService.test.ts | 40 ++++ 19 files changed, 1081 insertions(+), 115 deletions(-) create mode 100644 src/main/agent/deepchat/resources/promptAssembly.ts create mode 100644 src/shared/types/prompt-assembly.ts create mode 100644 test/main/agent/deepchat/resources/promptAssembly.test.ts diff --git a/docs/architecture/tape-contract-lineage/tasks.md b/docs/architecture/tape-contract-lineage/tasks.md index c99c8559f..5ef50d59c 100644 --- a/docs/architecture/tape-contract-lineage/tasks.md +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -10,10 +10,11 @@ ## P0: Contract Domains And Prompt Provenance - [ ] Add canonical contract schemas, builders, hash versions, and domain tests. -- [ ] Return structured prompt sections without changing provider-visible prompt text. -- [ ] Record AGENTS.md freshness/degradation and pinned-skill/tooling omissions. -- [ ] Thread prompt provenance through turn and loop assembly. -- [ ] Review and commit the prompt/contract-domain slice. +- [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. +- [ ] Review and commit the canonical contract-domain slice. ## P0: ViewManifest V5 And Enforcement diff --git a/src/main/agent/deepchat/loop/loopRun.ts b/src/main/agent/deepchat/loop/loopRun.ts index b6da30b59..09034609e 100644 --- a/src/main/agent/deepchat/loop/loopRun.ts +++ b/src/main/agent/deepchat/loop/loopRun.ts @@ -1,10 +1,12 @@ 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' export interface LoopRunResources { toolDefinitions: MCPToolDefinition[] activeSkillNames: string[] + promptAssembly?: DeepChatPromptAssembly } export interface LoopRunProviderRecovery { @@ -38,6 +40,7 @@ export interface CreateLoopRunInput { resources: { toolDefinitions: readonly MCPToolDefinition[] activeSkillNames: readonly string[] + promptAssembly?: DeepChatPromptAssembly } initialRequestSeq?: number initialLogicalRound?: number @@ -66,7 +69,8 @@ export function createLoopRun( streamState: input.streamState, resources: { toolDefinitions: [...input.resources.toolDefinitions], - activeSkillNames: [...input.resources.activeSkillNames] + activeSkillNames: [...input.resources.activeSkillNames], + ...(input.resources.promptAssembly ? { promptAssembly: input.resources.promptAssembly } : {}) }, providerRecovery: { contextOverflowHandoffAttempted: false, diff --git a/src/main/agent/deepchat/loop/ports.ts b/src/main/agent/deepchat/loop/ports.ts index b48164c92..f34f09126 100644 --- a/src/main/agent/deepchat/loop/ports.ts +++ b/src/main/agent/deepchat/loop/ports.ts @@ -10,6 +10,7 @@ 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' @@ -197,6 +198,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..a43e46f50 --- /dev/null +++ b/src/main/agent/deepchat/resources/promptAssembly.ts @@ -0,0 +1,149 @@ +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 + +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 +} + +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' + + return 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 } : {}) + }) +} + +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.`) + } + + let prompt = '' + for (const section of sections) { + if (!section.content.trim()) continue + if (!prompt) { + prompt = section.content + continue + } + prompt += `${section.separatorBefore ?? '\n\n'}${section.content}` + } + + return Object.freeze({ + prompt, + sections: Object.freeze([...sections]) + }) +} + +export function appendPromptAssemblySection( + assembly: DeepChatPromptAssembly, + section: DeepChatPromptAssemblySection +): DeepChatPromptAssembly { + if ( + assembly.sections.some( + (candidate) => + candidate.kind === section.kind && + candidate.sourceRef === section.sourceRef && + candidate.contentHash === section.contentHash + ) + ) { + return assembly + } + if (assembly.sections.length >= MAX_PROMPT_SECTIONS) { + throw new RangeError(`System prompt has more than ${MAX_PROMPT_SECTIONS} provenance sections.`) + } + const prompt = !section.content.trim() + ? assembly.prompt + : assembly.prompt + ? `${assembly.prompt}${section.separatorBefore ?? '\n\n'}${section.content}` + : section.content + return Object.freeze({ + prompt, + sections: Object.freeze([...assembly.sections, section]) + }) +} + +export function recordPromptAssemblyObservation( + assembly: DeepChatPromptAssembly, + section: DeepChatPromptAssemblySection +): DeepChatPromptAssembly { + if (assembly.sections.length >= MAX_PROMPT_SECTIONS) { + throw new RangeError(`System prompt has more than ${MAX_PROMPT_SECTIONS} provenance sections.`) + } + return Object.freeze({ + prompt: assembly.prompt, + sections: Object.freeze([...assembly.sections, section]) + }) +} + +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 82f3d5b2e..f4744b372 100644 --- a/src/main/agent/deepchat/resources/systemEnvPromptBuilder.ts +++ b/src/main/agent/deepchat/resources/systemEnvPromptBuilder.ts @@ -1,7 +1,13 @@ 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 { ProviderCatalogPort } from '@/provider/ports' +import { assemblePromptSections, createPromptAssemblySection } from './promptAssembly' export interface BuildSystemEnvPromptOptions { providerId?: string @@ -23,10 +29,23 @@ const SYSTEM_ENV_SLOW_STEP_MS = 500 const AGENTS_READ_BUDGET_MS = 200 const AGENTS_CACHE_TTL_MS = 30_000 -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() @@ -107,13 +126,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', { @@ -121,21 +143,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 }) @@ -145,12 +167,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) }) @@ -165,29 +187,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( @@ -221,9 +274,9 @@ export function buildRuntimeCapabilitiesPrompt( return lines.length > 1 ? lines.join('\n') : '' } -export async function buildSystemEnvPrompt( +export async function buildSystemEnvPromptAssembly( options: BuildSystemEnvPromptOptions = {} -): Promise { +): Promise { const now = options.now ?? new Date() const platform = options.platform ?? process.platform const workdir = resolveWorkdir(options.workdir) @@ -231,7 +284,7 @@ export async function buildSystemEnvPrompt( ? 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( @@ -244,7 +297,7 @@ export async function buildSystemEnvPrompt( 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:`, @@ -254,11 +307,31 @@ export async function buildSystemEnvPrompt( `Platform: ${platform}`, `Today's date: ${now.toDateString()}`, '' - ] - - if (agentsContent.trim().length > 0) { - promptLines.push(`Instructions from: ${agentsFilePath}\n`, agentsContent) - } + ].join('\n') + const agentsContent = agentsInstructions.content.trim() + ? `Instructions from: ${agentsFilePath}\n\n${agentsInstructions.content}` + : '' + + 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 eeae1abba..016f85091 100644 --- a/src/main/agent/deepchat/resources/systemPromptBuilder.ts +++ b/src/main/agent/deepchat/resources/systemPromptBuilder.ts @@ -1,12 +1,21 @@ import type { ProviderModelResolutionPort } from '@/provider/settings' 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, buildSystemEnvPrompt } from "./systemEnvPromptBuilder"; +import { + buildRuntimeCapabilitiesPrompt, + buildSystemEnvPromptAssembly +} from "./systemEnvPromptBuilder"; +import { assemblePromptSections, createPromptAssemblySection } from './promptAssembly' import type { SkillSettingsPort } from "@/skill/settings"; import { LIVE_DELEGATION_AGENT_TOOL_NAME } from '@shared/agentTools' import { UNTRUSTED_CHILD_OUTPUT_POLICY } from '@shared/orchestration/resultSafety' @@ -86,10 +95,10 @@ function getVerificationScriptNames(manifest: PackageJsonManifest | null): strin .map(([name]) => name); } -export async function buildSystemPromptWithSkills( +export async function buildSystemPromptAssemblyWithSkills( dependencies: SystemPromptBuilderDependencies, input: SystemPromptBuildInput, -): Promise { +): Promise { const { sessionId, basePrompt, toolDefinitions, activeSkillNamesOverride, resourceInstance } = input; dependencies.assertCurrent(sessionId, resourceInstance); @@ -98,7 +107,13 @@ export async function buildSystemPromptWithSkills( 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() @@ -108,6 +123,8 @@ export async function buildSystemPromptWithSkills( const skillsEnabled = dependencies.skillSettings.isEnabled(); const skillService = dependencies.skillService; + const skillsMetadataDegradations: DeepChatPromptDegradationCode[] = []; + const pinnedSkillsDegradations: DeepChatPromptDegradationCode[] = []; let sessionAgentId: string | null = null; if (skillsEnabled) { try { @@ -118,6 +135,10 @@ export async function buildSystemPromptWithSkills( error, ); } + if (!sessionAgentId) { + skillsMetadataDegradations.push('skill_agent_unavailable'); + pinnedSkillsDegradations.push('skill_agent_unavailable'); + } } const availableSkills: Array<{ name: string; @@ -150,6 +171,7 @@ export async function buildSystemPromptWithSkills( `[DeepChatAgent] Failed to load skills metadata for session ${sessionId}:`, error, ); + skillsMetadataDegradations.push('skill_metadata_unavailable'); } dependencies.logSlowStep(sessionId, "system-prompt.skills-metadata-load", metadataStartedAt); @@ -168,6 +190,7 @@ export async function buildSystemPromptWithSkills( `[DeepChatAgent] Failed to load active skills for session ${sessionId}:`, error, ); + pinnedSkillsDegradations.push('active_skills_unavailable'); } dependencies.logSlowStep( sessionId, @@ -180,9 +203,13 @@ export async function buildSystemPromptWithSkills( 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 requestedActiveSkills = normalizeStringList(activeSkillNames); + const normalizedActiveSkills = requestedActiveSkills.filter((skillName) => + availableSkillNames.has(skillName), ); + if (normalizedActiveSkills.length !== requestedActiveSkills.length) { + pinnedSkillsDegradations.push('pinned_skill_unavailable'); + } const agentToolNames = getAgentToolNames(toolDefinitions); const runtimePrompt = buildRuntimeCapabilitiesPrompt({ hasYoBrowser: toolDefinitions.some( @@ -216,34 +243,54 @@ export async function buildSystemPromptWithSkills( const content = skill?.content?.trim(); if (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, ); + pinnedSkillsDegradations.push('pinned_skill_load_failed'); } } 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({ + envSections = ( + await buildSystemEnvPromptAssembly({ providerId, modelId, workdir, now, 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); + 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 = ""; + const toolingDegradations: DeepChatPromptDegradationCode[] = []; try { stepStartedAt = Date.now(); toolingPrompt = dependencies.toolService.buildToolSystemPrompt({ @@ -253,24 +300,67 @@ export async function buildSystemPromptWithSkills( dependencies.logSlowStep(sessionId, "system-prompt.tooling-prompt", stepStartedAt); } catch (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), + 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 composedPrompt; + return assembly; +} + +export async function buildSystemPromptWithSkills( + dependencies: SystemPromptBuilderDependencies, + input: SystemPromptBuildInput, +): Promise { + return (await buildSystemPromptAssemblyWithSkills(dependencies, input)).prompt; } function buildOrchestrationPolicyPrompt( @@ -309,13 +399,6 @@ 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) => diff --git a/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts b/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts index e6209a076..7a0b99d45 100644 --- a/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts +++ b/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts @@ -7,6 +7,7 @@ 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 { ProviderExecutionPort, ModelConfig, @@ -29,6 +30,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 { @@ -181,6 +186,7 @@ export type DeepChatLoopRunInput = { providerModelFacts?: ProviderModelRuntimeFacts tools?: MCPToolDefinition[] baseSystemPrompt?: string + basePromptAssembly?: DeepChatPromptAssembly contextContributions?: ContextRuntimeContributions initialBlocks?: AssistantMessageBlock[] initialAccounting?: MessageMetadata @@ -192,7 +198,7 @@ export type DeepChatLoopRunInput = { refreshSystemPrompt?: ( activeSkillNames: string[] | undefined, toolDefinitions: MCPToolDefinition[] - ) => Promise + ) => Promise maxProviderRounds?: number onBeforeProviderStream?: () => void onRunRegistered?: (runId: string) => void @@ -352,6 +358,7 @@ export class DeepChatLoopRunner { providerModelFacts: providedProviderModelFacts, tools: providedTools, baseSystemPrompt, + basePromptAssembly, contextContributions, initialBlocks, initialAccounting, @@ -450,6 +457,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), @@ -492,7 +510,8 @@ export class DeepChatLoopRunner { streamState: createState(), resources: { toolDefinitions: tools, - activeSkillNames: getEffectiveRuntimeSkillNames() + activeSkillNames: getEffectiveRuntimeSkillNames(), + promptAssembly: initialPromptAssembly }, initialRequestSeq }) @@ -587,17 +606,22 @@ 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) - }) + return await this.ports.promptAssembly + .createBasePromptAssembler(resourceInstance) + .assembleWithProvenance({ + sessionId: toAppSessionId(sessionId), + configuredPrompt: generationSettings.systemPrompt, + toolDefinitions: refreshedTools, + activeSkillNames: getEffectiveRuntimeSkillNames(activeSkillNames) + }) }, toolExecution: this.ports.toolExecutionPort, toolResults: this.ports.toolResultPort, diff --git a/src/main/agent/deepchat/runtime/process.ts b/src/main/agent/deepchat/runtime/process.ts index 61fb85048..14d05b9ca 100644 --- a/src/main/agent/deepchat/runtime/process.ts +++ b/src/main/agent/deepchat/runtime/process.ts @@ -35,6 +35,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' const UNKNOWN_CONTEXT_LIMIT = Number.MAX_SAFE_INTEGER const MAX_TRUNCATED_TOOL_RECOVERY_ATTEMPTS = 1 @@ -1288,7 +1292,20 @@ export async function processStream(params: ProcessParams): Promise { + return await buildSystemPromptAssemblyWithSkills(this.builderDependencies, { + sessionId, + basePrompt, + toolDefinitions, + activeSkillNamesOverride, + orchestrationPolicy: this.deps.orchestrationPolicy.resolveOrchestrationPolicy(sessionId), + resourceInstance + }) + } + createBasePromptAssembler(expectedInstance: DeepChatAgentInstance): BasePromptAssembler { return { assemble: async (input) => @@ -82,6 +101,14 @@ export class PromptAssemblyService { [...input.toolDefinitions], [...input.activeSkillNames], expectedInstance + ), + assembleWithProvenance: async (input) => + await this.buildWithProvenance( + input.sessionId, + input.configuredPrompt, + [...input.toolDefinitions], + [...input.activeSkillNames], + expectedInstance ) } } diff --git a/src/main/agent/deepchat/runtime/turnCoordinator.ts b/src/main/agent/deepchat/runtime/turnCoordinator.ts index 29e12f4aa..8488c7f69 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, @@ -245,11 +251,11 @@ export class TurnCoordinator { const toolReserveTokens = estimateToolReserveTokens(tools) 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, @@ -270,7 +276,8 @@ export class TurnCoordinator { tools, toolReserveTokens, basePromptAssembler, - baseSystemPrompt + basePromptAssembly, + baseSystemPrompt: basePromptAssembly.prompt } } @@ -501,7 +508,7 @@ export class TurnCoordinator { tools, toolReserveTokens, basePromptAssembler, - baseSystemPrompt: unguardedBaseSystemPrompt + basePromptAssembly: unguardedBasePromptAssembly } = await this.prepareTurnResources({ sessionId, messageId: userMessageId, @@ -515,9 +522,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 || [], @@ -540,7 +548,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 @@ -820,6 +829,7 @@ export class TurnCoordinator { search, tools, baseSystemPrompt, + basePromptAssembly, contextContributions, resourceInstance: instance, providerModelFacts, @@ -827,14 +837,14 @@ export class TurnCoordinator { 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, activeSkillNames: activeSkillNames ?? effectiveActiveSkillNames }) return shouldGuardAttachmentText - ? appendAttachmentTextSafetyRule(refreshedBasePrompt) + ? appendAttachmentTextSafetySection(refreshedBasePrompt) : refreshedBasePrompt }, interleavedReasoning, @@ -1222,7 +1232,7 @@ export class TurnCoordinator { tools, toolReserveTokens, basePromptAssembler, - baseSystemPrompt: unguardedBaseSystemPrompt + basePromptAssembly: unguardedBasePromptAssembly } = await this.prepareTurnResources({ sessionId, messageId, @@ -1231,7 +1241,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({ @@ -1260,7 +1271,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 ?? @@ -1457,6 +1469,7 @@ export class TurnCoordinator { abortController: preStreamAbortController, tools, baseSystemPrompt, + basePromptAssembly, contextContributions, initialBlocks, initialAccounting: resumeAccounting, @@ -1464,14 +1477,14 @@ 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, activeSkillNames: activeSkillNames ?? effectiveActiveSkillNames }) return shouldGuardAttachmentText - ? appendAttachmentTextSafetyRule(refreshedBasePrompt) + ? appendAttachmentTextSafetySection(refreshedBasePrompt) : refreshedBasePrompt }, interleavedReasoning, @@ -1700,12 +1713,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 dda25d404..c95fe17ef 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' @@ -229,7 +230,7 @@ export interface ProcessParams { refreshSystemPrompt?: ( activeSkillNames: string[] | undefined, toolDefinitions: MCPToolDefinition[] - ) => Promise + ) => Promise toolExecution: ToolExecutionPort toolResults: ToolResultPort coreStream: ( diff --git a/src/shared/types/prompt-assembly.ts b/src/shared/types/prompt-assembly.ts new file mode 100644 index 000000000..98f7b7992 --- /dev/null +++ b/src/shared/types/prompt-assembly.ts @@ -0,0 +1,55 @@ +export type DeepChatPromptSectionKind = + | 'configured_prompt' + | 'runtime_capabilities' + | 'system_environment' + | 'agents_instructions' + | 'skills_metadata' + | 'pinned_skills' + | 'tooling' + | 'orchestration_policy' + | 'permission_rules' + | 'verification_policy' + | 'attachment_safety' + | 'effective_system_prompt' + +export type DeepChatPromptSectionInclusion = 'included' | 'omitted' | 'degraded' + +export type DeepChatPromptSourceFreshness = + | 'fresh' + | 'cached' + | 'deferred' + | 'missing' + | 'read_error' + +export type DeepChatPromptDegradationCode = + | 'agents_file_deferred' + | 'agents_file_missing' + | 'agents_file_read_error' + | 'skill_agent_unavailable' + | 'skill_metadata_unavailable' + | 'active_skills_unavailable' + | 'pinned_skill_unavailable' + | 'pinned_skill_load_failed' + | 'environment_build_failed' + | 'tooling_build_failed' + | 'prompt_projection_mismatch' + | 'legacy_prompt_provenance' + +export interface DeepChatPromptSectionProvenance { + readonly kind: DeepChatPromptSectionKind + readonly sourceRef: string + readonly inclusion: DeepChatPromptSectionInclusion + readonly contentHash?: string + readonly freshness?: DeepChatPromptSourceFreshness + readonly degradationCodes?: readonly DeepChatPromptDegradationCode[] +} + +export interface DeepChatPromptAssemblySection extends DeepChatPromptSectionProvenance { + readonly content: string + readonly separatorBefore?: '\n' | '\n\n' +} + +export interface DeepChatPromptAssembly { + readonly prompt: string + readonly sections: readonly DeepChatPromptAssemblySection[] +} diff --git a/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts b/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts index c8729a196..a1516334f 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' @@ -95,9 +96,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 @@ -115,7 +115,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) => ({ @@ -3735,6 +3756,18 @@ 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}$/) + }) } ) @@ -5064,10 +5097,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' } }) @@ -10663,10 +10697,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( diff --git a/test/main/agent/deepchat/loop/loopRun.test.ts b/test/main/agent/deepchat/loop/loopRun.test.ts index a01cb53c0..97b604e2e 100644 --- a/test/main/agent/deepchat/loop/loopRun.test.ts +++ b/test/main/agent/deepchat/loop/loopRun.test.ts @@ -95,6 +95,32 @@ 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 } + }) + + 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..3f1ecf638 --- /dev/null +++ b/test/main/agent/deepchat/resources/promptAssembly.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { + appendPromptAssemblySection, + assemblePromptSections, + createOpaquePromptAssembly, + createPromptAssemblySection, + reconcilePromptAssembly, + recordPromptAssemblyObservation +} from '@/agent/deepchat/resources/promptAssembly' + +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('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 1e62b2190..e9d1fc12a 100644 --- a/test/main/agent/deepchat/resources/systemEnvPromptBuilder.test.ts +++ b/test/main/agent/deepchat/resources/systemEnvPromptBuilder.test.ts @@ -1,7 +1,10 @@ import * as fs from 'node:fs' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import logger from '@shared/logger' -import { buildSystemEnvPrompt } from '@/agent/deepchat/resources/systemEnvPromptBuilder' +import { + buildSystemEnvPrompt, + buildSystemEnvPromptAssembly +} from '@/agent/deepchat/resources/systemEnvPromptBuilder' function fileError(code: string): NodeJS.ErrnoException { return Object.assign(new Error(`${code} mock error`), { code }) @@ -34,20 +37,65 @@ describe('buildSystemEnvPrompt', () => { '[SystemEnvPromptBuilder] Failed to read AGENTS.md', expect.anything() ) + + const assembly = await buildSystemEnvPromptAssembly({ + workdir: '/tmp/deepchat-env-prompt-missing', + providerId: 'provider', + modelId: 'model', + 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', + 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', modelId: 'model', 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', + 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 () => { @@ -66,6 +114,20 @@ describe('buildSystemEnvPrompt', () => { code: 'EISDIR', message: 'EISDIR mock error' }) + + const assembly = await buildSystemEnvPromptAssembly({ + workdir: '/tmp/deepchat-env-prompt-error', + providerId: 'provider', + modelId: 'model', + 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 () => { @@ -77,7 +139,7 @@ describe('buildSystemEnvPrompt', () => { }) as ReturnType ) - const promptPromise = buildSystemEnvPrompt({ + const promptPromise = buildSystemEnvPromptAssembly({ workdir: '/tmp/deepchat-env-prompt-slow', providerId: 'provider', modelId: 'model', @@ -85,9 +147,16 @@ describe('buildSystemEnvPrompt', () => { }) await vi.advanceTimersByTimeAsync(200) - const prompt = await promptPromise - - expect(prompt).not.toContain('Instructions from:') + const promptAssembly = await promptPromise + + 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 @@ -97,14 +166,20 @@ 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', 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 ab9a30084..8331568a0 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' @@ -57,6 +60,21 @@ describe('DeepChat system prompt builder', () => { toolDefinitions: [], resourceInstance: instance }) + const assembly = await buildSystemPromptAssemblyWithSkills(dependencies, { + sessionId: 'session-1', + basePrompt: ' BASE PROMPT ', + toolDefinitions: [], + resourceInstance: instance + }) + const acpAssembly = await buildSystemPromptAssemblyWithSkills( + { ...dependencies, isAcpBackedSubagentSession: () => true }, + { + sessionId: 'session-1', + basePrompt: ' BASE PROMPT ', + toolDefinitions: [], + resourceInstance: instance + } + ) const explicit = await buildSystemPromptWithSkills(dependencies, { sessionId: 'session-1', basePrompt: ' BASE PROMPT ', @@ -101,6 +119,50 @@ 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}`, + `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: expect.stringMatching(/^[a-f0-9]{64}$/) + } + ) 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') @@ -236,4 +298,126 @@ 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'], + 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: [], + 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/process.test.ts b/test/main/agent/deepchat/runtime/process.test.ts index e64d80613..ed6c6f43d 100644 --- a/test/main/agent/deepchat/runtime/process.test.ts +++ b/test/main/agent/deepchat/runtime/process.test.ts @@ -2508,7 +2508,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) { @@ -2530,7 +2530,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' @@ -2588,6 +2588,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 b9de30833..71d6956ad 100644 --- a/test/main/agent/deepchat/runtime/promptAssemblyService.test.ts +++ b/test/main/agent/deepchat/runtime/promptAssemblyService.test.ts @@ -11,8 +11,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 })) @@ -108,6 +123,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( From b8e61d1e3d01f2320ab069fa7435f636be8a56a1 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sat, 8 Aug 2026 21:38:23 +0800 Subject: [PATCH 03/37] feat(tape): define execution contracts --- .../tape-contract-lineage/tasks.md | 2 +- src/main/tape/domain/canonicalJson.ts | 28 +- src/main/tape/domain/executionContract.ts | 572 ++++++++++++++++++ src/shared/types/execution-contract.ts | 68 +++ src/shared/types/prompt-assembly.ts | 83 +-- .../harness/deepChatAgentHarness.test.ts | 4 +- test/main/tape/canonicalJson.test.ts | 13 + test/main/tape/executionContract.test.ts | 394 ++++++++++++ 8 files changed, 1117 insertions(+), 47 deletions(-) create mode 100644 src/main/tape/domain/executionContract.ts create mode 100644 src/shared/types/execution-contract.ts create mode 100644 test/main/tape/executionContract.test.ts diff --git a/docs/architecture/tape-contract-lineage/tasks.md b/docs/architecture/tape-contract-lineage/tasks.md index 5ef50d59c..e2735856f 100644 --- a/docs/architecture/tape-contract-lineage/tasks.md +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -14,7 +14,7 @@ - [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. -- [ ] Review and commit the canonical contract-domain slice. +- [x] Review and commit the canonical ExecutionContract domain slice. ## P0: ViewManifest V5 And Enforcement diff --git a/src/main/tape/domain/canonicalJson.ts b/src/main/tape/domain/canonicalJson.ts index 66b1fe521..24f18f56e 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 { @@ -90,10 +101,13 @@ export function hashJson(value: unknown): string { // ViewManifest hashes keep the legacy object accumulator; journal identities need 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/executionContract.ts b/src/main/tape/domain/executionContract.ts new file mode 100644 index 000000000..74755a335 --- /dev/null +++ b/src/main/tape/domain/executionContract.ts @@ -0,0 +1,572 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' +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_HASH_VERSION, + DEEPCHAT_EXECUTION_CONTRACT_SCHEMA_VERSION, + type DeepChatExecutionContract, + type DeepChatExecutionContractRequest, + type DeepChatExecutionDynamicControlSnapshot, + type DeepChatExecutionToolCeiling, + type DeepChatExecutionToolTargetIdentity, + type DeepChatExecutionWorkspaceCeiling +} from '@shared/types/execution-contract' +import { canonicalJsonStringifyData, hashJsonData } from './canonicalJson' + +export const MAX_EXECUTION_CONTRACT_BYTES = 64 * 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']) + +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 +} + +export class ExecutionContractError extends Error { + constructor( + message: string, + readonly code: 'invalid_input' | 'limit_exceeded' | 'conflicting_tool', + options?: ErrorOptions + ) { + super(message, options) + this.name = 'ExecutionContractError' + } +} + +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 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 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 + }) + if (!path.isAbsolute(workspacePath)) { + throw new ExecutionContractError('workspace.path must be absolute.', 'invalid_input') + } + return { kind: 'path', path: path.normalize(workspacePath) } +} + +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 : '' +} + +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 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 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 draft: Omit = { + schemaVersion: DEEPCHAT_EXECUTION_CONTRACT_SCHEMA_VERSION, + hashVersion: DEEPCHAT_EXECUTION_CONTRACT_HASH_VERSION, + request: normalizeRequest(input.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: hashData(input.providerMessages, 'provider messages', true), + effectiveGenerationConfigHash: buildEffectiveGenerationConfigHash(input), + providerVisibleToolDefinitionsHash: buildProviderVisibleToolDefinitionsHash(input.tools), + internalExecutionPolicyHash: hashData(ceilings, 'internal execution policy'), + assemblerVersion: requireString( + input.assemblerVersion, + 'assemblerVersion', + MAX_ASSEMBLER_VERSION_BYTES + ), + taskContractRef: null + } + } + 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 + } +} diff --git a/src/shared/types/execution-contract.ts b/src/shared/types/execution-contract.ts new file mode 100644 index 000000000..a134c5ea6 --- /dev/null +++ b/src/shared/types/execution-contract.ts @@ -0,0 +1,68 @@ +import type { PermissionMode } from './agent-interface' +import type { ToolExecutionContract } from './core/mcp' +import type { DeepChatPromptSectionProvenance } from './prompt-assembly' + +export const DEEPCHAT_EXECUTION_CONTRACT_SCHEMA_VERSION = 1 as const +export const DEEPCHAT_EXECUTION_CONTRACT_HASH_VERSION = 1 as const + +export type DeepChatExecutionToolSource = 'agent' | 'mcp' + +export interface DeepChatExecutionToolTargetIdentity { + readonly providerVisibleName: string + readonly source: DeepChatExecutionToolSource + readonly serverName: string + readonly serverId: string | null + readonly configGeneration: number | null + readonly bindingHash: string | null + readonly originalName: string +} + +export interface DeepChatExecutionToolCeiling { + readonly target: DeepChatExecutionToolTargetIdentity + readonly execution: ToolExecutionContract +} + +export type DeepChatExecutionWorkspaceCeiling = + | { readonly kind: 'path'; readonly path: string } + | { readonly kind: 'runtime_default' } + +export interface DeepChatExecutionContractRequest { + readonly sessionId: string + readonly messageId: string + readonly runId: string + readonly requestSeq: number +} + +export interface DeepChatExecutionContractCeilings { + readonly tools: readonly DeepChatExecutionToolCeiling[] + readonly workspace: DeepChatExecutionWorkspaceCeiling + readonly maxSubagentDepth: number +} + +export interface DeepChatExecutionDynamicControlSnapshot { + readonly permissionMode: PermissionMode + readonly requestAdmitted: boolean + readonly cancellationRequested: boolean +} + +export interface DeepChatExecutionContractProvenance { + readonly promptSections: readonly DeepChatPromptSectionProvenance[] + readonly providerId: string + readonly modelId: string + readonly promptHash: string + readonly effectiveGenerationConfigHash: string + readonly providerVisibleToolDefinitionsHash: string + readonly internalExecutionPolicyHash: string + readonly assemblerVersion: string + readonly taskContractRef: null +} + +export interface DeepChatExecutionContract { + readonly schemaVersion: typeof DEEPCHAT_EXECUTION_CONTRACT_SCHEMA_VERSION + readonly hashVersion: typeof DEEPCHAT_EXECUTION_CONTRACT_HASH_VERSION + readonly request: DeepChatExecutionContractRequest + readonly ceilings: DeepChatExecutionContractCeilings + readonly dynamicControlSnapshot: DeepChatExecutionDynamicControlSnapshot + readonly provenance: DeepChatExecutionContractProvenance + readonly contractHash: string +} diff --git a/src/shared/types/prompt-assembly.ts b/src/shared/types/prompt-assembly.ts index 98f7b7992..38788b878 100644 --- a/src/shared/types/prompt-assembly.ts +++ b/src/shared/types/prompt-assembly.ts @@ -1,39 +1,50 @@ -export type DeepChatPromptSectionKind = - | 'configured_prompt' - | 'runtime_capabilities' - | 'system_environment' - | 'agents_instructions' - | 'skills_metadata' - | 'pinned_skills' - | 'tooling' - | 'orchestration_policy' - | 'permission_rules' - | 'verification_policy' - | 'attachment_safety' - | 'effective_system_prompt' - -export type DeepChatPromptSectionInclusion = 'included' | 'omitted' | 'degraded' - -export type DeepChatPromptSourceFreshness = - | 'fresh' - | 'cached' - | 'deferred' - | 'missing' - | 'read_error' - -export type DeepChatPromptDegradationCode = - | 'agents_file_deferred' - | 'agents_file_missing' - | 'agents_file_read_error' - | 'skill_agent_unavailable' - | 'skill_metadata_unavailable' - | 'active_skills_unavailable' - | 'pinned_skill_unavailable' - | 'pinned_skill_load_failed' - | 'environment_build_failed' - | 'tooling_build_failed' - | 'prompt_projection_mismatch' - | 'legacy_prompt_provenance' +export const DEEPCHAT_PROMPT_SECTION_KINDS = [ + 'configured_prompt', + 'runtime_capabilities', + 'system_environment', + 'agents_instructions', + 'skills_metadata', + 'pinned_skills', + 'tooling', + 'orchestration_policy', + 'permission_rules', + 'verification_policy', + 'attachment_safety', + 'effective_system_prompt' +] as const + +export type DeepChatPromptSectionKind = (typeof DEEPCHAT_PROMPT_SECTION_KINDS)[number] + +export const DEEPCHAT_PROMPT_SECTION_INCLUSIONS = ['included', 'omitted', 'degraded'] as const + +export type DeepChatPromptSectionInclusion = (typeof DEEPCHAT_PROMPT_SECTION_INCLUSIONS)[number] + +export const DEEPCHAT_PROMPT_SOURCE_FRESHNESS_VALUES = [ + 'fresh', + 'cached', + 'deferred', + 'missing', + 'read_error' +] as const + +export type DeepChatPromptSourceFreshness = (typeof DEEPCHAT_PROMPT_SOURCE_FRESHNESS_VALUES)[number] + +export const DEEPCHAT_PROMPT_DEGRADATION_CODES = [ + 'agents_file_deferred', + 'agents_file_missing', + 'agents_file_read_error', + 'skill_agent_unavailable', + 'skill_metadata_unavailable', + 'active_skills_unavailable', + 'pinned_skill_unavailable', + 'pinned_skill_load_failed', + 'environment_build_failed', + 'tooling_build_failed', + 'prompt_projection_mismatch', + 'legacy_prompt_provenance' +] as const + +export type DeepChatPromptDegradationCode = (typeof DEEPCHAT_PROMPT_DEGRADATION_CODES)[number] export interface DeepChatPromptSectionProvenance { readonly kind: DeepChatPromptSectionKind diff --git a/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts b/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts index a1516334f..9e9945b2b 100644 --- a/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts +++ b/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts @@ -3756,9 +3756,7 @@ 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.prompt).toBe(callArgs.run.messages[0].content) expect( callArgs.run.resources.promptAssembly.sections.find( (section: { kind: string }) => section.kind === 'attachment_safety' 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..57276ce94 --- /dev/null +++ b/test/main/tape/executionContract.test.ts @@ -0,0 +1,394 @@ +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 { + ExecutionContractError, + MAX_EXECUTION_CONTRACT_PROMPT_SECTIONS, + MAX_EXECUTION_CONTRACT_TOOLS, + buildEffectiveGenerationConfigHash, + buildExecutionContract, + buildProviderVisibleToolDefinitionsHash, + isToolEffectWithinCeiling, + meetToolEffects, + verifyExecutionContractHash, + type BuildExecutionContractInput +} from '@/tape/domain/executionContract' + +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 + } +} + +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('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') + }) +}) From 8405302375ed9a7fa244686fec84705641485ca4 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sat, 8 Aug 2026 22:29:31 +0800 Subject: [PATCH 04/37] feat(tape): embed view execution contracts --- .../tape-contract-lineage/tasks.md | 2 +- src/main/tape/domain/canonicalJson.ts | 2 +- src/main/tape/domain/executionContract.ts | 295 +++++++++++++++++- src/main/tape/domain/replay.ts | 33 +- src/main/tape/domain/viewManifest.ts | 150 +++++++-- src/shared/types/tape-view-manifest.ts | 20 +- .../session/data/tapeViewManifest.test.ts | 189 +++++++++++ test/main/tape/executionContract.test.ts | 12 + 8 files changed, 676 insertions(+), 27 deletions(-) diff --git a/docs/architecture/tape-contract-lineage/tasks.md b/docs/architecture/tape-contract-lineage/tasks.md index e2735856f..4f4231d8c 100644 --- a/docs/architecture/tape-contract-lineage/tasks.md +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -18,7 +18,7 @@ ## P0: ViewManifest V5 And Enforcement -- [ ] Embed ExecutionContract in ViewManifest schema 5 and preserve v1-v4 reads. +- [x] Embed ExecutionContract in ViewManifest schema 5 and preserve v1-v4 reads. - [ ] Keep interactive manifest persistence fail-open and require contract-bearing child manifests. - [ ] Carry the exact View contract to tool dispatch without Session-global mutable state. - [ ] Enforce stable tool target, effect, workspace, and nesting ceilings with current authority. diff --git a/src/main/tape/domain/canonicalJson.ts b/src/main/tape/domain/canonicalJson.ts index 24f18f56e..64f665987 100644 --- a/src/main/tape/domain/canonicalJson.ts +++ b/src/main/tape/domain/canonicalJson.ts @@ -99,7 +99,7 @@ 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, diff --git a/src/main/tape/domain/executionContract.ts b/src/main/tape/domain/executionContract.ts index 74755a335..8c895e261 100644 --- a/src/main/tape/domain/executionContract.ts +++ b/src/main/tape/domain/executionContract.ts @@ -48,6 +48,48 @@ const PROMPT_SECTION_INCLUSIONS = new Set(DEEPCHAT_PROMPT_SECTION_INCLUS 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_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 @@ -152,6 +194,50 @@ function hashData(value: unknown, label: string, omitUndefinedProperties = false } } +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 } @@ -450,6 +536,185 @@ function resolveLeadingSystemPrompt(messages: readonly ChatMessage[]): string { 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 ( + (path.posix.isAbsolute(value.path) && path.posix.normalize(value.path) === value.path) || + (path.win32.isAbsolute(value.path) && path.win32.normalize(value.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 + ) +} + export function buildEffectiveGenerationConfigHash(input: { modelConfig: ModelConfig temperature: number @@ -469,6 +734,10 @@ export function buildEffectiveGenerationConfigHash(input: { ) } +export function buildProviderMessagesHash(messages: readonly ChatMessage[]): string { + return hashData(messages, 'provider messages', true) +} + export function buildProviderVisibleToolDefinitionsHash( definitions: readonly MCPToolDefinition[] ): string { @@ -528,7 +797,7 @@ export function buildExecutionContract( promptSections: normalizePromptSections(input.promptAssembly), providerId: requireString(input.providerId, 'providerId', MAX_IDENTITY_BYTES), modelId: requireString(input.modelId, 'modelId', MAX_IDENTITY_BYTES), - promptHash: hashData(input.providerMessages, 'provider messages', true), + promptHash: buildProviderMessagesHash(input.providerMessages), effectiveGenerationConfigHash: buildEffectiveGenerationConfigHash(input), providerVisibleToolDefinitionsHash: buildProviderVisibleToolDefinitionsHash(input.tools), internalExecutionPolicyHash: hashData(ceilings, 'internal execution policy'), @@ -570,3 +839,27 @@ export function verifyExecutionContractHash(contract: DeepChatExecutionContract) 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 + } + 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/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/shared/types/tape-view-manifest.ts b/src/shared/types/tape-view-manifest.ts index 6b0fbf3a5..cdc7dacf8 100644 --- a/src/shared/types/tape-view-manifest.ts +++ b/src/shared/types/tape-view-manifest.ts @@ -1,3 +1,5 @@ +import type { DeepChatExecutionContract } from './execution-contract' + export type DeepChatTapeViewTaskType = 'chat' | 'resume' | 'tool_loop' export type DeepChatTapeViewPolicy = @@ -92,9 +94,7 @@ export interface DeepChatTapeViewMeta { traceDebugEnabled: boolean } -export interface DeepChatTapeViewManifest { - schemaVersion: 1 | 2 | 3 | 4 - hashVersion: number +interface DeepChatTapeViewManifestBase { viewId: string sessionId: string messageId: string @@ -115,6 +115,20 @@ export interface DeepChatTapeViewManifest { assembledAt: number } +export interface DeepChatTapeViewManifestLegacy extends DeepChatTapeViewManifestBase { + schemaVersion: 1 | 2 | 3 | 4 + hashVersion: number + executionContract?: never +} + +export interface DeepChatTapeViewManifestV5 extends DeepChatTapeViewManifestBase { + schemaVersion: 5 + hashVersion: 3 + executionContract: DeepChatExecutionContract +} + +export type DeepChatTapeViewManifest = DeepChatTapeViewManifestLegacy | DeepChatTapeViewManifestV5 + export type DeepChatTapeViewManifestIntegrity = 'valid' | 'invalid' | 'unverified' export interface DeepChatTapeViewManifestRecord { 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/tape/executionContract.test.ts b/test/main/tape/executionContract.test.ts index 57276ce94..f386dd248 100644 --- a/test/main/tape/executionContract.test.ts +++ b/test/main/tape/executionContract.test.ts @@ -5,6 +5,7 @@ import { assemblePromptSections, createPromptAssemblySection } from '@/agent/deepchat/resources/promptAssembly' +import { hashJsonData } from '@/tape/domain/canonicalJson' import { ExecutionContractError, MAX_EXECUTION_CONTRACT_PROMPT_SECTIONS, @@ -12,6 +13,7 @@ import { buildEffectiveGenerationConfigHash, buildExecutionContract, buildProviderVisibleToolDefinitionsHash, + isDeepChatExecutionContract, isToolEffectWithinCeiling, meetToolEffects, verifyExecutionContractHash, @@ -391,4 +393,14 @@ describe('ExecutionContract domain', () => { expect(meetToolEffects('read', 'write')).toBe('read') expect(meetToolEffects('write', 'write')).toBe('write') }) + + 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) + }) }) From ae1cd1125e0d3d9d89e3cd211ec46f76b74fb04d Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sat, 8 Aug 2026 22:51:31 +0800 Subject: [PATCH 05/37] feat(agent): bind contracts to provider requests --- .../tape-contract-lineage/tasks.md | 4 +- .../agent/deepchat/loop/contextCoordinator.ts | 65 ++++++++++++--- src/main/agent/deepchat/loop/loopRun.ts | 33 +++++++- .../deepchat/runtime/deepChatLoopRunner.ts | 81 ++++++++++++++++++- src/main/agent/deepchat/runtime/dispatch.ts | 17 +++- src/main/agent/deepchat/runtime/process.ts | 21 ++++- src/shared/types/tool.d.ts | 3 + .../harness/deepChatAgentHarness.test.ts | 24 ++++++ .../deepchat/loop/contextCoordinator.test.ts | 66 +++++++++++++++ test/main/agent/deepchat/loop/loopRun.test.ts | 44 ++++++++++ .../agent/deepchat/runtime/process.test.ts | 48 ++++++++++- 11 files changed, 385 insertions(+), 21 deletions(-) diff --git a/docs/architecture/tape-contract-lineage/tasks.md b/docs/architecture/tape-contract-lineage/tasks.md index 4f4231d8c..aae54ea20 100644 --- a/docs/architecture/tape-contract-lineage/tasks.md +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -9,7 +9,7 @@ ## P0: Contract Domains And Prompt Provenance -- [ ] Add canonical contract schemas, builders, hash versions, and domain tests. +- [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. @@ -20,7 +20,7 @@ - [x] Embed ExecutionContract in ViewManifest schema 5 and preserve v1-v4 reads. - [ ] Keep interactive manifest persistence fail-open and require contract-bearing child manifests. -- [ ] Carry the exact View contract to tool dispatch without Session-global mutable state. +- [x] Carry the exact View contract to tool dispatch without Session-global mutable state. - [ ] Enforce stable tool target, effect, workspace, and nesting ceilings with current authority. - [ ] Cover retries, tool rounds, revocation, expansion, and contract mismatch. - [ ] Review and commit the View/enforcement slice. diff --git a/src/main/agent/deepchat/loop/contextCoordinator.ts b/src/main/agent/deepchat/loop/contextCoordinator.ts index 3b9c8dc09..538aa6d6f 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,7 @@ export interface ProviderAttemptInput { budget: ProviderAttemptBudgetPort recovery: ProviderAttemptRecoveryPort manifest: ProviderAttemptManifestPort + executionContract?: ProviderAttemptExecutionContractPort rateGate: ProviderRateGatePort provider: ProviderAttemptStreamPort outcome: ProviderAttemptOutcomePort @@ -507,6 +527,7 @@ export class DeepChatContextCoordinator { providerMessages: ChatMessage[] providerMaxTokens: number requestSeq: number + executionContract: DeepChatExecutionContract | null }> => { let providerMessages = input.requestMessages let providerMaxTokens = input.maxTokens @@ -593,6 +614,27 @@ 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.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 {} + } + } + bindActiveRequestContract(input.run, requestSeq, executionContract) try { input.manifest.append({ requestSeq, @@ -616,14 +658,17 @@ 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) + try { + input.manifest.onAppendError(error) + } catch {} } - return { providerMessages, providerMaxTokens, requestSeq } + return { providerMessages, providerMaxTokens, requestSeq, executionContract } } const recoverProviderContextOverflow = async ( @@ -681,10 +726,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 +782,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 09034609e..989dc6491 100644 --- a/src/main/agent/deepchat/loop/loopRun.ts +++ b/src/main/agent/deepchat/loop/loopRun.ts @@ -2,6 +2,7 @@ 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' export interface LoopRunResources { toolDefinitions: MCPToolDefinition[] @@ -14,6 +15,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 +34,7 @@ export interface LoopRun { readonly streamState: TStreamState resources: LoopRunResources providerRecovery: LoopRunProviderRecovery + activeRequestContract: LoopRunRequestContractBinding | null } export interface CreateLoopRunInput { @@ -75,7 +82,8 @@ export function createLoopRun( providerRecovery: { contextOverflowHandoffAttempted: false, strictProviderOverflowRetryUsed: false - } + }, + activeRequestContract: null } } @@ -95,9 +103,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/runtime/deepChatLoopRunner.ts b/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts index 7a0b99d45..292b531cf 100644 --- a/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts +++ b/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts @@ -8,6 +8,7 @@ import type { 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 { ProviderExecutionPort, ModelConfig, @@ -20,6 +21,7 @@ import type { DeepChatTapeViewTokenBudget } from '@shared/types/tape-view-manifest' import { randomUUID } from 'node:crypto' +import { LIVE_DELEGATION_AGENT_TOOL_NAME } from '@shared/agentTools' import { getReasoningEffectiveEnabledForProvider } from '@shared/types/model-db' import { isTtsModelConfig, isTtsModelId } from '@shared/ttsSettings' import { nanoid } from 'nanoid' @@ -66,6 +68,7 @@ import { type TapeViewContextSelection } from '@/tape/domain/viewManifest' import type { DeepChatLoopTapePort } from '@/tape/ports/capabilities' +import { buildExecutionContract } from '@/tape/domain/executionContract' import { ExecutionJournalCorruptionError, ExecutionJournalError, @@ -224,6 +227,7 @@ export interface AppendTapeViewManifestInput { traceDebugEnabled: boolean contextBuilderVersion: 'legacy-v1' | 'cache-aware-v1' syntheticContributions?: DeepChatTapeViewSyntheticContribution[] + executionContract?: DeepChatExecutionContract } export interface DeepChatLoopRunnerPorts { @@ -299,6 +303,15 @@ function buildProviderContextOverflowAfterRecoveryErrorMessage( ].join(' ') } +function resolveExecutionContractSubagentDepth(tools: readonly MCPToolDefinition[]): number { + return tools.some( + (tool) => + tool.source === 'agent' && tool.function.name === LIVE_DELEGATION_AGENT_TOOL_NAME + ) + ? 1 + : 0 +} + function selectProcessTerminal(result: ProcessResult): ProcessTerminalSelection { let stopReason = result.stopReason if (!stopReason) { @@ -704,6 +717,62 @@ export class DeepChatLoopRunner { expectedInstance: resourceInstance }) }, + executionContract: { + 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 + }) + }, + onBuildError: (error) => + logger.warn( + `[DeepChatAgent] Failed to construct execution contract: ${ + error instanceof Error ? error.message : String(error) + }` + ) + }, manifest: { resolvePolicy: resolveTapeViewManifestPolicy, append: (manifest) => @@ -757,8 +826,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: { @@ -995,7 +1073,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/dispatch.ts b/src/main/agent/deepchat/runtime/dispatch.ts index f9ef2c1a9..5424d897a 100644 --- a/src/main/agent/deepchat/runtime/dispatch.ts +++ b/src/main/agent/deepchat/runtime/dispatch.ts @@ -12,6 +12,7 @@ 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 { parseQuestionToolArgs, QUESTION_TOOL_NAME @@ -1580,6 +1581,7 @@ async function runToolCall(params: { onToolCallStarted?: (toolCallId: string) => void executionJournal: Pick operationScope: Pick + executionContract?: DeepChatExecutionContract | null }): Promise { const { execution, @@ -1595,7 +1597,8 @@ async function runToolCall(params: { allowProgressUpdates, onToolCallStarted, executionJournal, - operationScope + operationScope, + executionContract } = params const { completedToolCall, toolCall, toolContext } = execution let returnedToolResult: MCPToolResponse | null = null @@ -1720,6 +1723,8 @@ async function runToolCall(params: { const enabledMcpServerIds = controls?.getEnabledMcpServerIds?.() const result = await toolExecution.execute(toolCall, { runId: io.requestId, + requestSeq: operationScope.requestSeq, + ...(executionContract ? { executionContract } : {}), onProgress: applyProgressUpdate, signal: io.abortSignal, permissionMode: toolPermissionMode, @@ -1962,6 +1967,7 @@ export interface SettleToolBatchParams { providerId?: string executionJournal: Pick operationScope: Pick + executionContract?: DeepChatExecutionContract | null } export async function settleToolBatch( @@ -1987,7 +1993,8 @@ export async function settleToolBatch( collaborators, providerId, executionJournal, - operationScope + operationScope, + executionContract } = params const { notificationObserver, controls, diagnostics, onToolCallStarted } = collaborators ?? {} if (disposition.kind === 'execute') { @@ -2166,7 +2173,8 @@ export async function settleToolBatch( allowProgressUpdates: false, onToolCallStarted, executionJournal, - operationScope + operationScope, + executionContract }) } catch (error) { if (isExecutionJournalError(error)) throw error @@ -2439,7 +2447,8 @@ export async function settleToolBatch( allowProgressUpdates: true, onToolCallStarted, executionJournal, - operationScope + operationScope, + executionContract }) batchState.invokedCallIds.add(tc.id) diff --git a/src/main/agent/deepchat/runtime/process.ts b/src/main/agent/deepchat/runtime/process.ts index 14d05b9ca..c42f80423 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, @@ -56,6 +57,8 @@ type ToolRoundBatch = { toolCalls: ToolCallResult[] disposition: ToolBatchDisposition nextAction: 'continue' | 'terminal' + requestSeq: number + executionContract: DeepChatExecutionContract | null } function getLatestErrorMessage(state: StreamState): string | null { @@ -1080,6 +1083,13 @@ export async function processStream(params: ProcessParams): Promise 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 9e9945b2b..697cb08e8 100644 --- a/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts +++ b/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts @@ -4034,6 +4034,8 @@ 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: 5, + hashVersion: 3, taskType: 'chat', policy: 'cache_aware_context_v1', policyVersion: 1, @@ -4043,12 +4045,34 @@ describe('DeepChatAgentHarness', () => { } }) expect(manifests[1]).toMatchObject({ + schemaVersion: 5, + hashVersion: 3, 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.map((manifest: any) => manifest.executionContract.request)).toEqual([ + expect.objectContaining({ + sessionId: 's1', + messageId: callArgs.run.messageId, + runId: callArgs.run.runId, + requestSeq: 1 + }), + expect.objectContaining({ + sessionId: 's1', + messageId: callArgs.run.messageId, + runId: callArgs.run.runId, + requestSeq: 2 + }) + ]) + expect(manifests[0].executionContract.provenance.promptHash).toBe( + manifests[0].hashes.promptHash + ) + expect(manifests[1].executionContract.provenance.providerVisibleToolDefinitionsHash).toBe( + manifests[1].hashes.toolDefinitionsHash + ) }) it('continues provider requests when view manifest persistence fails', async () => { diff --git a/test/main/agent/deepchat/loop/contextCoordinator.test.ts b/test/main/agent/deepchat/loop/contextCoordinator.test.ts index b2d8d6913..73df00954 100644 --- a/test/main/agent/deepchat/loop/contextCoordinator.test.ts +++ b/test/main/agent/deepchat/loop/contextCoordinator.test.ts @@ -74,12 +74,17 @@ function createAttemptInput(options?: { providerEvents?: LLMCoreStreamEvent[][] providerAttempts?: Array<{ events?: LLMCoreStreamEvent[]; error?: unknown }> appendManifest?: (manifest: any) => void + buildExecutionContract?: (input: any) => any viewContext?: false }) { 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[] = [] @@ -102,6 +107,10 @@ function createAttemptInput(options?: { order, manifests, providerRequests, + manifestContractRefs, + providerContractRefs, + contractBuildInputs, + executionContractErrors, manifestErrors, outcomes, outcomeErrors, @@ -157,6 +166,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 @@ -166,6 +191,7 @@ function createAttemptInput(options?: { }), append: (manifest: any) => { order.push(`manifest:${manifest.requestSeq}`) + manifestContractRefs.push(manifest.executionContract) manifests.push(structuredClone(manifest)) options?.appendManifest?.(manifest) }, @@ -187,6 +213,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: [] } @@ -348,6 +375,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', @@ -439,6 +471,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)) @@ -517,6 +553,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')) }) @@ -675,6 +716,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', @@ -688,6 +731,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 97b604e2e..c64dfa5e4 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 @@ -67,6 +68,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({ diff --git a/test/main/agent/deepchat/runtime/process.test.ts b/test/main/agent/deepchat/runtime/process.test.ts index ed6c6f43d..2f2e8a718 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' @@ -913,6 +916,49 @@ 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: [] }, + 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, + requestSeq: 1, + executionContract + }) + expect( + (toolService.callTool as ReturnType).mock.calls[0][1].executionContract + ).toBe(executionContract) + }) + it('counts a post-call permission tool before persisting pause', async () => { const toolService = createPostCallPermissionToolService() From d80bee718f573a743a336488aa5cfc255b0a35a0 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sat, 8 Aug 2026 23:56:28 +0800 Subject: [PATCH 06/37] feat(agent): enforce view execution ceilings --- .../tape-contract-lineage/plan.md | 2 + .../tape-contract-lineage/spec.md | 9 + .../tape-contract-lineage/tasks.md | 6 +- .../harness/createDeepChatAgentHarness.ts | 3 +- .../instance/deepChatAgentInstance.ts | 6 +- .../agent/deepchat/loop/contextCoordinator.ts | 6 + src/main/agent/deepchat/loop/ports.ts | 2 + .../deepchat/runtime/deepChatLoopRunner.ts | 5 +- .../runtime/deferredExecutionContract.ts | 92 ++++++ .../deepchat/runtime/deferredToolExecutor.ts | 12 +- src/main/agent/deepchat/runtime/dispatch.ts | 38 ++- .../runtime/interactionCoordinator.ts | 13 +- .../runtime/sessionIdentityService.ts | 5 + src/main/app/composition.ts | 50 +++ src/main/tape/domain/executionContract.ts | 223 +++++++++++++ src/main/tool/index.ts | 153 +++++++-- src/main/tool/runtimePorts.ts | 13 + src/shared/chat.d.ts | 1 + src/shared/types/agent-interface.d.ts | 1 + src/shared/types/core/chat.ts | 1 + src/shared/types/execution-contract.ts | 8 + src/shared/types/tool.d.ts | 1 + .../harness/deepChatAgentHarness.test.ts | 45 +++ .../deepchat/loop/contextCoordinator.test.ts | 35 ++ .../runtime/deferredExecutionContract.test.ts | 216 +++++++++++++ .../runtime/deferredToolExecutor.test.ts | 75 ++++- .../agent/deepchat/runtime/process.test.ts | 71 ++++ .../runtime/sessionIdentityService.test.ts | 8 + test/main/tape/executionContract.test.ts | 151 +++++++++ .../tool/agentTools/agentToolDependencies.ts | 4 +- test/main/tool/toolService.test.ts | 305 ++++++++++++++++++ 31 files changed, 1511 insertions(+), 49 deletions(-) create mode 100644 src/main/agent/deepchat/runtime/deferredExecutionContract.ts create mode 100644 test/main/agent/deepchat/runtime/deferredExecutionContract.test.ts diff --git a/docs/architecture/tape-contract-lineage/plan.md b/docs/architecture/tape-contract-lineage/plan.md index 4f1919670..580a0db7e 100644 --- a/docs/architecture/tape-contract-lineage/plan.md +++ b/docs/architecture/tape-contract-lineage/plan.md @@ -39,6 +39,8 @@ - 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, workspace scope, and nesting ceiling before crossing ToolService dispatch. - Retain existing live permission, workdir, deletion, and Subagent-authority checks as the current diff --git a/docs/architecture/tape-contract-lineage/spec.md b/docs/architecture/tape-contract-lineage/spec.md index be4767995..d3ee10603 100644 --- a/docs/architecture/tape-contract-lineage/spec.md +++ b/docs/architecture/tape-contract-lineage/spec.md @@ -144,6 +144,13 @@ value is retained by the loop run and passed to tool dispatch. A session-global 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: @@ -266,3 +273,5 @@ This table describes write disciplines, not a count of all Tape event families. 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 index aae54ea20..25a2e24ef 100644 --- a/docs/architecture/tape-contract-lineage/tasks.md +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -19,10 +19,10 @@ ## P0: ViewManifest V5 And Enforcement - [x] Embed ExecutionContract in ViewManifest schema 5 and preserve v1-v4 reads. -- [ ] Keep interactive manifest persistence fail-open and require contract-bearing child manifests. +- [x] Keep interactive manifest persistence fail-open and require contract-bearing child manifests. - [x] Carry the exact View contract to tool dispatch without Session-global mutable state. -- [ ] Enforce stable tool target, effect, workspace, and nesting ceilings with current authority. -- [ ] Cover retries, tool rounds, revocation, expansion, and contract mismatch. +- [x] Enforce stable tool target, effect, workspace, and nesting ceilings with current authority. +- [x] Cover retries, tool rounds, revocation, expansion, and contract mismatch. - [ ] Review and commit the View/enforcement slice. ## P1: Strict Contract Persistence diff --git a/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts b/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts index f5830b4ab..b80a39351 100644 --- a/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts +++ b/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts @@ -423,7 +423,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/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 538aa6d6f..ffb97480e 100644 --- a/src/main/agent/deepchat/loop/contextCoordinator.ts +++ b/src/main/agent/deepchat/loop/contextCoordinator.ts @@ -452,6 +452,7 @@ export interface ProviderAttemptInput { recovery: ProviderAttemptRecoveryPort manifest: ProviderAttemptManifestPort executionContract?: ProviderAttemptExecutionContractPort + strictViewContract?: boolean rateGate: ProviderRateGatePort provider: ProviderAttemptStreamPort outcome: ProviderAttemptOutcomePort @@ -616,6 +617,9 @@ export class DeepChatContextCoordinator { }) 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({ @@ -632,6 +636,7 @@ export class DeepChatContextCoordinator { try { input.executionContract.onBuildError(error) } catch {} + if (input.strictViewContract) throw error } } bindActiveRequestContract(input.run, requestSeq, executionContract) @@ -666,6 +671,7 @@ export class DeepChatContextCoordinator { try { input.manifest.onAppendError(error) } catch {} + if (input.strictViewContract) throw error } return { providerMessages, providerMaxTokens, requestSeq, executionContract } diff --git a/src/main/agent/deepchat/loop/ports.ts b/src/main/agent/deepchat/loop/ports.ts index f34f09126..15fa2ab32 100644 --- a/src/main/agent/deepchat/loop/ports.ts +++ b/src/main/agent/deepchat/loop/ports.ts @@ -13,6 +13,7 @@ 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' export interface ProviderRequest { runId: string @@ -87,6 +88,7 @@ export interface PersistedToolBatchState { readonly invokedCallIds: readonly string[] readonly committedResultCallIds: readonly string[] readonly pendingInteractionCallIds: readonly string[] + readonly executionContract?: DeepChatExecutionContract } export type ToolBatchOutcome< diff --git a/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts b/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts index 292b531cf..aaddc1077 100644 --- a/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts +++ b/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts @@ -253,7 +253,7 @@ export interface DeepChatLoopRunnerPorts { sessionSettings: Pick promptAssembly: Pick runLifecycle: LoopRunLifecyclePort - identity: Pick + identity: Pick sessionPermissionPort: SessionPermissionPort reviewToolPermission: ToolPermissionReviewer hookSink: Pick @@ -405,6 +405,8 @@ 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' const providerModelFacts = providedProviderModelFacts ?? @@ -773,6 +775,7 @@ export class DeepChatLoopRunner { }` ) }, + strictViewContract, manifest: { resolvePolicy: resolveTapeViewManifestPolicy, append: (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 b5c243eb7..4b2d8ef89 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' export type DeferredToolExecutionResult = { responseText: string @@ -97,7 +98,8 @@ export class DeferredToolExecutor { sessionId: string, messageId: string, toolCall: NonNullable, - onToolCallStarted?: () => void + onToolCallStarted?: () => void, + executionContract?: DeepChatExecutionContract ): Promise { const toolName = toolCall.name if (!toolName) { @@ -360,6 +362,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 5424d897a..0aa582281 100644 --- a/src/main/agent/deepchat/runtime/dispatch.ts +++ b/src/main/agent/deepchat/runtime/dispatch.ts @@ -13,6 +13,7 @@ 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 @@ -165,6 +166,7 @@ type MutableToolBatchState = { callOrder: string[] invokedCallIds: Set committedResultCallIds: Set + executionContract?: DeepChatExecutionContract } const USER_CANCELED_GENERATION_ERROR = 'common.error.userCanceledGeneration' @@ -175,11 +177,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 } : {}) } } @@ -288,7 +294,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 } : {}) } } @@ -1390,7 +1397,8 @@ function appendPermissionActionBlock( }, permission: NonNullable, origin: Extract, - order: number + order: number, + executionContract?: DeepChatExecutionContract | null ): ToolBatchInteraction { state.blocks.push({ type: 'action', @@ -1415,6 +1423,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 } : {}) } }) @@ -1723,6 +1738,7 @@ 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, @@ -2005,7 +2021,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++ @@ -2217,7 +2233,8 @@ export async function settleToolBatch( outcome.toolContext, outcome.permission, 'post-call-permission', - takeInteractionOrder() + takeInteractionOrder(), + executionContract ) pendingInteractions.push(interaction) updateToolCallBlock(batchToolCallBlocks, outcome.toolContext.id, '', false) @@ -2374,7 +2391,8 @@ export async function settleToolBatch( toolContext, preCheckedPermission, 'pre-check-permission', - takeInteractionOrder() + takeInteractionOrder(), + executionContract ) pendingInteractions.push(interaction) updateToolCallBlock(batchToolCallBlocks, tc.id, '', false) @@ -2415,7 +2433,8 @@ export async function settleToolBatch( toolContext, reviewPermission, 'pre-check-permission', - takeInteractionOrder() + takeInteractionOrder(), + executionContract ) pendingInteractions.push(interaction) updateToolCallBlock(batchToolCallBlocks, tc.id, '', false) @@ -2468,7 +2487,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 2dec9684f..929ad22fa 100644 --- a/src/main/agent/deepchat/runtime/interactionCoordinator.ts +++ b/src/main/agent/deepchat/runtime/interactionCoordinator.ts @@ -50,6 +50,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' const DEFERRED_INTERACTION_PARKED_ERROR = 'Execution is parked after an Execution Journal failure and will not be retried automatically.' @@ -86,6 +88,7 @@ export interface InteractionCoordinatorPorts { continuationAdmission: InteractionContinuationAdmissionPort publishEvent: DeepChatEventPublisher interactionParking: Pick + viewManifests: Pick } export interface InteractionContinuationAdmissionPort { @@ -256,6 +259,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() await awaitWithAbort( this.grantPermissionForPayload(sessionId, permissionPayload, toolCall), @@ -290,7 +300,8 @@ export class InteractionCoordinator { sessionId, messageId, toolCall, - markDeferredToolCallStarted + markDeferredToolCallStarted, + executionContract ) const refreshedInteraction = this.readLatestPendingInteraction( sessionId, 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/app/composition.ts b/src/main/app/composition.ts index 06df31aff..e831534a4 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -187,6 +187,7 @@ import { normalizeDeepChatSubagentSlots, resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' +import { composeSubagentAuthority } from '@/session/subagentAuthority' import type { AcpAsLlmProviderPermissionPort, AcpAsLlmProviderSessionControlPort, @@ -1100,6 +1101,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 !== false, + 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) { diff --git a/src/main/tape/domain/executionContract.ts b/src/main/tape/domain/executionContract.ts index 8c895e261..29c2696b4 100644 --- a/src/main/tape/domain/executionContract.ts +++ b/src/main/tape/domain/executionContract.ts @@ -19,9 +19,11 @@ import { 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, @@ -31,6 +33,7 @@ import { import { canonicalJsonStringifyData, hashJsonData } from './canonicalJson' 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 @@ -58,6 +61,7 @@ const EXECUTION_CONTRACT_KEYS = [ '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 = [ @@ -118,6 +122,95 @@ export class ExecutionContractError extends Error { } } +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') } @@ -328,6 +421,12 @@ function normalizeToolCeiling( } } +export function buildExecutionToolCeiling( + definition: MCPToolDefinition +): DeepChatExecutionToolCeiling { + return normalizeToolCeiling(definition, 0) +} + export function buildExecutionToolTargetKey(target: DeepChatExecutionToolTargetIdentity): string { return canonicalJsonStringifyData(target) } @@ -759,6 +858,130 @@ export function meetToolEffects(left: ToolEffect, right: ToolEffect): ToolEffect return isToolEffectWithinCeiling(left, right) ? left : right } +function normalizeWorkspaceForComparison( + workspace: DeepChatExecutionWorkspaceCeiling +): string | null { + if (workspace.kind === 'runtime_default') return null + if (path.win32.isAbsolute(workspace.path)) { + return `win32:${path.win32.resolve(workspace.path)}` + } + if (path.posix.isAbsolute(workspace.path)) { + return `posix:${path.posix.resolve(workspace.path)}` + } + return null +} + +function executionWorkspacesMatch( + current: DeepChatExecutionWorkspaceCeiling, + ceiling: DeepChatExecutionWorkspaceCeiling +): boolean { + if (current.kind === 'runtime_default' || ceiling.kind === 'runtime_default') { + return current.kind === ceiling.kind + } + const currentPath = normalizeWorkspaceForComparison(current) + const ceilingPath = normalizeWorkspaceForComparison(ceiling) + return currentPath !== null && currentPath === ceilingPath +} + +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)) { diff --git a/src/main/tool/index.ts b/src/main/tool/index.ts index a2a55be41..3707f6e9f 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, @@ -467,6 +470,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, @@ -654,6 +658,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 @@ -785,14 +888,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() @@ -800,7 +903,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( @@ -832,6 +935,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 @@ -934,18 +1054,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 void diff --git a/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts b/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts index 697cb08e8..6fdb01090 100644 --- a/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts +++ b/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts @@ -11845,6 +11845,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({ diff --git a/test/main/agent/deepchat/loop/contextCoordinator.test.ts b/test/main/agent/deepchat/loop/contextCoordinator.test.ts index 73df00954..1fe7e3d10 100644 --- a/test/main/agent/deepchat/loop/contextCoordinator.test.ts +++ b/test/main/agent/deepchat/loop/contextCoordinator.test.ts @@ -76,6 +76,7 @@ function createAttemptInput(options?: { appendManifest?: (manifest: any) => void buildExecutionContract?: (input: any) => any viewContext?: false + strictViewContract?: boolean }) { const run = createRun() const order: string[] = [] @@ -129,6 +130,7 @@ function createAttemptInput(options?: { supportsVision: true, supportsAudioInput: true, traceDebugEnabled: true, + strictViewContract: options?.strictViewContract, viewContext: options?.viewContext === false ? undefined @@ -488,6 +490,39 @@ describe('DeepChatContextCoordinator', () => { ]) }) + it.each([ + { + name: 'contract construction', + create: () => + createAttemptInput({ + strictViewContract: true, + buildExecutionContract: () => { + throw new Error('contract unavailable') + } + }), + message: 'contract unavailable' + }, + { + name: 'manifest persistence', + create: () => + createAttemptInput({ + strictViewContract: true, + appendManifest: () => { + throw new Error('manifest unavailable') + } + }), + message: 'manifest unavailable' + } + ])('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') + }) + it('keeps generation fail-open when provider outcome persistence throws', async () => { const fixture = createAttemptInput() const persistenceError = new Error('outcome 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 160d89ac5..c3df1ce28 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' const SESSION_ID = 'session-1' const MESSAGE_ID = 'message-1' @@ -13,6 +16,49 @@ const TOOL_CALL = { params: '{"path":"a.txt"}', response: '' } +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'] @@ -89,14 +135,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 () => []), @@ -176,6 +215,26 @@ 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) + + 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('returns a non-retryable terminal error when T2 persistence fails', async () => { const { executionJournal, executor, order } = createHarness() executionJournal.commitToolOutcome.mockImplementationOnce(() => { diff --git a/test/main/agent/deepchat/runtime/process.test.ts b/test/main/agent/deepchat/runtime/process.test.ts index 2f2e8a718..ba98313a9 100644 --- a/test/main/agent/deepchat/runtime/process.test.ts +++ b/test/main/agent/deepchat/runtime/process.test.ts @@ -45,6 +45,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)) @@ -951,6 +956,7 @@ describe('processStream', () => { expect(toolService.callTool).toHaveBeenCalled() expect((toolService.callTool as ReturnType).mock.calls[0][1]).toMatchObject({ runId: RUN_ID, + messageId: 'm1', requestSeq: 1, executionContract }) @@ -959,6 +965,71 @@ describe('processStream', () => { ).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: [] }, + 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' + ) + const permissionBlock = finalPauseCall?.[1].find( + (block) => block.action_type === 'tool_call_permission' + ) + 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() 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/tape/executionContract.test.ts b/test/main/tape/executionContract.test.ts index f386dd248..454b8baac 100644 --- a/test/main/tape/executionContract.test.ts +++ b/test/main/tape/executionContract.test.ts @@ -9,13 +9,19 @@ 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' @@ -394,6 +400,151 @@ describe('ExecutionContract domain', () => { 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\\' } 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 fb17c8a91..94b3332ba 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 = { From ee203697492a900e6d39b38a82e6bb10cfeb6f80 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 00:40:10 +0800 Subject: [PATCH 07/37] feat(tape): freeze delegation task contracts --- .../tape-contract-lineage/spec.md | 8 +- .../tape-contract-lineage/tasks.md | 12 +- src/main/app/composition.ts | 11 +- .../data/tables/liveDelegationTurns.ts | 170 ++++++- .../data/tables/liveDelegations.ts | 1 + .../orchestration/liveDelegationRepository.ts | 128 ++++- .../orchestration/liveDelegationService.ts | 34 +- .../liveDelegationTaskContract.ts | 42 ++ src/main/session/data/database.ts | 11 +- src/main/tape/application/lineageService.ts | 23 +- .../tape/application/taskContractService.ts | 201 ++++++++ src/main/tape/domain/contractFacts.ts | 7 + src/main/tape/domain/effectiveView.ts | 7 +- src/main/tape/domain/tapeIdentity.ts | 24 + src/main/tape/domain/taskContract.ts | 464 ++++++++++++++++++ .../infrastructure/sqlite/tapeEntryStore.ts | 60 ++- src/main/tape/ports/storage.ts | 10 + src/shared/orchestration/liveDelegation.ts | 12 +- src/shared/types/task-contract.ts | 201 ++++++++ .../liveDelegationRepository.test.ts | 201 +++++++- .../liveDelegationService.test.ts | 30 +- test/main/tape/taskContract.test.ts | 269 ++++++++++ .../main/tape/taskContractPersistence.test.ts | 144 ++++++ 23 files changed, 1990 insertions(+), 80 deletions(-) create mode 100644 src/main/orchestration/liveDelegationTaskContract.ts create mode 100644 src/main/tape/application/taskContractService.ts create mode 100644 src/main/tape/domain/contractFacts.ts create mode 100644 src/main/tape/domain/tapeIdentity.ts create mode 100644 src/main/tape/domain/taskContract.ts create mode 100644 src/shared/types/task-contract.ts create mode 100644 test/main/tape/taskContract.test.ts create mode 100644 test/main/tape/taskContractPersistence.test.ts diff --git a/docs/architecture/tape-contract-lineage/spec.md b/docs/architecture/tape-contract-lineage/spec.md index d3ee10603..1d2777873 100644 --- a/docs/architecture/tape-contract-lineage/spec.md +++ b/docs/architecture/tape-contract-lineage/spec.md @@ -2,11 +2,11 @@ ## Status -Proposed for implementation. This architecture extends DeepChat's existing Tape, provider View, -and live-delegation execution planes with explicit task and execution contracts. It does not add a -second scheduler or make Tape an online permission service. +In implementation. P0 is complete and P1 is in progress. This architecture extends DeepChat's +existing Tape, provider View, and live-delegation execution planes with explicit task and execution +contracts. It does not add a second scheduler or make Tape an online permission service. -Last reviewed: 2026-08-08. +Last reviewed: 2026-08-09. ## Decision diff --git a/docs/architecture/tape-contract-lineage/tasks.md b/docs/architecture/tape-contract-lineage/tasks.md index 25a2e24ef..6d2f4e567 100644 --- a/docs/architecture/tape-contract-lineage/tasks.md +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -23,16 +23,16 @@ - [x] Carry the exact View contract to tool dispatch without Session-global mutable state. - [x] Enforce stable tool target, effect, workspace, and nesting ceilings with current authority. - [x] Cover retries, tool rounds, revocation, expansion, and contract mismatch. -- [ ] Review and commit the View/enforcement slice. +- [x] Review and commit the View/enforcement slice. ## P1: Strict Contract Persistence -- [ ] Reserve `contract/*` and add a transaction-aware strict Tape capability. -- [ ] Add complete Tape identity and canonical conflict validation. -- [ ] Add nullable live-delegation contract/evaluation projection columns and migration coverage. -- [ ] Atomically freeze parent TaskContract with initial and follow-up turn creation. +- [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. - [ ] Re-anchor hash-verified runtime projections after parent Tape reset. -- [ ] Review and commit the parent-freeze/storage slice. +- [x] Review and commit the parent-freeze/storage foundation slice. ## P1: Child Inheritance diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index e831534a4..0acdd7e7c 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -168,6 +168,7 @@ 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 { createProjectRoutes } from '../project/routes' import { RemoteService } from '../remote' import type { RemoteServiceLike } from '../remote/ports' @@ -303,9 +304,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 @@ -563,6 +561,13 @@ export async function createMainProcessControl(dependencies: { }) } ) + const taskContractService = new TaskContractService( + () => sessionData.database.deepchatContractStore + ) + const liveDelegationRepository = new LiveDelegationRepository( + new LiveDelegationDatabase(mainDatabase), + taskContractService + ) const sessionRuntimeEvents = new SessionRuntimeEvents() const projectDatabase = new ProjectDatabase(mainDatabase) const agentDatabase = dependencies.agentDatabase diff --git a/src/main/orchestration/data/tables/liveDelegationTurns.ts b/src/main/orchestration/data/tables/liveDelegationTurns.ts index 8005f1a34..b5efc6fd1 100644 --- a/src/main/orchestration/data/tables/liveDelegationTurns.ts +++ b/src/main/orchestration/data/tables/liveDelegationTurns.ts @@ -7,6 +7,12 @@ 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 +} 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 +29,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 +68,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_CONTRACT_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 +149,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 +164,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 +200,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_CONTRACT_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 +296,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 +327,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..b0d5f7e40 100644 --- a/src/main/orchestration/data/tables/liveDelegations.ts +++ b/src/main/orchestration/data/tables/liveDelegations.ts @@ -6,6 +6,7 @@ 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 interface LiveDelegationRow { delegation_id: string diff --git a/src/main/orchestration/liveDelegationRepository.ts b/src/main/orchestration/liveDelegationRepository.ts index 2b32227e9..1142b36ba 100644 --- a/src/main/orchestration/liveDelegationRepository.ts +++ b/src/main/orchestration/liveDelegationRepository.ts @@ -32,6 +32,15 @@ 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 { ParentTaskContractWriter } from '@/tape/application/taskContractService' +import { + buildTaskContract, + restoreTaskContract, + restoreTaskContractRef, + serializeTaskContract, + serializeTaskContractRef +} from '@/tape/domain/taskContract' +import type { LiveDelegationTaskContractInput } from './liveDelegationTaskContract' const MAX_RETAINED_CONSUMED_MESSAGES_PER_PARENT = 500 const MAX_LIST_LIMIT = 100 @@ -62,6 +71,7 @@ export interface CreateLiveDelegationInput { targetAgentId: string title: string prompt: string + taskContract: LiveDelegationTaskContractInput now?: number } @@ -73,7 +83,10 @@ export interface LiveDelegationWithTurn { export interface ActiveLiveDelegationTurn extends LiveDelegationWithTurn {} export class LiveDelegationRepository { - constructor(private readonly database: LiveDelegationDatabase) {} + constructor( + private readonly database: LiveDelegationDatabase, + private readonly taskContracts: ParentTaskContractWriter + ) {} create(input: CreateLiveDelegationInput, beforeMutation?: () => void): LiveDelegationWithTurn { const id = StoredIdSchema.parse(input.id) @@ -84,6 +97,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 +123,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) @@ -297,6 +330,7 @@ export class LiveDelegationRepository { delegationId: string, turnId: string, task: string, + taskContractInput: LiveDelegationTaskContractInput, now = Date.now(), beforeMutation?: () => void ): LiveDelegationWithTurn { @@ -332,14 +366,43 @@ export class LiveDelegationRepository { ) validateBytes(prompt, LIVE_DELEGATION_MAX_PROMPT_BYTES, 'Follow-up task with messages') const nextSeq = delegation.lastTurnSeq + 1 + 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 + }) + 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( @@ -692,7 +755,33 @@ 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) + if ((taskContract === null) !== (taskContractRef === null)) { + throw new Error( + `Live delegation turn ${row.turn_id} has an incomplete TaskContract projection.` + ) + } + if (taskContract && taskContractRef?.contractHash !== taskContract.contractHash) { + throw new Error(`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 Error(`Live delegation turn ${row.turn_id} has a misbound TaskContract projection.`) + } + } + const parsed = LiveDelegationTurnSchema.parse({ id: row.turn_id, delegationId: row.delegation_id, seq: row.seq, @@ -703,6 +792,9 @@ function toTurn(row: LiveDelegationTurnRow): LiveDelegationTurn { error: row.error, resultRef: parseResultRef(row.result_ref_json), tapeReceipt: parseTapeReceipt(row.tape_receipt_json), + taskContract, + taskContractRef, + inheritedTaskContractRef, effectState: row.effect_state, effectEvidence: parseEffectEvidence(row.effect_evidence_json), createdAt: row.created_at, @@ -710,6 +802,12 @@ function toTurn(row: LiveDelegationTurnRow): LiveDelegationTurn { updatedAt: row.updated_at, completedAt: row.completed_at }) + return { + ...parsed, + taskContract, + taskContractRef, + inheritedTaskContractRef + } } function toEvent(row: LiveDelegationEventRow): LiveDelegationEvent { @@ -837,6 +935,20 @@ function parseEffectEvidence(value: string | null): OrchestrationEffectEvidence return value ? OrchestrationEffectEvidenceSchema.parse(JSON.parse(value)) : null } +function parseTaskContract(value: string | null) { + if (!value) return null + const contract = restoreTaskContract(JSON.parse(value)) + if (!contract) throw new Error('Stored live delegation TaskContract is malformed.') + return contract +} + +function parseTaskContractRef(value: string | null) { + if (!value) return null + const ref = restoreTaskContractRef(JSON.parse(value)) + if (!ref) throw new Error('Stored live delegation TaskContract 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..aabd454f4 100644 --- a/src/main/orchestration/liveDelegationService.ts +++ b/src/main/orchestration/liveDelegationService.ts @@ -53,6 +53,10 @@ import type { LiveDelegationConsentReceipt, LiveDelegationConsentVerifier } from './liveDelegationConsent' +import { + createLiveDelegationTaskContractInput, + LIVE_DELEGATION_REQUIRED_RESULT_SECTIONS +} from './liveDelegationTaskContract' const MAX_WAITERS = 32 const DEFAULT_WAIT_TIMEOUT_MS = 30_000 @@ -256,6 +260,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 +270,8 @@ export class LiveDelegationService { slotId: slot.id, targetAgentId, title: input.title, - prompt: input.prompt + prompt: input.prompt, + taskContract: createLiveDelegationTaskContractInput(projectDir) }, beforeMutation ) @@ -335,7 +341,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 +356,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 ) @@ -1398,8 +1404,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 +1483,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 +1513,7 @@ export class LiveDelegationService { } function buildTurnHandoff(delegation: LiveDelegation, turn: LiveDelegationTurn): string { + const [handoffSection, ...remainingSections] = LIVE_DELEGATION_REQUIRED_RESULT_SECTIONS return [ '# DeepChat Live Delegation', '', @@ -1512,14 +1524,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:', diff --git a/src/main/orchestration/liveDelegationTaskContract.ts b/src/main/orchestration/liveDelegationTaskContract.ts new file mode 100644 index 000000000..3d08ab7d4 --- /dev/null +++ b/src/main/orchestration/liveDelegationTaskContract.ts @@ -0,0 +1,42 @@ +import type { + DeepChatEvaluationRef, + DeepChatTaskAcceptanceRequirement, + DeepChatTaskWorkspaceCeiling +} from '@shared/types/task-contract' + +export const LIVE_DELEGATION_REQUIRED_RESULT_SECTIONS = [ + 'Handoff', + 'Result', + 'Evidence', + 'Changed Files', + 'Validation', + 'Unresolved' +] as const + +export interface LiveDelegationTaskContractInput { + workspace: DeepChatTaskWorkspaceCeiling + acceptance: readonly DeepChatTaskAcceptanceRequirement[] + predecessorEvaluationRef: DeepChatEvaluationRef | null + maxToolEffect: 'read' | 'write' + maxSubagentDepth: number +} + +export function createLiveDelegationTaskContractInput( + projectDir: string | null, + predecessorEvaluationRef: DeepChatEvaluationRef | null = null +): LiveDelegationTaskContractInput { + return { + workspace: projectDir ? { kind: 'path', path: projectDir } : { kind: 'runtime_default' }, + acceptance: [ + { + id: 'live-delegation-required-sections', + kind: 'required_sections', + level: 2, + sections: LIVE_DELEGATION_REQUIRED_RESULT_SECTIONS + } + ], + predecessorEvaluationRef, + maxToolEffect: 'write', + maxSubagentDepth: 0 + } +} 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/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..5a1c5c223 --- /dev/null +++ b/src/main/tape/application/taskContractService.ts @@ -0,0 +1,201 @@ +import { + DEEPCHAT_TASK_CONTRACT_REF_SCHEMA_VERSION, + type DeepChatTaskContract, + type DeepChatTaskContractRef +} from '@shared/types/task-contract' +import { isDeepChatTaskContract, 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 ParentTaskContractFactData = { + schemaVersion: typeof TASK_CONTRACT_FACT_SCHEMA_VERSION + delivery: 'parent_frozen' + contract: DeepChatTaskContract + originRef: null + supersedesRef: null +} + +type StrictTaskContractEventInput = Omit & { + name: typeof TASK_CONTRACT_FACT_NAME + source: NonNullable + provenanceKey: string + data: ParentTaskContractFactData +} + +export interface FreezeParentTaskContractInput { + parentSessionId: string + contract: DeepChatTaskContract + createdAt?: number +} + +export interface TaskContractCommitReceipt { + contract: DeepChatTaskContract + ref: DeepChatTaskContractRef + created: boolean +} + +export interface ParentTaskContractWriter { + freezeParentTaskContract(input: FreezeParentTaskContractInput): 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 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) +} + +export class TaskContractService implements ParentTaskContractWriter { + constructor(private readonly getStore: () => ContractPersistenceStore) {} + + freezeParentTaskContract(input: FreezeParentTaskContractInput): 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.parentSessionId !== description.parentSessionId) { + throw new TaskContractPersistenceError( + 'TaskContract parent Session does not match its persistence target.', + '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( + 'Parent TaskContract freeze 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 TaskContractPersistenceError( + `Parent Tape ${input.parentSessionId} has no stable identity.`, + 'persistence_failed' + ) + } + const tapeIdentity = computeTapeIdentity(firstEntry) + const provenanceKey = `contract:task_frozen:v1:parent:${description.turnId}` + const event: StrictTaskContractEventInput = { + sessionId: input.parentSessionId, + name: TASK_CONTRACT_FACT_NAME, + source: { type: 'subagent', id: description.turnId, seq: description.turnSeq }, + provenanceKey, + data: { + schemaVersion: TASK_CONTRACT_FACT_SCHEMA_VERSION, + delivery: 'parent_frozen', + contract: input.contract, + originRef: null, + supersedesRef: null + }, + meta: { protocolVersion: TASK_CONTRACT_FACT_PROTOCOL_VERSION }, + createdAt: input.createdAt + } + + const existing = store.getByProvenanceKey(input.parentSessionId, provenanceKey) + if (existing) { + if (!rowMatchesTaskContractFact(existing, event)) { + throw new TaskContractPersistenceError( + `Stored TaskContract conflicts with turn ${description.turnId}.`, + 'corruption' + ) + } + return { + contract: input.contract, + ref: buildTaskContractRef(existing, tapeIdentity, input.contract.contractHash), + created: false + } + } + + 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/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/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..d67f5d9e6 --- /dev/null +++ b/src/main/tape/domain/taskContract.ts @@ -0,0 +1,464 @@ +import { Buffer } from 'node:buffer' +import path from 'node:path' +import { + DEEPCHAT_TASK_CONTRACT_HASH_VERSION, + DEEPCHAT_TASK_CONTRACT_SCHEMA_VERSION, + MAX_TASK_CONTRACT_BYTES, + MAX_TASK_CONTRACT_REQUIREMENTS, + MAX_TASK_CONTRACT_RESULT_SCHEMA_BYTES, + type DeepChatEvaluationRef, + type DeepChatTaskAcceptanceRequirement, + type DeepChatTaskContract, + type DeepChatTaskContractRef, + type DeepChatTaskWorkspaceCeiling +} from '@shared/types/task-contract' +import type { JsonValue } from '@shared/contracts/json' +import { canonicalJsonStringifyData, hashJsonData } from './canonicalJson' + +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 MAX_RESULT_SCHEMA_DEPTH = 64 +const MAX_RESULT_SCHEMA_NODES = 4_096 +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 + acceptance: readonly DeepChatTaskAcceptanceRequirement[] + 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') + } + if ( + !workspace.path || + workspace.path.includes('\0') || + utf8Length(workspace.path) > MAX_WORKSPACE_PATH_BYTES || + !path.isAbsolute(workspace.path) + ) { + throw new TaskContractError('workspace.path must be a bounded absolute path.', 'invalid_input') + } + return { kind: 'path', path: path.normalize(workspace.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 normalizeJsonValue(value: JsonValue, label: string): JsonValue { + assertBoundedJsonSchema(value, label, 0, { nodes: 0, ancestors: new Set() }) + let serialized: string + try { + serialized = canonicalJsonStringifyData(value) + } catch (error) { + throw new TaskContractError(`${label} must contain only JSON data.`, 'invalid_input', { + cause: error + }) + } + if (utf8Length(serialized) > MAX_TASK_CONTRACT_RESULT_SCHEMA_BYTES) { + throw new TaskContractError( + `${label} exceeds ${MAX_TASK_CONTRACT_RESULT_SCHEMA_BYTES} UTF-8 bytes.`, + 'limit_exceeded' + ) + } + const normalized = JSON.parse(serialized) as JsonValue + return normalized +} + +function assertBoundedJsonSchema( + value: unknown, + label: string, + depth: number, + state: { nodes: number; ancestors: Set } +): void { + state.nodes += 1 + if (depth > MAX_RESULT_SCHEMA_DEPTH || state.nodes > MAX_RESULT_SCHEMA_NODES) { + throw new TaskContractError( + `${label} exceeds the structural complexity limit.`, + 'limit_exceeded' + ) + } + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return + } + if (!value || typeof value !== 'object') { + throw new TaskContractError(`${label} must contain only JSON data.`, 'invalid_input') + } + if (state.ancestors.has(value)) { + throw new TaskContractError(`${label} must not contain circular references.`, 'invalid_input') + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new TaskContractError(`${label} must not contain symbol properties.`, 'invalid_input') + } + + state.ancestors.add(value) + try { + if (Array.isArray(value)) { + const keys = Object.getOwnPropertyNames(value).filter((key) => key !== 'length') + if (keys.length !== value.length) { + throw new TaskContractError(`${label} must not contain sparse arrays.`, 'invalid_input') + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)) + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new TaskContractError( + `${label} must contain only data properties.`, + 'invalid_input' + ) + } + assertBoundedJsonSchema(descriptor.value, label, depth + 1, state) + } + return + } + + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + throw new TaskContractError(`${label} must contain only plain objects.`, 'invalid_input') + } + for (const key of Object.getOwnPropertyNames(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new TaskContractError(`${label} must contain only data properties.`, 'invalid_input') + } + if (key === '$ref') { + throw new TaskContractError(`${label} must not contain $ref.`, 'invalid_input') + } + assertBoundedJsonSchema(descriptor.value, label, depth + 1, state) + } + } finally { + state.ancestors.delete(value) + } +} + +function normalizeAcceptance( + requirements: readonly DeepChatTaskAcceptanceRequirement[] +): DeepChatTaskAcceptanceRequirement[] { + if (!Array.isArray(requirements)) { + throw new TaskContractError('acceptance must be an array.', 'invalid_input') + } + if (requirements.length > MAX_TASK_CONTRACT_REQUIREMENTS) { + throw new TaskContractError( + `acceptance exceeds ${MAX_TASK_CONTRACT_REQUIREMENTS} requirements.`, + 'limit_exceeded' + ) + } + const ids = new Set() + const normalized = requirements.map((requirement, index) => { + const label = `acceptance[${index}]` + const id = requireString(requirement?.id, `${label}.id`, MAX_IDENTITY_BYTES) + if (ids.has(id)) { + throw new TaskContractError( + `acceptance requirement ID is duplicated: ${id}.`, + 'invalid_input' + ) + } + ids.add(id) + + if (requirement.kind === 'required_sections') { + if (requirement.level !== 2 || !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 } + } + + if (requirement.kind === 'result_schema') { + return { + id, + kind: 'result_schema' as const, + section: requireString(requirement.section, `${label}.section`, MAX_SECTION_NAME_BYTES), + schema: normalizeJsonValue(requirement.schema, `${label}.schema`) + } + } + throw new TaskContractError(`${label}.kind is invalid.`, 'invalid_input') + }) + 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') + } + + 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', + predecessorEvaluationRef: normalizeEvaluationRef(input.predecessorEvaluationRef ?? null) + }, + 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: requireString( + input.parentSessionId, + 'parentSessionId', + MAX_IDENTITY_BYTES, + 256 + ), + 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: normalizeAcceptance(input.acceptance), + 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, + acceptance: contract.taskHarness.acceptance, + 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 ( + !hasExactKeys(ref, TASK_CONTRACT_REF_KEYS) || + ref?.schemaVersion !== 1 || + requireString(ref.sessionId, 'TaskContractRef.sessionId', MAX_IDENTITY_BYTES, 256) !== + ref.sessionId || + !SHA_256_PATTERN.test(ref.tapeIdentity) || + !SHA_256_PATTERN.test(ref.contractHash) || + requirePositiveSafeInteger(ref.entryId, 'TaskContractRef.entryId') !== ref.entryId + ) { + throw new TaskContractError('TaskContractRef is invalid.', 'invalid_input') + } + return canonicalJsonStringifyData(ref) +} + +export function restoreTaskContractRef(value: unknown): DeepChatTaskContractRef | null { + try { + const ref = value as DeepChatTaskContractRef + serializeTaskContractRef(ref) + return deepFreeze(ref) + } catch { + return null + } +} 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/shared/orchestration/liveDelegation.ts b/src/shared/orchestration/liveDelegation.ts index 1e7a6ff65..9ae04d683 100644 --- a/src/shared/orchestration/liveDelegation.ts +++ b/src/shared/orchestration/liveDelegation.ts @@ -1,5 +1,9 @@ import { z } from 'zod' import { OrchestrationEffectEvidenceSchema, OrchestrationEffectStateSchema } from './toolEffect' +import { + DeepChatTaskContractProjectionSchema, + DeepChatTaskContractRefSchema +} from '../types/task-contract' export const LIVE_DELEGATION_SCHEMA_VERSION = 1 export const LIVE_DELEGATION_MAX_TITLE_LENGTH = 160 @@ -133,6 +137,9 @@ const LiveDelegationTurnBaseSchema = z error: z.string().nullable(), resultRef: LiveDelegationResultRefSchema.nullable().default(null), tapeReceipt: LiveDelegationTapeReceiptSchema.nullable(), + taskContract: DeepChatTaskContractProjectionSchema.nullable().default(null), + taskContractRef: DeepChatTaskContractRefSchema.nullable().default(null), + inheritedTaskContractRef: DeepChatTaskContractRefSchema.nullable().default(null), effectState: OrchestrationEffectStateSchema, effectEvidence: OrchestrationEffectEvidenceSchema.nullable(), createdAt: z.number().int().nonnegative(), @@ -185,7 +192,10 @@ export const LiveDelegationSummarySchema = LiveDelegationSchema.omit({ export const LiveDelegationTurnSummarySchema = LiveDelegationTurnBaseSchema.omit({ prompt: true, resultSummary: true, - error: true + error: true, + taskContract: true, + taskContractRef: true, + inheritedTaskContractRef: true }) .extend({ promptPreview: z.string().max(LIVE_DELEGATION_MAX_PREVIEW_CHARACTERS), diff --git a/src/shared/types/task-contract.ts b/src/shared/types/task-contract.ts new file mode 100644 index 000000000..01e9374c0 --- /dev/null +++ b/src/shared/types/task-contract.ts @@ -0,0 +1,201 @@ +import { z } from 'zod' +import { JsonValueSchema, type JsonValue } from '../contracts/json' + +export const DEEPCHAT_TASK_CONTRACT_SCHEMA_VERSION = 1 as const +export const DEEPCHAT_TASK_CONTRACT_HASH_VERSION = 1 as const +export const DEEPCHAT_TASK_CONTRACT_REF_SCHEMA_VERSION = 1 as const +export const DEEPCHAT_EVALUATION_REF_SCHEMA_VERSION = 1 as const + +export const MAX_TASK_CONTRACT_BYTES = 128 * 1024 +export const MAX_TASK_CONTRACT_REQUIREMENTS = 64 +export const MAX_TASK_CONTRACT_RESULT_SCHEMA_BYTES = 32 * 1024 +export const MAX_TASK_CONTRACT_REF_BYTES = 4 * 1024 +export const MAX_TASK_EVALUATION_BYTES = 32 * 1024 + +export interface DeepChatTaskContractRef { + readonly schemaVersion: typeof DEEPCHAT_TASK_CONTRACT_REF_SCHEMA_VERSION + readonly sessionId: string + readonly tapeIdentity: string + readonly entryId: number + readonly contractHash: string +} + +export interface DeepChatEvaluationRef { + readonly schemaVersion: typeof DEEPCHAT_EVALUATION_REF_SCHEMA_VERSION + readonly sessionId: string + readonly tapeIdentity: string + readonly entryId: number + readonly evaluationHash: string +} + +export interface DeepChatTaskSchema { + readonly input: { + readonly kind: 'text' + readonly maxBytes: number + } + readonly output: { + readonly kind: 'markdown' + } +} + +export interface DeepChatTaskConfig { + readonly completionMode: 'single_response' + readonly retryMode: 'parent_follow_up' + readonly predecessorEvaluationRef: DeepChatEvaluationRef | null +} + +export interface DeepChatTaskDescription { + readonly delegationId: string + readonly turnId: string + readonly turnSeq: number + readonly turnKind: 'initial' | 'follow_up' + readonly parentSessionId: string + readonly slotId: string + readonly targetAgentId: string + readonly title: string + readonly prompt: string +} + +export type DeepChatTaskWorkspaceCeiling = + | { readonly kind: 'path'; readonly path: string } + | { readonly kind: 'runtime_default' } + +export interface DeepChatRequiredSectionsAcceptance { + readonly id: string + readonly kind: 'required_sections' + readonly level: 2 + readonly sections: readonly string[] +} + +export interface DeepChatResultSchemaAcceptance { + readonly id: string + readonly kind: 'result_schema' + readonly section: string + readonly schema: JsonValue +} + +export type DeepChatTaskAcceptanceRequirement = + | DeepChatRequiredSectionsAcceptance + | DeepChatResultSchemaAcceptance + +export interface DeepChatTaskHarness { + readonly acceptance: readonly DeepChatTaskAcceptanceRequirement[] + readonly ceilings: { + readonly maxToolEffect: 'read' | 'write' + readonly workspace: DeepChatTaskWorkspaceCeiling + readonly maxSubagentDepth: number + } +} + +export interface DeepChatTaskContract { + readonly schemaVersion: typeof DEEPCHAT_TASK_CONTRACT_SCHEMA_VERSION + readonly hashVersion: typeof DEEPCHAT_TASK_CONTRACT_HASH_VERSION + readonly taskSchema: DeepChatTaskSchema + readonly taskConfig: DeepChatTaskConfig + readonly taskDescription: DeepChatTaskDescription + readonly taskHarness: DeepChatTaskHarness + readonly contractHash: string +} + +const StoredIdSchema = z.string().trim().min(1).max(256) +const Sha256Schema = z.string().regex(/^[0-9a-f]{64}$/u) + +export const DeepChatTaskContractRefSchema = z + .object({ + schemaVersion: z.literal(DEEPCHAT_TASK_CONTRACT_REF_SCHEMA_VERSION), + sessionId: StoredIdSchema, + tapeIdentity: Sha256Schema, + entryId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + contractHash: Sha256Schema + }) + .strict() + +export const DeepChatEvaluationRefSchema = z + .object({ + schemaVersion: z.literal(DEEPCHAT_EVALUATION_REF_SCHEMA_VERSION), + sessionId: StoredIdSchema, + tapeIdentity: Sha256Schema, + entryId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + evaluationHash: Sha256Schema + }) + .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 DeepChatTaskAcceptanceRequirementSchema = z.discriminatedUnion('kind', [ + 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(), + z + .object({ + id: StoredIdSchema, + kind: z.literal('result_schema'), + section: z.string().trim().min(1).max(256), + schema: JsonValueSchema + }) + .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'), + 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(DeepChatTaskAcceptanceRequirementSchema) + .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/test/main/orchestration/liveDelegationRepository.test.ts b/test/main/orchestration/liveDelegationRepository.test.ts index d1002a0fd..177826de6 100644 --- a/test/main/orchestration/liveDelegationRepository.test.ts +++ b/test/main/orchestration/liveDelegationRepository.test.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { Database, nativeSqliteDescribeIf } from '../nativeSqliteHarness' +import { createLiveDelegationTaskContractInput } from '@/orchestration/liveDelegationTaskContract' const databaseModule = Database ? await import('@/orchestration/data/database').catch(() => null) @@ -17,6 +18,12 @@ 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 DatabaseCtor = Database! const LiveDelegationDatabaseCtor = databaseModule?.LiveDelegationDatabase! @@ -24,13 +31,18 @@ const LiveDelegationsTableCtor = delegationsModule?.LiveDelegationsTable! const LiveDelegationTurnsTableCtor = turnsModule?.LiveDelegationTurnsTable! const LiveDelegationEventsTableCtor = eventsModule?.LiveDelegationEventsTable! const LiveDelegationRepositoryCtor = repositoryModule?.LiveDelegationRepository! +const DeepChatContractStoreCtor = tapeStoreModule?.DeepChatContractStore! +const TaskContractServiceCtor = taskContractServiceModule?.TaskContractService! +const CONTRACT_SCHEMA_VERSION = delegationsModule?.LIVE_DELEGATION_CONTRACT_DATABASE_SCHEMA_VERSION! const describeIfSqlite = nativeSqliteDescribeIf( Boolean( LiveDelegationDatabaseCtor && LiveDelegationsTableCtor && LiveDelegationTurnsTableCtor && LiveDelegationEventsTableCtor && - LiveDelegationRepositoryCtor + LiveDelegationRepositoryCtor && + DeepChatContractStoreCtor && + TaskContractServiceCtor ), 'Live delegation persistence modules are unavailable' ) @@ -38,6 +50,7 @@ const describeIfSqlite = nativeSqliteDescribeIf( describeIfSqlite('LiveDelegationRepository', () => { let db: InstanceType | null let repository: InstanceType + let contractStore: InstanceType beforeEach(() => { db = new DatabaseCtor(':memory:') @@ -52,8 +65,11 @@ 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) ) addSession('parent') }) @@ -81,6 +97,7 @@ describeIfSqlite('LiveDelegationRepository', () => { targetAgentId: 'agent-1', title: 'Review architecture', prompt: 'Review module boundaries.', + taskContract: createLiveDelegationTaskContractInput(null), now: 100 }) } @@ -105,6 +122,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 +154,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 +177,7 @@ describeIfSqlite('LiveDelegationRepository', () => { targetAgentId: 'agent-1', title: 'Commit before mutation', prompt: 'Verify transaction ownership.', + taskContract: createLiveDelegationTaskContractInput(null), now: 101 }, commitReceipt('receipt-success') @@ -150,6 +193,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 +218,8 @@ describeIfSqlite('LiveDelegationRepository', () => { slotId: 'reviewer', targetAgentId: 'agent-1', title: 'Orphan', - prompt: 'Do work.' + prompt: 'Do work.', + taskContract: createLiveDelegationTaskContractInput(null) }, beforeMutation ) @@ -182,6 +227,132 @@ 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') + } + } + ) + + 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(/misbound TaskContract projection/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 +363,7 @@ describeIfSqlite('LiveDelegationRepository', () => { targetAgentId: 'agent-1', title: `Review ${index}`, prompt: `Inspect boundary ${index}.`, + taskContract: createLiveDelegationTaskContractInput(null), now: 100 + index }) @@ -214,6 +386,7 @@ describeIfSqlite('LiveDelegationRepository', () => { replacement.delegation.id, 'turn-follow-up', 'Continue the review.', + createLiveDelegationTaskContractInput(null), 140 ) ).toThrow('at most 5 active live delegations') @@ -267,13 +440,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 +532,7 @@ describeIfSqlite('LiveDelegationRepository', () => { 'delegation-1', 'turn-2', 'Continue with the bounded evidence.', + createLiveDelegationTaskContractInput(null), 130 ) @@ -372,7 +554,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( diff --git a/test/main/orchestration/liveDelegationService.test.ts b/test/main/orchestration/liveDelegationService.test.ts index 5541614af..49bb2a222 100644 --- a/test/main/orchestration/liveDelegationService.test.ts +++ b/test/main/orchestration/liveDelegationService.test.ts @@ -10,6 +10,7 @@ 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 +30,12 @@ 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 DatabaseCtor = Database! const LiveDelegationDatabaseCtor = databaseModule?.LiveDelegationDatabase! @@ -37,6 +44,8 @@ const LiveDelegationTurnsTableCtor = turnsModule?.LiveDelegationTurnsTable! const LiveDelegationEventsTableCtor = eventsModule?.LiveDelegationEventsTable! const LiveDelegationRepositoryCtor = repositoryModule?.LiveDelegationRepository! const LiveDelegationServiceCtor = serviceModule?.LiveDelegationService! +const DeepChatContractStoreCtor = tapeStoreModule?.DeepChatContractStore! +const TaskContractServiceCtor = taskContractServiceModule?.TaskContractService! const describeIfSqlite = nativeSqliteDescribeIf( Boolean( LiveDelegationDatabaseCtor && @@ -44,7 +53,9 @@ const describeIfSqlite = nativeSqliteDescribeIf( LiveDelegationTurnsTableCtor && LiveDelegationEventsTableCtor && LiveDelegationRepositoryCtor && - LiveDelegationServiceCtor + LiveDelegationServiceCtor && + DeepChatContractStoreCtor && + TaskContractServiceCtor ), 'Live delegation lifecycle modules are unavailable' ) @@ -72,8 +83,11 @@ describeIfSqlite('LiveDelegationService', () => { new LiveDelegationsTableCtor(db).createTable() new LiveDelegationTurnsTableCtor(db).createTable() new LiveDelegationEventsTableCtor(db).createTable() + const contractStore = new DeepChatContractStoreCtor(db) + contractStore.createTable() repository = new LiveDelegationRepositoryCtor( - new LiveDelegationDatabaseCtor({ getDatabase: () => db! }) + new LiveDelegationDatabaseCtor({ getDatabase: () => db! }), + new TaskContractServiceCtor(() => contractStore) ) harness = createSessionHarness(db) deletionGate = new SessionDeletionGate() @@ -235,7 +249,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({ @@ -1134,6 +1149,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 +1624,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) @@ -1685,6 +1702,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') @@ -1724,6 +1742,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 +1783,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') @@ -1800,6 +1820,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') @@ -1858,6 +1879,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') @@ -1888,6 +1910,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 +1921,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') diff --git a/test/main/tape/taskContract.test.ts b/test/main/tape/taskContract.test.ts new file mode 100644 index 000000000..e86c38637 --- /dev/null +++ b/test/main/tape/taskContract.test.ts @@ -0,0 +1,269 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { + MAX_TASK_CONTRACT_REQUIREMENTS, + type DeepChatTaskAcceptanceRequirement +} from '@shared/types/task-contract' +import type { JsonValue } from '@shared/contracts/json' +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 }, + acceptance: [ + { + id: 'sections', + kind: 'required_sections', + level: 2, + sections: ['Validation', 'Handoff'] + }, + { + id: 'result', + kind: 'result_schema', + section: 'Result', + schema: { + required: ['decision'], + properties: { decision: { type: 'string' } }, + type: 'object' + } + } + ], + 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({ + acceptance: [ + { + id: 'result', + kind: 'result_schema', + section: 'Result', + schema: { + type: 'object', + properties: { decision: { type: 'string' } }, + required: ['decision'] + } + }, + { + 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([ + 'result', + '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('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) + }) + + it('rejects duplicate sections, remote references, and bounded-input overflow', () => { + expect(() => + buildTaskContract( + buildInput({ + acceptance: [ + { + id: 'sections', + kind: 'required_sections', + level: 2, + sections: ['Handoff', 'handoff'] + } + ] + }) + ) + ).toThrow(TaskContractError) + expect(() => + buildTaskContract( + buildInput({ + acceptance: [ + { + id: 'schema', + kind: 'result_schema', + section: 'Result', + schema: { $ref: 'https://example.invalid/schema.json' } + } + ] + }) + ) + ).toThrow(/must not contain \$ref/u) + expect(() => + buildTaskContract( + buildInput({ + acceptance: Array.from( + { length: MAX_TASK_CONTRACT_REQUIREMENTS + 1 }, + (_, index): DeepChatTaskAcceptanceRequirement => ({ + id: `section-${index}`, + kind: 'required_sections', + level: 2, + sections: [`Section ${index}`] + }) + ) + }) + ) + ).toThrow(/exceeds 64 requirements/u) + expect(() => + buildTaskContract( + buildInput({ + acceptance: [ + { + id: 'oversized-schema', + kind: 'result_schema', + section: 'Result', + schema: { const: 'x'.repeat(32 * 1024) } + } + ] + }) + ) + ).toThrow(/exceeds 32768 UTF-8 bytes/u) + + let deeplyNested: JsonValue = {} + for (let depth = 0; depth < 66; depth += 1) deeplyNested = { allOf: [deeplyNested] } + expect(() => + buildTaskContract( + buildInput({ + acceptance: [ + { + id: 'deep-schema', + kind: 'result_schema', + section: 'Result', + schema: deeplyNested + } + ] + }) + ) + ).toThrow(/structural complexity limit/u) + + let getterRead = false + const accessorSchema = Object.create(null) as Record + Object.defineProperty(accessorSchema, 'type', { + enumerable: true, + get: () => { + getterRead = true + return 'object' + } + }) + expect(() => + buildTaskContract( + buildInput({ + acceptance: [ + { + id: 'accessor-schema', + kind: 'result_schema', + section: 'Result', + schema: accessorSchema + } + ] + }) + ) + ).toThrow(/only data properties/u) + expect(getterRead).toBe(false) + }) + + 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..34e34d042 --- /dev/null +++ b/test/main/tape/taskContractPersistence.test.ts @@ -0,0 +1,144 @@ +import { expect, it } from 'vitest' +import { Database, nativeSqliteItIf } from '../nativeSqliteHarness' +import { buildTaskContract } from '@/tape/domain/taskContract' +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 DatabaseCtor = Database! +const DeepChatTapeEntriesTableCtor = tapeStoreModule?.DeepChatTapeEntriesTable! +const DeepChatContractStoreCtor = tapeStoreModule?.DeepChatContractStore! +const TaskContractServiceCtor = serviceModule?.TaskContractService! +const itIfSqlite = nativeSqliteItIf( + Boolean(DeepChatTapeEntriesTableCtor && DeepChatContractStoreCtor && TaskContractServiceCtor), + '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' }, + acceptance: [ + { + 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) + + 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 rows = contractStore.getBySession('parent-1') + expect(rows.filter((row) => row.name === 'contract/task_frozen')).toHaveLength(1) + expect(buildEffectiveTapeView(rows).rows.map((row) => row.name)).not.toContain( + 'contract/task_frozen' + ) + expect( + buildEffectiveTapeView(rows, { includeAuditEvents: true }).rows.map((row) => row.name) + ).toContain('contract/task_frozen') + + db.prepare( + `INSERT INTO deepchat_tape_entries ( + session_id, entry_id, kind, name, payload_json, meta_json, created_at + ) VALUES ('parent-1', 3, 'event', 'contract/future_fact', + '{"marker":"future-contract-marker"}', '{}', 300)` + ).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: 3 }], + 'future-contract-marker' + ) + ).toEqual([]) + expect( + contractStore.getEffectiveContextRowsAtHead({ sessionId: 'parent-1', maxEntryId: 3 }, [3], { + before: 0, + after: 0, + limit: 10 + }) + ).toEqual([]) + } finally { + db.close() + } + } +) From f78d6f9697f4907835e8a496d6c53e01547e2362 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 01:42:50 +0800 Subject: [PATCH 08/37] feat(tape): inherit child task contracts --- .../tape-contract-lineage/spec.md | 8 + .../tape-contract-lineage/tasks.md | 14 +- .../harness/createDeepChatAgentHarness.ts | 3 + .../agent/deepchat/harness/runtimeServices.ts | 2 + src/main/agent/deepchat/loop/ports.ts | 5 + .../deepchat/runtime/deepChatLoopRunner.ts | 38 ++- .../runtime/taskContractCapability.ts | 47 ++++ .../agent/deepchat/runtime/turnCoordinator.ts | 12 +- src/main/app/composition.ts | 3 + .../orchestration/liveDelegationRepository.ts | 195 ++++++++++++++- .../orchestration/liveDelegationService.ts | 65 ++++- .../liveDelegationTaskContract.ts | 14 ++ .../tape/application/taskContractService.ts | 229 ++++++++++++++++-- src/main/tape/domain/executionContract.ts | 83 ++++++- src/main/tape/domain/taskContract.ts | 38 +-- src/shared/types/execution-contract.ts | 3 +- src/shared/types/task-contract.ts | 7 + .../harness/deepChatAgentHarness.test.ts | 105 +++++++- .../liveDelegationRepository.test.ts | 176 +++++++++++++- .../liveDelegationService.test.ts | 150 +++++++++++- test/main/session/runtimeIntegration.test.ts | 3 + test/main/tape/executionContract.test.ts | 111 +++++++++ test/main/tape/taskContract.test.ts | 3 + 23 files changed, 1242 insertions(+), 72 deletions(-) create mode 100644 src/main/agent/deepchat/runtime/taskContractCapability.ts diff --git a/docs/architecture/tape-contract-lineage/spec.md b/docs/architecture/tape-contract-lineage/spec.md index 1d2777873..c224575c6 100644 --- a/docs/architecture/tape-contract-lineage/spec.md +++ b/docs/architecture/tape-contract-lineage/spec.md @@ -74,6 +74,10 @@ tape.systems: 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 acceptance requirements, so a recovered contract remains +distinguishable without adding a second runtime flag. + V1 supports two acceptance requirement kinds: - `required_sections`: required level-two Markdown section names; @@ -241,6 +245,10 @@ This table describes write disciplines, not a count of all Tape event families. 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 diff --git a/docs/architecture/tape-contract-lineage/tasks.md b/docs/architecture/tape-contract-lineage/tasks.md index 6d2f4e567..87fd39ee6 100644 --- a/docs/architecture/tape-contract-lineage/tasks.md +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -31,17 +31,17 @@ - [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. -- [ ] Re-anchor hash-verified runtime projections after parent Tape reset. +- [x] Re-anchor hash-verified runtime projections after parent Tape reset. - [x] Review and commit the parent-freeze/storage foundation slice. ## P1: Child Inheritance -- [ ] Strictly append the inherited TaskContract to child Tape before Handoff dispatch. -- [ ] Persist child-local reference and expose active contract context through a narrow port. -- [ ] Re-inherit the contract after child Tape reset before the next provider request. -- [ ] Reconcile legacy active turns with an explicit compatibility contract. -- [ ] Cover restart, repeated inheritance, reset/incarnation conflict, and missing child Tape. -- [ ] Review and commit the child-inheritance slice. +- [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 diff --git a/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts b/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts index b80a39351..d68f4fd72 100644 --- a/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts +++ b/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts @@ -359,6 +359,7 @@ function createDeepChatRuntimeServices(deps: DeepChatHarnessDependencies): DeepC identity, sessionPermissionPort, reviewToolPermission: createToolPermissionReviewer(toolRuntimeBindings), + taskContractContext: deps.taskContractContext, hookSink, compaction }) @@ -384,6 +385,8 @@ function createDeepChatRuntimeServices(deps: DeepChatHarnessDependencies): DeepC attachmentRouter, sessionSettings, promptAssembly, + identity, + taskContractContext: deps.taskContractContext, loopRunner, messageProjection, hookSink diff --git a/src/main/agent/deepchat/harness/runtimeServices.ts b/src/main/agent/deepchat/harness/runtimeServices.ts index b6c273814..bf90e1229 100644 --- a/src/main/agent/deepchat/harness/runtimeServices.ts +++ b/src/main/agent/deepchat/harness/runtimeServices.ts @@ -33,6 +33,7 @@ import type { DeepChatEventPublisher, DeepChatSessionUpdatePublisher } from '@/agent/deepchat/runtime/types' +import type { DeepChatTaskContractContextPort } from '@/agent/deepchat/loop/ports' export type DeepChatHarnessSkillPort = Pick< SkillServicePort, @@ -71,6 +72,7 @@ export interface DeepChatHarnessDependencies { promptSettings: Pick attachmentRouter: Pick interactionContinuationAdmission: InteractionContinuationAdmissionPort + taskContractContext: DeepChatTaskContractContextPort } /** diff --git a/src/main/agent/deepchat/loop/ports.ts b/src/main/agent/deepchat/loop/ports.ts index 15fa2ab32..fa5f726d4 100644 --- a/src/main/agent/deepchat/loop/ports.ts +++ b/src/main/agent/deepchat/loop/ports.ts @@ -14,6 +14,7 @@ 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' export interface ProviderRequest { runId: string @@ -38,6 +39,10 @@ export interface ToolCatalogPort { resolve(input?: { activeSkillNames?: string[] }): Promise } +export interface DeepChatTaskContractContextPort { + prepare(sessionId: string): DeepChatTaskContractContext | null +} + export type ToolExecutionOptions = Omit & { commitDispatch: ToolDispatchCommit } diff --git a/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts b/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts index aaddc1077..1ec864a50 100644 --- a/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts +++ b/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts @@ -21,7 +21,6 @@ import type { DeepChatTapeViewTokenBudget } from '@shared/types/tape-view-manifest' import { randomUUID } from 'node:crypto' -import { LIVE_DELEGATION_AGENT_TOOL_NAME } from '@shared/agentTools' import { getReasoningEffectiveEnabledForProvider } from '@shared/types/model-db' import { isTtsModelConfig, isTtsModelId } from '@shared/ttsSettings' import { nanoid } from 'nanoid' @@ -93,7 +92,11 @@ import type { import type { InputPreparationCoordinator } from '@/agent/deepchat/loop/inputPreparationCoordinator' import type { DeepChatContextCoordinator } from '@/agent/deepchat/loop/contextCoordinator' import { createLoopRun } from '@/agent/deepchat/loop/loopRun' -import type { ToolExecutionPort, ToolResultPort } from '@/agent/deepchat/loop/ports' +import type { + DeepChatTaskContractContextPort, + ToolExecutionPort, + ToolResultPort +} from '@/agent/deepchat/loop/ports' import { buildContextCheckpoint, createEmptyContextRuntimeContributions, @@ -114,6 +117,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' @@ -256,6 +263,7 @@ export interface DeepChatLoopRunnerPorts { identity: Pick sessionPermissionPort: SessionPermissionPort reviewToolPermission: ToolPermissionReviewer + taskContractContext: DeepChatTaskContractContextPort hookSink: Pick compaction: Pick } @@ -303,15 +311,6 @@ function buildProviderContextOverflowAfterRecoveryErrorMessage( ].join(' ') } -function resolveExecutionContractSubagentDepth(tools: readonly MCPToolDefinition[]): number { - return tools.some( - (tool) => - tool.source === 'agent' && tool.function.name === LIVE_DELEGATION_AGENT_TOOL_NAME - ) - ? 1 - : 0 -} - function selectProcessTerminal(result: ProcessResult): ProcessTerminalSelection { let stopReason = result.stopReason if (!stopReason) { @@ -496,11 +495,20 @@ 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) + const taskContractContext = strictViewContract + ? this.ports.taskContractContext.prepare(sessionId) + : null + return meetTaskContractToolDefinitions(sessionId, resolved, taskContractContext) + } + } const tools = providedTools ?? (await awaitWithAbort( @@ -741,6 +749,9 @@ export class DeepChatLoopRunner { effectiveSystemPrompt ) const cancellationRequested = abortSignal.aborted + const taskContractContext = strictViewContract + ? ports.taskContractContext.prepare(sessionId) + : null return buildExecutionContract({ request: { sessionId, @@ -765,7 +776,8 @@ export class DeepChatLoopRunner { requestAdmitted: !cancellationRequested, cancellationRequested }, - assemblerVersion: contextBuilderVersion + assemblerVersion: contextBuilderVersion, + taskContractContext }) }, onBuildError: (error) => 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 8488c7f69..62cc280dc 100644 --- a/src/main/agent/deepchat/runtime/turnCoordinator.ts +++ b/src/main/agent/deepchat/runtime/turnCoordinator.ts @@ -88,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 @@ -151,6 +154,8 @@ export interface TurnCoordinatorPorts { 'resolveProjectDir' | 'getEffectiveGenerationSettings' > promptAssembly: Pick + identity: Pick + taskContractContext: DeepChatTaskContractContextPort loopRunner: Pick messageProjection: Pick hookSink: Pick @@ -235,7 +240,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( @@ -248,6 +253,11 @@ export class TurnCoordinator { signal ) ) + const taskContractContext = + this.ports.identity.getSessionKind(sessionId) === 'subagent' + ? this.ports.taskContractContext.prepare(sessionId) + : null + const tools = meetTaskContractToolDefinitions(sessionId, resolvedTools, taskContractContext) const toolReserveTokens = estimateToolReserveTokens(tools) throwIfAbortRequested(signal) const basePromptAssembler = this.ports.promptAssembly.createBasePromptAssembler(instance) diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 0acdd7e7c..394d5e705 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -1503,6 +1503,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/orchestration/liveDelegationRepository.ts b/src/main/orchestration/liveDelegationRepository.ts index 1142b36ba..3d967f4e8 100644 --- a/src/main/orchestration/liveDelegationRepository.ts +++ b/src/main/orchestration/liveDelegationRepository.ts @@ -1,6 +1,7 @@ import { Buffer } from 'node:buffer' import { z } from 'zod' import type { SubagentTapeLinkReceipt } from '@shared/types/agent-interface' +import type { DeepChatTaskContractContext } from '@shared/types/task-contract' import { LIVE_DELEGATION_MAX_EFFECT_EVIDENCE_BYTES, LIVE_DELEGATION_MAX_ACTIVE_PER_PARENT, @@ -32,7 +33,7 @@ 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 { ParentTaskContractWriter } from '@/tape/application/taskContractService' +import type { TaskContractWriter } from '@/tape/application/taskContractService' import { buildTaskContract, restoreTaskContract, @@ -40,7 +41,10 @@ import { serializeTaskContract, serializeTaskContractRef } from '@/tape/domain/taskContract' -import type { LiveDelegationTaskContractInput } from './liveDelegationTaskContract' +import type { + LegacyLiveDelegationTaskContractInput, + LiveDelegationTaskContractInput +} from './liveDelegationTaskContract' const MAX_RETAINED_CONSUMED_MESSAGES_PER_PARENT = 500 const MAX_LIST_LIMIT = 100 @@ -82,10 +86,17 @@ export interface LiveDelegationWithTurn { export interface ActiveLiveDelegationTurn extends LiveDelegationWithTurn {} +export class LiveDelegationTaskContractError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'LiveDelegationTaskContractError' + } +} + export class LiveDelegationRepository { constructor( private readonly database: LiveDelegationDatabase, - private readonly taskContracts: ParentTaskContractWriter + private readonly taskContracts: TaskContractWriter ) {} create(input: CreateLiveDelegationInput, beforeMutation?: () => void): LiveDelegationWithTurn { @@ -273,6 +284,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, diff --git a/src/main/orchestration/liveDelegationService.ts b/src/main/orchestration/liveDelegationService.ts index aabd454f4..6da6075da 100644 --- a/src/main/orchestration/liveDelegationService.ts +++ b/src/main/orchestration/liveDelegationService.ts @@ -38,12 +38,17 @@ import type { PermissionMode, SubagentTapeLinkReceipt } from '@shared/types/agent-interface' +import type { DeepChatTaskContractContext } from '@shared/types/task-contract' 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 LiveDelegationRepository +} from './liveDelegationRepository' import type { LiveDelegationSafetyPort, LiveDelegationTurnExecutionSnapshot @@ -54,6 +59,7 @@ import type { LiveDelegationConsentVerifier } from './liveDelegationConsent' import { + createLegacyLiveDelegationTaskContractInput, createLiveDelegationTaskContractInput, LIVE_DELEGATION_REQUIRED_RESULT_SECTIONS } from './liveDelegationTaskContract' @@ -193,6 +199,19 @@ export class LiveDelegationService { }) } + prepareTaskContractContext(childSessionId: string): DeepChatTaskContractContext | null { + const context = this.options.repository.prepareActiveTaskContractContext(childSessionId) + const admittedTurnId = this.childToTurn.get(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 @@ -781,6 +800,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 && @@ -795,6 +818,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 @@ -846,6 +881,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() @@ -1298,7 +1334,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) @@ -1321,6 +1357,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' @@ -1335,6 +1385,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', @@ -1360,6 +1415,12 @@ export class LiveDelegationService { error }) if (!this.started) return + if (error instanceof LiveDelegationTaskContractError) { + if (record.delegation.childSessionId) { + this.childToTurn.delete(record.delegation.childSessionId) + } + return + } try { const current = this.options.repository.getTurn(record.turn.id) if (!current || !isActiveTurnStatus(current.status)) { diff --git a/src/main/orchestration/liveDelegationTaskContract.ts b/src/main/orchestration/liveDelegationTaskContract.ts index 3d08ab7d4..fda4f0f30 100644 --- a/src/main/orchestration/liveDelegationTaskContract.ts +++ b/src/main/orchestration/liveDelegationTaskContract.ts @@ -21,6 +21,10 @@ export interface LiveDelegationTaskContractInput { maxSubagentDepth: number } +export type LegacyLiveDelegationTaskContractInput = LiveDelegationTaskContractInput & { + creationReason: 'legacy_recovery' +} + export function createLiveDelegationTaskContractInput( projectDir: string | null, predecessorEvaluationRef: DeepChatEvaluationRef | null = null @@ -40,3 +44,13 @@ export function createLiveDelegationTaskContractInput( maxSubagentDepth: 0 } } + +export function createLegacyLiveDelegationTaskContractInput( + projectDir: string | null +): LegacyLiveDelegationTaskContractInput { + return { + ...createLiveDelegationTaskContractInput(projectDir), + acceptance: [], + creationReason: 'legacy_recovery' + } +} diff --git a/src/main/tape/application/taskContractService.ts b/src/main/tape/application/taskContractService.ts index 5a1c5c223..23f4bfe1f 100644 --- a/src/main/tape/application/taskContractService.ts +++ b/src/main/tape/application/taskContractService.ts @@ -3,7 +3,11 @@ import { type DeepChatTaskContract, type DeepChatTaskContractRef } from '@shared/types/task-contract' -import { isDeepChatTaskContract, serializeTaskContractRef } from '../domain/taskContract' +import { + isDeepChatTaskContract, + isDeepChatTaskContractRef, + serializeTaskContractRef +} from '../domain/taskContract' import { canonicalJsonStringifyData } from '../domain/canonicalJson' import { computeTapeIdentity } from '../domain/tapeIdentity' import type { DeepChatTapeEntryRow, TapeEventAppendInput } from '../domain/entry' @@ -13,19 +17,21 @@ 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 ParentTaskContractFactData = { +type TaskContractFactDelivery = 'parent_frozen' | 'child_inherited' | 'projection_recovery' + +type TaskContractFactData = { schemaVersion: typeof TASK_CONTRACT_FACT_SCHEMA_VERSION - delivery: 'parent_frozen' + delivery: TaskContractFactDelivery contract: DeepChatTaskContract - originRef: null - supersedesRef: null + originRef: DeepChatTaskContractRef | null + supersedesRef: DeepChatTaskContractRef | null } type StrictTaskContractEventInput = Omit & { name: typeof TASK_CONTRACT_FACT_NAME source: NonNullable provenanceKey: string - data: ParentTaskContractFactData + data: TaskContractFactData } export interface FreezeParentTaskContractInput { @@ -34,6 +40,18 @@ export interface FreezeParentTaskContractInput { 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 @@ -44,6 +62,11 @@ 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, @@ -84,6 +107,71 @@ function rowMatchesTaskContractFact( ) } +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, @@ -100,10 +188,52 @@ function buildTaskContractRef( return Object.freeze(ref) } -export class TaskContractService implements ParentTaskContractWriter { +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.', @@ -111,9 +241,35 @@ export class TaskContractService implements ParentTaskContractWriter { ) } const description = input.contract.taskDescription - if (input.parentSessionId !== description.parentSessionId) { + if ( + (input.role === 'parent' && input.targetSessionId !== description.parentSessionId) || + (input.role === 'child' && input.targetSessionId === description.parentSessionId) + ) { throw new TaskContractPersistenceError( - 'TaskContract parent Session does not match its persistence target.', + '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' ) } @@ -130,46 +286,70 @@ export class TaskContractService implements ParentTaskContractWriter { const store = this.getStore() if (!store.isInTransaction()) { throw new TaskContractPersistenceError( - 'Parent TaskContract freeze requires the live-delegation host transaction.', + 'TaskContract 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) { + store.ensureBootstrapAnchor(input.targetSessionId) + const firstEntry = store.getFirstEntriesBySessions([input.targetSessionId])[0] + if (!firstEntry || firstEntry.session_id !== input.targetSessionId) { throw new TaskContractPersistenceError( - `Parent Tape ${input.parentSessionId} has no stable identity.`, + `Tape ${input.targetSessionId} has no stable identity.`, 'persistence_failed' ) } const tapeIdentity = computeTapeIdentity(firstEntry) - const provenanceKey = `contract:task_frozen:v1:parent:${description.turnId}` + 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.parentSessionId, + 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: 'parent_frozen', + delivery, contract: input.contract, - originRef: null, - supersedesRef: null + originRef: input.originRef, + supersedesRef: recovering ? input.currentRef : null }, meta: { protocolVersion: TASK_CONTRACT_FACT_PROTOCOL_VERSION }, createdAt: input.createdAt } - const existing = store.getByProvenanceKey(input.parentSessionId, provenanceKey) + const existing = store.getByProvenanceKey(input.targetSessionId, provenanceKey) if (existing) { - if (!rowMatchesTaskContractFact(existing, event)) { + 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), @@ -177,6 +357,13 @@ export class TaskContractService implements ParentTaskContractWriter { } } + 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( diff --git a/src/main/tape/domain/executionContract.ts b/src/main/tape/domain/executionContract.ts index 29c2696b4..b5f12185f 100644 --- a/src/main/tape/domain/executionContract.ts +++ b/src/main/tape/domain/executionContract.ts @@ -30,7 +30,9 @@ import { 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' export const MAX_EXECUTION_CONTRACT_BYTES = 64 * 1024 export const MAX_EXECUTION_CONTRACT_BINDING_BYTES = 4 * 1024 @@ -109,6 +111,7 @@ export interface BuildExecutionContractInput { maxSubagentDepth: number dynamicControlSnapshot: DeepChatExecutionDynamicControlSnapshot assemblerVersion: string + taskContractContext?: DeepChatTaskContractContext | null } export class ExecutionContractError extends Error { @@ -810,7 +813,7 @@ function isStoredExecutionProvenance( 'provenance.assemblerVersion', MAX_ASSEMBLER_VERSION_BYTES ) && - value.taskContractRef === null + (value.taskContractRef === null || isDeepChatTaskContractRef(value.taskContractRef)) ) } @@ -883,6 +886,72 @@ function executionWorkspacesMatch( return currentPath !== null && currentPath === ceilingPath } +function isExecutionWorkspaceWithinTaskCeiling( + execution: DeepChatExecutionWorkspaceCeiling, + taskCeiling: DeepChatExecutionWorkspaceCeiling +): boolean { + if (execution.kind === 'runtime_default' || taskCeiling.kind === 'runtime_default') { + return execution.kind === taskCeiling.kind + } + + const relative = path.relative(path.resolve(taskCeiling.path), path.resolve(execution.path)) + return ( + relative === '' || + (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ) +} + +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 @@ -1010,10 +1079,12 @@ export function buildExecutionContract( 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: normalizeRequest(input.request), + request, ceilings, dynamicControlSnapshot: normalizeDynamicControlSnapshot(input.dynamicControlSnapshot), provenance: { @@ -1029,7 +1100,7 @@ export function buildExecutionContract( 'assemblerVersion', MAX_ASSEMBLER_VERSION_BYTES ), - taskContractRef: null + taskContractRef } } const contract: DeepChatExecutionContract = { @@ -1079,6 +1150,12 @@ export function isDeepChatExecutionContract(value: unknown): value is DeepChatEx ) { 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 diff --git a/src/main/tape/domain/taskContract.ts b/src/main/tape/domain/taskContract.ts index d67f5d9e6..9ed854e1f 100644 --- a/src/main/tape/domain/taskContract.ts +++ b/src/main/tape/domain/taskContract.ts @@ -56,6 +56,7 @@ export interface BuildTaskContractInput { prompt: string workspace: DeepChatTaskWorkspaceCeiling acceptance: readonly DeepChatTaskAcceptanceRequirement[] + creationReason?: 'delegation_created' | 'legacy_recovery' predecessorEvaluationRef?: DeepChatEvaluationRef | null maxToolEffect?: 'read' | 'write' maxSubagentDepth?: number @@ -343,6 +344,10 @@ function buildTaskContractDraft( 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') + } return { schemaVersion: DEEPCHAT_TASK_CONTRACT_SCHEMA_VERSION, @@ -354,6 +359,7 @@ function buildTaskContractDraft( taskConfig: { completionMode: 'single_response', retryMode: 'parent_follow_up', + creationReason, predecessorEvaluationRef: normalizeEvaluationRef(input.predecessorEvaluationRef ?? null) }, taskDescription: { @@ -417,6 +423,7 @@ export function isDeepChatTaskContract(value: unknown): value is DeepChatTaskCon ...contract.taskDescription, workspace: contract.taskHarness.ceilings.workspace, acceptance: contract.taskHarness.acceptance, + creationReason: contract.taskConfig.creationReason, predecessorEvaluationRef: contract.taskConfig.predecessorEvaluationRef, maxToolEffect: contract.taskHarness.ceilings.maxToolEffect, maxSubagentDepth: contract.taskHarness.ceilings.maxSubagentDepth @@ -439,26 +446,29 @@ export function serializeTaskContract(contract: DeepChatTaskContract): string { } export function serializeTaskContractRef(ref: DeepChatTaskContractRef): string { - if ( - !hasExactKeys(ref, TASK_CONTRACT_REF_KEYS) || - ref?.schemaVersion !== 1 || - requireString(ref.sessionId, 'TaskContractRef.sessionId', MAX_IDENTITY_BYTES, 256) !== - ref.sessionId || - !SHA_256_PATTERN.test(ref.tapeIdentity) || - !SHA_256_PATTERN.test(ref.contractHash) || - requirePositiveSafeInteger(ref.entryId, 'TaskContractRef.entryId') !== ref.entryId - ) { + if (!isDeepChatTaskContractRef(ref)) { throw new TaskContractError('TaskContractRef is invalid.', 'invalid_input') } return canonicalJsonStringifyData(ref) } -export function restoreTaskContractRef(value: unknown): DeepChatTaskContractRef | null { +export function isDeepChatTaskContractRef(value: unknown): value is DeepChatTaskContractRef { + if (!hasExactKeys(value, TASK_CONTRACT_REF_KEYS) || value.schemaVersion !== 1) return false try { - const ref = value as DeepChatTaskContractRef - serializeTaskContractRef(ref) - return deepFreeze(ref) + 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 null + return false } } + +export function restoreTaskContractRef(value: unknown): DeepChatTaskContractRef | null { + return isDeepChatTaskContractRef(value) ? deepFreeze(value) : null +} diff --git a/src/shared/types/execution-contract.ts b/src/shared/types/execution-contract.ts index 9afd0af3b..349f0678b 100644 --- a/src/shared/types/execution-contract.ts +++ b/src/shared/types/execution-contract.ts @@ -1,6 +1,7 @@ import type { PermissionMode } from './agent-interface' import type { ToolExecutionContract } from './core/mcp' import type { DeepChatPromptSectionProvenance } from './prompt-assembly' +import type { DeepChatTaskContractRef } from './task-contract' export const DEEPCHAT_EXECUTION_CONTRACT_SCHEMA_VERSION = 1 as const export const DEEPCHAT_EXECUTION_CONTRACT_HASH_VERSION = 1 as const @@ -62,7 +63,7 @@ export interface DeepChatExecutionContractProvenance { readonly providerVisibleToolDefinitionsHash: string readonly internalExecutionPolicyHash: string readonly assemblerVersion: string - readonly taskContractRef: null + readonly taskContractRef: DeepChatTaskContractRef | null } export interface DeepChatExecutionContract { diff --git a/src/shared/types/task-contract.ts b/src/shared/types/task-contract.ts index 01e9374c0..34e719981 100644 --- a/src/shared/types/task-contract.ts +++ b/src/shared/types/task-contract.ts @@ -41,6 +41,7 @@ export interface DeepChatTaskSchema { export interface DeepChatTaskConfig { readonly completionMode: 'single_response' readonly retryMode: 'parent_follow_up' + readonly creationReason: 'delegation_created' | 'legacy_recovery' readonly predecessorEvaluationRef: DeepChatEvaluationRef | null } @@ -97,6 +98,11 @@ export interface DeepChatTaskContract { readonly contractHash: string } +export interface DeepChatTaskContractContext { + readonly contract: DeepChatTaskContract + readonly localRef: DeepChatTaskContractRef +} + const StoredIdSchema = z.string().trim().min(1).max(256) const Sha256Schema = z.string().regex(/^[0-9a-f]{64}$/u) @@ -162,6 +168,7 @@ export const DeepChatTaskContractProjectionSchema: z.ZodType ({ nanoid: vi.fn(() => 'mock-msg-id') })) @@ -818,6 +820,9 @@ function createRuntimeDependencies( interactionContinuationAdmission: options.interactionContinuationAdmission ?? { resume: vi.fn().mockResolvedValue(false), suspend: vi.fn() + }, + taskContractContext: { + prepare: vi.fn().mockReturnValue(null) } } } @@ -4075,6 +4080,104 @@ describe('DeepChatAgentHarness', () => { ) }) + it('resolves the child-local TaskContract independently for every provider View', 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' }, + acceptance: [], + 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)) + .mockReturnValueOnce(contextForTape('c'.repeat(64), 2)) + .mockReturnValueOnce(contextForTape('d'.repeat(64), 3)) + .mockReturnValueOnce(null) + 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']) + + 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, + callArgs.run.resources.toolDefinitions + )) { + } + } + + 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(4) + expect(prepareTaskContract).toHaveBeenNthCalledWith(1, 's1') + expect( + manifests.map((manifest: any) => manifest.executionContract.provenance.taskContractRef) + ).toEqual([ + contextForTape('c'.repeat(64), 2).localRef, + contextForTape('d'.repeat(64), 3).localRef, + null + ]) + }) + it('continues provider requests when view manifest persistence fails', async () => { await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) await agent.processMessage('s1', 'Hello') diff --git a/test/main/orchestration/liveDelegationRepository.test.ts b/test/main/orchestration/liveDelegationRepository.test.ts index 177826de6..4cc9b9292 100644 --- a/test/main/orchestration/liveDelegationRepository.test.ts +++ b/test/main/orchestration/liveDelegationRepository.test.ts @@ -1,7 +1,10 @@ import { createHash } from 'node:crypto' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { Database, nativeSqliteDescribeIf } from '../nativeSqliteHarness' -import { createLiveDelegationTaskContractInput } from '@/orchestration/liveDelegationTaskContract' +import { + createLegacyLiveDelegationTaskContractInput, + createLiveDelegationTaskContractInput +} from '@/orchestration/liveDelegationTaskContract' const databaseModule = Database ? await import('@/orchestration/data/database').catch(() => null) @@ -237,7 +240,9 @@ describeIfSqlite('LiveDelegationRepository', () => { ) => { strictWriter.freezeParentTaskContract(input) throw new Error('projection write failed') - } + }, + ensureParentTaskContract: (input) => strictWriter.ensureParentTaskContract(input), + ensureChildTaskContract: (input) => strictWriter.ensureChildTaskContract(input) } ) @@ -405,6 +410,173 @@ 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') + } + } + ) + + 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') diff --git a/test/main/orchestration/liveDelegationService.test.ts b/test/main/orchestration/liveDelegationService.test.ts index 49bb2a222..e82e21345 100644 --- a/test/main/orchestration/liveDelegationService.test.ts +++ b/test/main/orchestration/liveDelegationService.test.ts @@ -43,6 +43,7 @@ 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! @@ -53,6 +54,7 @@ const describeIfSqlite = nativeSqliteDescribeIf( LiveDelegationTurnsTableCtor && LiveDelegationEventsTableCtor && LiveDelegationRepositoryCtor && + LiveDelegationTaskContractErrorCtor && LiveDelegationServiceCtor && DeepChatContractStoreCtor && TaskContractServiceCtor @@ -63,6 +65,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 @@ -83,7 +86,7 @@ describeIfSqlite('LiveDelegationService', () => { new LiveDelegationsTableCtor(db).createTable() new LiveDelegationTurnsTableCtor(db).createTable() new LiveDelegationEventsTableCtor(db).createTable() - const contractStore = new DeepChatContractStoreCtor(db) + contractStore = new DeepChatContractStoreCtor(db) contractStore.createTable() repository = new LiveDelegationRepositoryCtor( new LiveDelegationDatabaseCtor({ getDatabase: () => db! }), @@ -148,6 +151,87 @@ describeIfSqlite('LiveDelegationService', () => { ) }) + 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 = { @@ -1731,6 +1815,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) @@ -1869,7 +2005,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', @@ -1893,9 +2029,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() }) diff --git a/test/main/session/runtimeIntegration.test.ts b/test/main/session/runtimeIntegration.test.ts index 22322c56d..bddbc5478 100644 --- a/test/main/session/runtimeIntegration.test.ts +++ b/test/main/session/runtimeIntegration.test.ts @@ -814,6 +814,9 @@ function createRuntimeDependencies() { summary: { status: 'ready' as const, issues: [], suggestedActions: [] } })) }, + taskContractContext: { + prepare: vi.fn().mockReturnValue(null) + }, skillService: { getMetadataList: vi.fn().mockResolvedValue([]), getActiveSkills: vi.fn().mockResolvedValue([]), diff --git a/test/main/tape/executionContract.test.ts b/test/main/tape/executionContract.test.ts index 454b8baac..a4d3a1523 100644 --- a/test/main/tape/executionContract.test.ts +++ b/test/main/tape/executionContract.test.ts @@ -1,3 +1,4 @@ +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' @@ -25,6 +26,7 @@ import { 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' @@ -138,6 +140,42 @@ function buildInput( } } +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') }, + acceptance: [], + 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()) @@ -188,6 +226,79 @@ describe('ExecutionContract domain', () => { 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()] })) diff --git a/test/main/tape/taskContract.test.ts b/test/main/tape/taskContract.test.ts index e86c38637..3b645b3e6 100644 --- a/test/main/tape/taskContract.test.ts +++ b/test/main/tape/taskContract.test.ts @@ -106,6 +106,9 @@ describe('TaskContract domain', () => { 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('rejects duplicate sections, remote references, and bounded-input overflow', () => { From 35d6cec2b9a680bd2075050856b6ac328e525f4f Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 02:47:20 +0800 Subject: [PATCH 09/37] feat(tape): evaluate delegation results --- .../tape-contract-lineage/spec.md | 2 + .../tape-contract-lineage/tasks.md | 14 +- package.json | 3 +- pnpm-lock.yaml | 5 +- src/main/app/composition.ts | 7 +- .../data/tables/liveDelegationEvents.ts | 94 ++- .../data/tables/liveDelegationTurns.ts | 7 +- .../data/tables/liveDelegations.ts | 1 + .../orchestration/liveDelegationRepository.ts | 164 ++++- .../orchestration/liveDelegationService.ts | 120 ++-- .../tape/application/taskEvaluationService.ts | 280 +++++++++ src/main/tape/domain/taskEvaluation.ts | 558 ++++++++++++++++++ src/shared/orchestration/liveDelegation.ts | 102 +++- .../orchestration/liveDelegationMarkdown.ts | 78 +++ src/shared/types/task-contract.ts | 154 +++++ .../liveDelegationMigration.test.ts | 83 ++- .../liveDelegationRepository.test.ts | 171 +++++- .../liveDelegationService.test.ts | 162 ++++- .../main/tape/taskContractPersistence.test.ts | 91 ++- test/main/tape/taskEvaluation.test.ts | 280 +++++++++ 20 files changed, 2241 insertions(+), 135 deletions(-) create mode 100644 src/main/tape/application/taskEvaluationService.ts create mode 100644 src/main/tape/domain/taskEvaluation.ts create mode 100644 src/shared/orchestration/liveDelegationMarkdown.ts create mode 100644 test/main/tape/taskEvaluation.test.ts diff --git a/docs/architecture/tape-contract-lineage/spec.md b/docs/architecture/tape-contract-lineage/spec.md index c224575c6..6246fbc77 100644 --- a/docs/architecture/tape-contract-lineage/spec.md +++ b/docs/architecture/tape-contract-lineage/spec.md @@ -86,6 +86,8 @@ V1 supports two acceptance requirement kinds: `result_schema` accepts one JSON value after removing at most one enclosing Markdown code fence. It uses Ajv strict validation with remote loading disabled, rejects every `$ref`, and stops after a bounded error set. It does not execute custom formats or schema-provided code. +Ajv and regex-safety dependencies are pinned. Any semantic change to those validators, Markdown +section extraction, evidence normalization, or verdict reduction must bump `evaluatorVersion`. Requirements compose conjunctively. A missing required section or schema mismatch is `failed`. Missing candidate data, cancellation, interruption, unavailable evidence, or evaluator failure is diff --git a/docs/architecture/tape-contract-lineage/tasks.md b/docs/architecture/tape-contract-lineage/tasks.md index 87fd39ee6..898f84da9 100644 --- a/docs/architecture/tape-contract-lineage/tasks.md +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -45,14 +45,14 @@ ## P1: Evaluation And Parent Visibility -- [ ] Implement bounded required-section and result-schema evaluation. -- [ ] Commit evaluation fact, projection, terminal state, and mailbox event atomically. -- [ ] Ensure every contract-bearing terminal path produces evaluation or remains recoverable. -- [ ] Surface evaluation through inspect, wait, read_result, and the untrusted result envelope. -- [ ] Preserve orthogonal execution status, verdict, and disposition semantics. -- [ ] Cover no answer, malformed result, cancellation, interruption, evaluator failure, and +- [x] Implement bounded required-section and result-schema 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] Preserve orthogonal execution status, verdict, and disposition semantics. +- [x] Cover no answer, malformed result, cancellation, interruption, evaluator failure, and settlement retry/recovery. -- [ ] Review and commit the evaluation/settlement slice. +- [x] Review and commit the evaluation/settlement slice. ## Documentation And Final Validation diff --git a/package.json b/package.json index e875b9fae..9133c594f 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "@parcel/watcher": "^2.5.6", "@zerob13/nativekit": "0.6.3", "ai": "^7.0.54", + "ajv": "8.20.0", "axios": "^1.18.1", "better-sqlite3-multiple-ciphers": "12.9.0", "compare-versions": "^6.1.1", @@ -153,7 +154,7 @@ "pdf-parse-new": "^1.4.1", "qrcode": "^1.5.4", "run-applescript": "^7.1.0", - "safe-regex2": "^5.1.1", + "safe-regex2": "5.1.1", "sharp": "^0.35.3", "tokenx": "0.4.1", "turndown": "^7.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d7862992..9587fd904 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: ai: specifier: ^7.0.54 version: 7.0.54(zod@4.4.3) + ajv: + specifier: 8.20.0 + version: 8.20.0 axios: specifier: ^1.18.1 version: 1.18.1 @@ -163,7 +166,7 @@ importers: specifier: ^7.1.0 version: 7.1.0 safe-regex2: - specifier: ^5.1.1 + specifier: 5.1.1 version: 5.1.1 sharp: specifier: ^0.35.3 diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 394d5e705..296be8852 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -169,6 +169,7 @@ 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' @@ -564,9 +565,13 @@ 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 + taskContractService, + taskEvaluationService ) const sessionRuntimeEvents = new SessionRuntimeEvents() const projectDatabase = new ProjectDatabase(mainDatabase) 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 b5efc6fd1..fe2ba64c9 100644 --- a/src/main/orchestration/data/tables/liveDelegationTurns.ts +++ b/src/main/orchestration/data/tables/liveDelegationTurns.ts @@ -9,7 +9,8 @@ import type { OrchestrationEffectState } from '@shared/orchestration/toolEffect' import { MAX_TASK_CONTRACT_BYTES, MAX_TASK_CONTRACT_REF_BYTES, - MAX_TASK_EVALUATION_BYTES + MAX_TASK_EVALUATION_BYTES, + MAX_TASK_EVALUATION_REF_BYTES } from '@shared/types/task-contract' import { LIVE_DELEGATION_CONTRACT_DATABASE_SCHEMA_VERSION, @@ -113,7 +114,7 @@ const LIVE_DELEGATION_TURN_CONTRACT_COLUMNS_SQL = ` OR ( json_valid(evaluation_ref_json) AND json_type(evaluation_ref_json) = 'object' - AND length(CAST(evaluation_ref_json AS BLOB)) <= ${MAX_TASK_CONTRACT_REF_BYTES} + AND length(CAST(evaluation_ref_json AS BLOB)) <= ${MAX_TASK_EVALUATION_REF_BYTES} ) ) ), @@ -262,7 +263,7 @@ export const LIVE_DELEGATION_TURN_EVALUATION_REF_ADD_COLUMN_SQL = ` OR ( json_valid(evaluation_ref_json) AND json_type(evaluation_ref_json) = 'object' - AND length(CAST(evaluation_ref_json AS BLOB)) <= ${MAX_TASK_CONTRACT_REF_BYTES} + AND length(CAST(evaluation_ref_json AS BLOB)) <= ${MAX_TASK_EVALUATION_REF_BYTES} ) ) ) diff --git a/src/main/orchestration/data/tables/liveDelegations.ts b/src/main/orchestration/data/tables/liveDelegations.ts index b0d5f7e40..ff846c379 100644 --- a/src/main/orchestration/data/tables/liveDelegations.ts +++ b/src/main/orchestration/data/tables/liveDelegations.ts @@ -7,6 +7,7 @@ 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 3d967f4e8..70656b6eb 100644 --- a/src/main/orchestration/liveDelegationRepository.ts +++ b/src/main/orchestration/liveDelegationRepository.ts @@ -1,7 +1,11 @@ import { Buffer } from 'node:buffer' import { z } from 'zod' import type { SubagentTapeLinkReceipt } from '@shared/types/agent-interface' -import type { DeepChatTaskContractContext } from '@shared/types/task-contract' +import type { + DeepChatEvaluationRef, + DeepChatTaskContractContext, + DeepChatTaskEvaluation +} from '@shared/types/task-contract' import { LIVE_DELEGATION_MAX_EFFECT_EVIDENCE_BYTES, LIVE_DELEGATION_MAX_ACTIVE_PER_PARENT, @@ -34,6 +38,7 @@ 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, @@ -41,6 +46,13 @@ import { serializeTaskContract, serializeTaskContractRef } from '@/tape/domain/taskContract' +import { + buildTaskEvaluation, + restoreEvaluationRef, + restoreTaskEvaluation, + serializeEvaluationRef, + serializeTaskEvaluation +} from '@/tape/domain/taskEvaluation' import type { LegacyLiveDelegationTaskContractInput, LiveDelegationTaskContractInput @@ -96,7 +108,8 @@ export class LiveDelegationTaskContractError extends Error { export class LiveDelegationRepository { constructor( private readonly database: LiveDelegationDatabase, - private readonly taskContracts: TaskContractWriter + private readonly taskContracts: TaskContractWriter, + private readonly taskEvaluations: TaskEvaluationWriter ) {} create(input: CreateLiveDelegationInput, beforeMutation?: () => void): LiveDelegationWithTurn { @@ -555,6 +568,7 @@ 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, @@ -565,7 +579,8 @@ export class LiveDelegationRepository { slotId: delegation.slotId, targetAgentId: delegation.targetAgentId, title: delegation.title, - prompt + prompt, + predecessorEvaluationRef }) const taskContractJson = serializeTaskContract(taskContract) beforeMutation?.() @@ -707,11 +722,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 && + evaluation.evaluationHash !== turn.evaluation.evaluationHash + ) { + throw new LiveDelegationTaskContractError( + `Terminal evaluation retry conflicts with turn ${turn.id}.` + ) + } return { delegation: this.require(turn.delegationId), turn } } const summary = normalizeOptionalBytes( @@ -755,21 +793,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 @@ -782,11 +873,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) } } @@ -881,15 +974,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, @@ -898,6 +1002,8 @@ export class LiveDelegationRepository { input.kind, input.content, input.relatedTurnId, + evaluationJson, + evaluationRefJson, input.now ) return Number(result.lastInsertRowid) @@ -947,6 +1053,8 @@ function toTurn(row: LiveDelegationTurnRow): LiveDelegationTurn { 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 Error( `Live delegation turn ${row.turn_id} has an incomplete TaskContract projection.` @@ -970,6 +1078,20 @@ function toTurn(row: LiveDelegationTurnRow): LiveDelegationTurn { throw new Error(`Live delegation turn ${row.turn_id} has a misbound TaskContract projection.`) } } + if ((evaluation === null) !== (evaluationRef === null)) { + throw new Error(`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.evaluationHash !== evaluation.evaluationHash) + ) { + throw new Error(`Live delegation turn ${row.turn_id} has a misbound evaluation projection.`) + } const parsed = LiveDelegationTurnSchema.parse({ id: row.turn_id, delegationId: row.delegation_id, @@ -984,6 +1106,8 @@ function toTurn(row: LiveDelegationTurnRow): LiveDelegationTurn { taskContract, taskContractRef, inheritedTaskContractRef, + evaluation, + evaluationRef, effectState: row.effect_state, effectEvidence: parseEffectEvidence(row.effect_evidence_json), createdAt: row.created_at, @@ -995,11 +1119,15 @@ function toTurn(row: LiveDelegationTurnRow): LiveDelegationTurn { ...parsed, taskContract, taskContractRef, - inheritedTaskContractRef + 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, @@ -1009,6 +1137,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 }) } @@ -1138,6 +1268,20 @@ function parseTaskContractRef(value: string | null) { return ref } +function parseTaskEvaluation(value: string | null) { + if (!value) return null + const evaluation = restoreTaskEvaluation(JSON.parse(value)) + if (!evaluation) throw new Error('Stored live delegation Task evaluation is malformed.') + return evaluation +} + +function parseEvaluationRef(value: string | null) { + if (!value) return null + const ref = restoreEvaluationRef(JSON.parse(value)) + if (!ref) throw new Error('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 6da6075da..5204f0f05 100644 --- a/src/main/orchestration/liveDelegationService.ts +++ b/src/main/orchestration/liveDelegationService.ts @@ -39,6 +39,7 @@ import type { 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' @@ -63,13 +64,17 @@ import { createLiveDelegationTaskContractInput, LIVE_DELEGATION_REQUIRED_RESULT_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.]' @@ -509,6 +514,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 @@ -1213,7 +1222,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) @@ -1225,6 +1235,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, @@ -1654,8 +1675,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 @@ -1671,52 +1694,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, @@ -1883,11 +1860,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 } } @@ -1896,11 +1886,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 } } @@ -1914,12 +1912,20 @@ function projectDelegationSummary(delegation: LiveDelegation): LiveDelegationSum } function projectTurnSummary(turn: LiveDelegationTurn): LiveDelegationTurnSummary { - const { prompt, resultSummary, error, ...identity } = turn + const { prompt, resultSummary, error, 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/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/taskEvaluation.ts b/src/main/tape/domain/taskEvaluation.ts new file mode 100644 index 000000000..a00f887ab --- /dev/null +++ b/src/main/tape/domain/taskEvaluation.ts @@ -0,0 +1,558 @@ +import { Buffer } from 'node:buffer' +import { createHash } from 'node:crypto' +import Ajv, { type AnySchema, type ErrorObject } from 'ajv' +import safeRegex from 'safe-regex2' +import { + DEEPCHAT_EVALUATION_REF_SCHEMA_VERSION, + DEEPCHAT_TASK_EVALUATION_HASH_VERSION, + DEEPCHAT_TASK_EVALUATION_SCHEMA_VERSION, + DEEPCHAT_TASK_EVALUATOR_VERSION, + DeepChatTaskEvaluationProjectionSchema, + MAX_TASK_EVALUATION_BYTES, + MAX_TASK_EVALUATION_CANDIDATE_BYTES, + MAX_TASK_EVALUATION_PARENT_EVIDENCE, + MAX_TASK_EVALUATION_RECORDS, + type DeepChatEvaluationRef, + type DeepChatTaskContract, + type DeepChatTaskEvaluation, + type DeepChatTaskEvaluationExecutionStatus, + type DeepChatTaskEvaluationReasonCode, + type DeepChatTaskEvaluationRecord, + type DeepChatTaskEvaluationSummary +} from '@shared/types/task-contract' +import type { JsonValue } from '@shared/contracts/json' +import { + indexMarkdownLevelTwoSections, + removeEnclosingMarkdownFence +} from '@shared/orchestration/liveDelegationMarkdown' +import { canonicalJsonStringifyData, hashJsonData } from './canonicalJson' +import { isDeepChatTaskContract } from './taskContract' + +const MAX_CANDIDATE_JSON_DEPTH = 64 +const MAX_CANDIDATE_JSON_NODES = 4_096 +const MAX_EVIDENCE_PATH_CHARACTERS = 1_024 +const MAX_EVIDENCE_KEYWORD_CHARACTERS = 128 +const SHA_256_PATTERN = /^[0-9a-f]{64}$/u +const SUCCESS_REASON_CODES = new Set([ + 'required_sections_present', + 'result_schema_valid' +]) +const SINGLE_SCHEMA_KEYWORDS = [ + 'additionalItems', + 'additionalProperties', + 'contains', + 'else', + 'if', + 'items', + 'not', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties' +] as const +const ARRAY_SCHEMA_KEYWORDS = ['allOf', 'anyOf', 'oneOf', 'prefixItems'] as const +const MAP_SCHEMA_KEYWORDS = [ + '$defs', + 'definitions', + 'dependencies', + 'dependentSchemas', + 'patternProperties', + 'properties' +] as const + +type ParsedResultSection = + | { state: 'missing' } + | { state: 'invalid' } + | { state: 'too_complex' } + | { state: 'available'; value: unknown } + +type CachedSchemaEvaluation = Pick< + DeepChatTaskEvaluationRecord, + 'outcome' | 'code' | 'instancePath' | 'keyword' +> + +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 verdict = records.some((record) => record.outcome === 'failed') + ? 'failed' + : records.some((record) => record.outcome === 'indeterminate') + ? 'indeterminate' + : 'passed' + const reasonCodes = [ + ...new Set(records.filter((record) => record.outcome !== 'passed').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, + turnId: input.contract.taskDescription.turnId, + taskContractHash: input.contract.contractHash, + candidate, + executionStatus: input.executionStatus, + verdict, + disposition: verdict === 'passed' ? 'accepted' : 'parked', + 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 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: DeepChatTaskEvaluation, + evaluationRef: DeepChatEvaluationRef, + maxEvidenceRecords = MAX_TASK_EVALUATION_PARENT_EVIDENCE +): DeepChatTaskEvaluationSummary { + const canonicalEvaluation = restoreTaskEvaluation(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 relevant = canonicalEvaluation.records.filter((record) => record.outcome !== 'passed') + const evidence = relevant.slice(0, evidenceLimit) + return deepFreeze({ + verdict: canonicalEvaluation.verdict, + disposition: canonicalEvaluation.disposition, + reasonCodes: [...canonicalEvaluation.reasonCodes], + candidate: canonicalEvaluation.candidate, + evidence, + evaluationRef: canonicalRef, + omittedEvidenceCount: + canonicalEvaluation.omittedRecordCount + Math.max(0, relevant.length - evidence.length) + }) +} + +function evaluateRequirements( + contract: DeepChatTaskContract, + candidateResult: string +): DeepChatTaskEvaluationRecord[] { + const sections = indexMarkdownLevelTwoSections(candidateResult) + const parsedSections = new Map() + const schemaEvaluations = new Map() + const ajv = new Ajv({ + allErrors: false, + strict: true, + validateFormats: false, + messages: false + }) + + return contract.taskHarness.acceptance.map((requirement) => { + if (requirement.kind === 'required_sections') { + const missing = requirement.sections.filter( + (section) => !(sections.get(section.toLowerCase())?.body.trim() ?? '') + ) + return evaluationRecord({ + requirementId: requirement.id, + requirementKind: requirement.kind, + outcome: missing.length === 0 ? 'passed' : 'failed', + code: missing.length === 0 ? 'required_sections_present' : 'required_sections_missing', + section: missing[0] ?? null, + additionalEvidenceCount: Math.max(0, missing.length - 1) + }) + } + + const sectionIdentity = requirement.section.toLowerCase() + let parsedSection = parsedSections.get(sectionIdentity) + if (!parsedSection) { + const section = sections.get(sectionIdentity) + if (!section?.body.trim()) { + parsedSection = { state: 'missing' } + } else { + try { + const value = JSON.parse(removeEnclosingMarkdownFence(section.body)) as unknown + parsedSection = isBoundedCandidateJson(value) + ? { state: 'available', value } + : { state: 'too_complex' } + } catch { + parsedSection = { state: 'invalid' } + } + } + parsedSections.set(sectionIdentity, parsedSection) + } + + if (parsedSection.state === 'missing') { + return evaluationRecord({ + requirementId: requirement.id, + requirementKind: requirement.kind, + outcome: 'failed', + code: 'result_section_missing', + section: requirement.section + }) + } + if (parsedSection.state === 'invalid') { + return evaluationRecord({ + requirementId: requirement.id, + requirementKind: requirement.kind, + outcome: 'failed', + code: 'result_json_invalid', + section: requirement.section + }) + } + if (parsedSection.state === 'too_complex') { + return evaluationRecord({ + requirementId: requirement.id, + requirementKind: requirement.kind, + outcome: 'indeterminate', + code: 'candidate_too_complex', + section: requirement.section + }) + } + + const schemaCacheKey = `${sectionIdentity}\0${hashJsonData(requirement.schema)}` + let schemaEvaluation = schemaEvaluations.get(schemaCacheKey) + if (!schemaEvaluation) { + try { + assertSafeSchemaRegexes(requirement.schema) + const validate = ajv.compile(requirement.schema as AnySchema) + schemaEvaluation = validate(parsedSection.value) + ? { + outcome: 'passed', + code: 'result_schema_valid', + instancePath: null, + keyword: null + } + : schemaMismatchEvidence(validate.errors?.[0]) + } catch { + schemaEvaluation = { + outcome: 'indeterminate', + code: 'evaluator_error', + instancePath: null, + keyword: null + } + } + schemaEvaluations.set(schemaCacheKey, schemaEvaluation) + } + return evaluationRecord({ + requirementId: requirement.id, + requirementKind: requirement.kind, + section: requirement.section, + ...schemaEvaluation + }) + }) +} + +function schemaMismatchEvidence(error: ErrorObject | null | undefined): CachedSchemaEvaluation { + return { + outcome: 'failed', + code: 'result_schema_mismatch', + instancePath: normalizeEvidenceText(error?.instancePath, MAX_EVIDENCE_PATH_CHARACTERS), + keyword: normalizeEvidenceText(error?.keyword, MAX_EVIDENCE_KEYWORD_CHARACTERS) + } +} + +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, + instancePath: input.instancePath ?? null, + keyword: input.keyword ?? 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.verdict === 'passed') !== (evaluation.disposition === 'accepted')) return false + 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 !== 'passed') + .map((record) => record.code) + ) + ].sort(compareCodePoints) + if ( + evaluation.omittedRecordCount === 0 && + canonicalJsonStringifyData(evaluation.reasonCodes) !== + canonicalJsonStringifyData(recordedReasonCodes) + ) { + return false + } + const reasonOutcomes = evaluation.reasonCodes.map(reasonCodeOutcome) + const expectedVerdict = reasonOutcomes.includes('failed') + ? 'failed' + : reasonOutcomes.includes('indeterminate') + ? 'indeterminate' + : 'passed' + if (evaluation.verdict !== expectedVerdict) return false + return evaluation.records.every( + (record) => + recordMatchesReasonCode(record) && + (record.outcome === 'passed' || evaluation.reasonCodes.includes(record.code)) + ) +} + +function recordMatchesReasonCode(record: DeepChatTaskEvaluationRecord): boolean { + const expectedOutcome = reasonCodeOutcome(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 reasonCodeOutcome( + code: DeepChatTaskEvaluationReasonCode +): DeepChatTaskEvaluationRecord['outcome'] { + if (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 isBoundedCandidateJson(value: unknown): boolean { + const state = { nodes: 0 } + const visit = (candidate: unknown, depth: number): boolean => { + state.nodes += 1 + if (depth > MAX_CANDIDATE_JSON_DEPTH || state.nodes > MAX_CANDIDATE_JSON_NODES) return false + if (candidate === null || typeof candidate !== 'object') return true + if (Array.isArray(candidate)) return candidate.every((entry) => visit(entry, depth + 1)) + return Object.values(candidate as Record).every((entry) => + visit(entry, depth + 1) + ) + } + return visit(value, 0) +} + +function assertSafeSchemaRegexes(value: JsonValue): void { + if (typeof value === 'boolean' || !value || typeof value !== 'object' || Array.isArray(value)) { + return + } + const schema = value as Record + if (typeof schema.pattern === 'string' && !safeRegex(schema.pattern)) { + throw new TaskEvaluationError('Result schema contains an unsafe pattern.', 'invalid_input') + } + if ( + schema.patternProperties && + typeof schema.patternProperties === 'object' && + !Array.isArray(schema.patternProperties) + ) { + for (const pattern of Object.keys(schema.patternProperties)) { + if (!safeRegex(pattern)) { + throw new TaskEvaluationError( + 'Result schema contains an unsafe pattern property.', + 'invalid_input' + ) + } + } + } + + for (const keyword of SINGLE_SCHEMA_KEYWORDS) { + visitNestedSchema(schema[keyword]) + } + for (const keyword of ARRAY_SCHEMA_KEYWORDS) { + const nested = schema[keyword] + if (Array.isArray(nested)) { + for (const child of nested) visitNestedSchema(child) + } + } + for (const keyword of MAP_SCHEMA_KEYWORDS) { + const nested = schema[keyword] + if (!nested || typeof nested !== 'object' || Array.isArray(nested)) continue + for (const child of Object.values(nested)) visitNestedSchema(child) + } +} + +function visitNestedSchema(value: JsonValue | undefined): void { + if (Array.isArray(value)) { + for (const child of value) visitNestedSchema(child) + return + } + if (typeof value === 'boolean' || (value && typeof value === 'object')) { + assertSafeSchemaRegexes(value) + } +} + +function normalizeEvidenceText(value: string | undefined, maxCharacters: number): string | null { + if (!value) return null + const sanitized = value.replaceAll('\0', '\uFFFD') + return sanitized.length <= maxCharacters ? sanitized : sanitized.slice(0, maxCharacters) +} + +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/shared/orchestration/liveDelegation.ts b/src/shared/orchestration/liveDelegation.ts index 9ae04d683..04ef897bf 100644 --- a/src/shared/orchestration/liveDelegation.ts +++ b/src/shared/orchestration/liveDelegation.ts @@ -1,6 +1,9 @@ import { z } from 'zod' import { OrchestrationEffectEvidenceSchema, OrchestrationEffectStateSchema } from './toolEffect' import { + DeepChatEvaluationRefSchema, + DeepChatTaskEvaluationProjectionSchema, + DeepChatTaskEvaluationSummarySchema, DeepChatTaskContractProjectionSchema, DeepChatTaskContractRefSchema } from '../types/task-contract' @@ -140,6 +143,8 @@ const LiveDelegationTurnBaseSchema = z taskContract: DeepChatTaskContractProjectionSchema.nullable().default(null), taskContractRef: DeepChatTaskContractRefSchema.nullable().default(null), inheritedTaskContractRef: DeepChatTaskContractRefSchema.nullable().default(null), + evaluation: DeepChatTaskEvaluationProjectionSchema.nullable().default(null), + evaluationRef: DeepChatEvaluationRefSchema.nullable().default(null), effectState: OrchestrationEffectStateSchema, effectEvidence: OrchestrationEffectEvidenceSchema.nullable(), createdAt: z.number().int().nonnegative(), @@ -150,10 +155,10 @@ const LiveDelegationTurnBaseSchema = z .strict() export const LiveDelegationTurnSchema = LiveDelegationTurnBaseSchema.superRefine( - validateLiveDelegationEffect + validateLiveDelegationTurn ) -export const LiveDelegationEventSchema = z +const LiveDelegationEventBaseSchema = z .object({ id: z.number().int().positive(), delegationId: LiveDelegationIdSchema, @@ -163,10 +168,16 @@ export const LiveDelegationEventSchema = z content: z.string(), relatedTurnId: LiveDelegationIdSchema.nullable(), consumedByTurnId: LiveDelegationIdSchema.nullable(), + evaluation: DeepChatTaskEvaluationProjectionSchema.nullable().default(null), + evaluationRef: DeepChatEvaluationRefSchema.nullable().default(null), createdAt: z.number().int().nonnegative() }) .strict() +export const LiveDelegationEventSchema = LiveDelegationEventBaseSchema.superRefine( + validateLiveDelegationEvent +) + export type LiveDelegationStatus = z.infer export type LiveDelegationOperation = z.infer export type LiveDelegationTurnStatus = z.infer @@ -195,20 +206,28 @@ export const LiveDelegationTurnSummarySchema = LiveDelegationTurnBaseSchema.omit error: true, taskContract: true, taskContractRef: true, - inheritedTaskContractRef: 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() @@ -223,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() @@ -273,3 +293,73 @@ 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.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/task-contract.ts b/src/shared/types/task-contract.ts index 34e719981..49e7713d6 100644 --- a/src/shared/types/task-contract.ts +++ b/src/shared/types/task-contract.ts @@ -4,6 +4,9 @@ import { JsonValueSchema, type JsonValue } from '../contracts/json' export const DEEPCHAT_TASK_CONTRACT_SCHEMA_VERSION = 1 as const export const DEEPCHAT_TASK_CONTRACT_HASH_VERSION = 1 as const export const DEEPCHAT_TASK_CONTRACT_REF_SCHEMA_VERSION = 1 as const +export const DEEPCHAT_TASK_EVALUATION_SCHEMA_VERSION = 1 as const +export const DEEPCHAT_TASK_EVALUATION_HASH_VERSION = 1 as const +export const DEEPCHAT_TASK_EVALUATOR_VERSION = 'task-contract-v1' as const export const DEEPCHAT_EVALUATION_REF_SCHEMA_VERSION = 1 as const export const MAX_TASK_CONTRACT_BYTES = 128 * 1024 @@ -11,6 +14,36 @@ export const MAX_TASK_CONTRACT_REQUIREMENTS = 64 export const MAX_TASK_CONTRACT_RESULT_SCHEMA_BYTES = 32 * 1024 export const MAX_TASK_CONTRACT_REF_BYTES = 4 * 1024 export const MAX_TASK_EVALUATION_BYTES = 32 * 1024 +export const MAX_TASK_EVALUATION_REF_BYTES = 4 * 1024 +export const MAX_TASK_EVALUATION_RECORDS = 64 +export const MAX_TASK_EVALUATION_PARENT_EVIDENCE = 16 +export const MAX_TASK_EVALUATION_CANDIDATE_BYTES = 1024 * 1024 + +export const DEEPCHAT_TASK_EVALUATION_REASON_CODES = [ + 'candidate_missing', + 'candidate_too_large', + 'candidate_too_complex', + 'execution_cancelled', + 'execution_interrupted', + 'required_sections_present', + 'required_sections_missing', + 'result_schema_valid', + 'result_section_missing', + 'result_json_invalid', + 'result_schema_mismatch', + 'evaluator_error' +] as const + +export type DeepChatTaskEvaluationReasonCode = + (typeof DEEPCHAT_TASK_EVALUATION_REASON_CODES)[number] +export type DeepChatTaskEvaluationVerdict = 'passed' | 'failed' | 'indeterminate' +export type DeepChatTaskEvaluationDisposition = 'accepted' | 'parked' +export type DeepChatTaskEvaluationExecutionStatus = + | 'completed' + | 'failed' + | 'cancelled' + | 'interrupted' +export type DeepChatTaskEvaluationOutcome = 'passed' | 'failed' | 'indeterminate' export interface DeepChatTaskContractRef { readonly schemaVersion: typeof DEEPCHAT_TASK_CONTRACT_REF_SCHEMA_VERSION @@ -28,6 +61,53 @@ export interface DeepChatEvaluationRef { readonly evaluationHash: string } +export type DeepChatTaskEvaluationCandidate = + | { + readonly kind: 'answer' + readonly sha256: string + readonly utf8Bytes: number + } + | { + readonly kind: 'absent' + } + +export interface DeepChatTaskEvaluationRecord { + readonly requirementId: string | null + readonly requirementKind: 'required_sections' | 'result_schema' | null + readonly outcome: DeepChatTaskEvaluationOutcome + readonly code: DeepChatTaskEvaluationReasonCode + readonly section: string | null + readonly instancePath: string | null + readonly keyword: string | null + readonly additionalEvidenceCount: number +} + +export interface DeepChatTaskEvaluation { + readonly schemaVersion: typeof DEEPCHAT_TASK_EVALUATION_SCHEMA_VERSION + readonly hashVersion: typeof DEEPCHAT_TASK_EVALUATION_HASH_VERSION + readonly evaluatorVersion: typeof DEEPCHAT_TASK_EVALUATOR_VERSION + readonly turnId: string + readonly taskContractHash: string + readonly candidate: DeepChatTaskEvaluationCandidate + readonly executionStatus: DeepChatTaskEvaluationExecutionStatus + readonly verdict: DeepChatTaskEvaluationVerdict + readonly disposition: DeepChatTaskEvaluationDisposition + readonly reasonCodes: readonly DeepChatTaskEvaluationReasonCode[] + readonly records: readonly DeepChatTaskEvaluationRecord[] + readonly omittedRecordCount: number + readonly evaluationHash: string +} + +export interface DeepChatTaskEvaluationSummary { + readonly verdict: DeepChatTaskEvaluationVerdict + readonly disposition: DeepChatTaskEvaluationDisposition + readonly reasonCodes: readonly DeepChatTaskEvaluationReasonCode[] + readonly candidate: DeepChatTaskEvaluationCandidate + readonly evidence: readonly DeepChatTaskEvaluationRecord[] + readonly evaluationRef: DeepChatEvaluationRef + readonly omittedEvidenceCount: number +} + export interface DeepChatTaskSchema { readonly input: { readonly kind: 'text' @@ -126,6 +206,80 @@ export const DeepChatEvaluationRefSchema = z }) .strict() +export const DeepChatTaskEvaluationCandidateSchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('answer'), + sha256: Sha256Schema, + utf8Bytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) + }) + .strict(), + z.object({ kind: z.literal('absent') }).strict() +]) + +export const DeepChatTaskEvaluationReasonCodeSchema = z.enum(DEEPCHAT_TASK_EVALUATION_REASON_CODES) + +export const DeepChatTaskEvaluationRecordSchema = z + .object({ + requirementId: StoredIdSchema.nullable(), + requirementKind: z.enum(['required_sections', 'result_schema']).nullable(), + outcome: z.enum(['passed', 'failed', 'indeterminate']), + code: DeepChatTaskEvaluationReasonCodeSchema, + 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 DeepChatTaskEvaluationProjectionSchema: z.ZodType = 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), + 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(DeepChatTaskEvaluationReasonCodeSchema), + records: z.array(DeepChatTaskEvaluationRecordSchema).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 evaluation may be accepted' + }) + } + }) + +export const DeepChatTaskEvaluationSummarySchema: z.ZodType = z + .object({ + verdict: z.enum(['passed', 'failed', 'indeterminate']), + disposition: z.enum(['accepted', 'parked']), + reasonCodes: z.array(DeepChatTaskEvaluationReasonCodeSchema), + 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() + .superRefine((evaluation, context) => { + if ((evaluation.verdict === 'passed') !== (evaluation.disposition === 'accepted')) { + context.addIssue({ + code: 'custom', + path: ['disposition'], + message: 'Only a passed evaluation may be accepted' + }) + } + }) + 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() diff --git a/test/main/orchestration/liveDelegationMigration.test.ts b/test/main/orchestration/liveDelegationMigration.test.ts index 5c06b6c54..2e0736ad7 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,7 @@ 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() }) @@ -228,7 +230,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 +246,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 4cc9b9292..3190c7305 100644 --- a/test/main/orchestration/liveDelegationRepository.test.ts +++ b/test/main/orchestration/liveDelegationRepository.test.ts @@ -27,6 +27,9 @@ const tapeStoreModule = Database 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,6 +39,7 @@ const LiveDelegationEventsTableCtor = eventsModule?.LiveDelegationEventsTable! const LiveDelegationRepositoryCtor = repositoryModule?.LiveDelegationRepository! 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( @@ -45,7 +49,8 @@ const describeIfSqlite = nativeSqliteDescribeIf( LiveDelegationEventsTableCtor && LiveDelegationRepositoryCtor && DeepChatContractStoreCtor && - TaskContractServiceCtor + TaskContractServiceCtor && + TaskEvaluationServiceCtor ), 'Live delegation persistence modules are unavailable' ) @@ -72,7 +77,8 @@ describeIfSqlite('LiveDelegationRepository', () => { contractStore.createTable() repository = new LiveDelegationRepositoryCtor( new LiveDelegationDatabaseCtor({ getDatabase: () => db! }), - new TaskContractServiceCtor(() => contractStore) + new TaskContractServiceCtor(() => contractStore), + new TaskEvaluationServiceCtor(() => contractStore) ) addSession('parent') }) @@ -105,6 +111,23 @@ describeIfSqlite('LiveDelegationRepository', () => { }) } + function completeAcceptedAnswer(): 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') + } + it('persists the thread and initial turn before child binding', () => { const created = createDelegation() @@ -243,7 +266,8 @@ describeIfSqlite('LiveDelegationRepository', () => { }, ensureParentTaskContract: (input) => strictWriter.ensureParentTaskContract(input), ensureChildTaskContract: (input) => strictWriter.ensureChildTaskContract(input) - } + }, + new TaskEvaluationServiceCtor(() => contractStore) ) expect(() => @@ -500,7 +524,8 @@ describeIfSqlite('LiveDelegationRepository', () => { strictWriter.ensureChildTaskContract(input) throw new Error('projection write failed') } - } + }, + new TaskEvaluationServiceCtor(() => contractStore) ) expect(() => @@ -857,15 +882,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', @@ -882,4 +917,128 @@ 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: completeAcceptedAnswer(), + 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: { verdict: 'passed', disposition: 'accepted', 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('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: completeAcceptedAnswer(), + 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 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: completeAcceptedAnswer(), + 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: completeAcceptedAnswer(), + 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 e82e21345..d8b0ca53c 100644 --- a/test/main/orchestration/liveDelegationService.test.ts +++ b/test/main/orchestration/liveDelegationService.test.ts @@ -36,6 +36,9 @@ const tapeStoreModule = Database 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! @@ -47,6 +50,7 @@ const LiveDelegationTaskContractErrorCtor = repositoryModule?.LiveDelegationTask const LiveDelegationServiceCtor = serviceModule?.LiveDelegationService! const DeepChatContractStoreCtor = tapeStoreModule?.DeepChatContractStore! const TaskContractServiceCtor = taskContractServiceModule?.TaskContractService! +const TaskEvaluationServiceCtor = taskEvaluationServiceModule?.TaskEvaluationService! const describeIfSqlite = nativeSqliteDescribeIf( Boolean( LiveDelegationDatabaseCtor && @@ -57,7 +61,8 @@ const describeIfSqlite = nativeSqliteDescribeIf( LiveDelegationTaskContractErrorCtor && LiveDelegationServiceCtor && DeepChatContractStoreCtor && - TaskContractServiceCtor + TaskContractServiceCtor && + TaskEvaluationServiceCtor ), 'Live delegation lifecycle modules are unavailable' ) @@ -90,7 +95,8 @@ describeIfSqlite('LiveDelegationService', () => { contractStore.createTable() repository = new LiveDelegationRepositoryCtor( new LiveDelegationDatabaseCtor({ getDatabase: () => db! }), - new TaskContractServiceCtor(() => contractStore) + new TaskContractServiceCtor(() => contractStore), + new TaskEvaluationServiceCtor(() => contractStore) ) harness = createSessionHarness(db) deletionGate = new SessionDeletionGate() @@ -129,18 +135,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({ + verdict: 'failed', + disposition: 'parked', + reasonCodes: ['required_sections_missing'] + }) }) ] }) expect(repository.require(delegationId).status).toBe('idle') + expect(repository.requireTurn(detail.turns[0]!.id)).toMatchObject({ + status: 'completed', + evaluation: { verdict: 'failed', disposition: 'parked' } + }) expect(harness.sessions.linkSubagentTape).toHaveBeenCalledWith( expect.objectContaining({ parentSessionId: 'parent', @@ -151,6 +167,38 @@ describeIfSqlite('LiveDelegationService', () => { ) }) + it('surfaces one accepted evaluation through wait, inspect, and read_result', async () => { + const detail = await service.spawn('parent', { + slotId: 'reviewer', + title: 'Review accepted 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 = completeAcceptedAnswer() + 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({ + verdict: 'passed', + disposition: 'accepted', + reasonCodes: [], + evidence: [] + }) + + const inspected = service.inspect('parent', detail.delegation.id) + expect(inspected.turns[0]!.evaluation).toEqual(waitedEvaluation) + + 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() @@ -478,10 +526,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) }) @@ -492,22 +540,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: { verdict: 'failed', disposition: 'parked' } + }) + expect(repository.require(detail.delegation.id).status).toBe('idle') }) it('pages the complete verified answer without exposing process output', async () => { @@ -596,6 +681,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: { verdict: 'failed', disposition: 'parked' } + }) + }) + 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', { @@ -1761,7 +1865,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: { + verdict: 'indeterminate', + disposition: 'parked', + reasonCodes: ['candidate_missing'] + } }) expect(harness.sessions.linkSubagentTape).toHaveBeenCalledWith( expect.objectContaining({ outcome: 'error', resultSummary: null }) @@ -1770,7 +1879,12 @@ 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({ + verdict: 'indeterminate', + disposition: 'parked', + reasonCodes: ['candidate_missing'] + }) }) ] }) @@ -1805,6 +1919,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', @@ -2118,6 +2233,23 @@ describeIfSqlite('LiveDelegationService', () => { }) }) +function completeAcceptedAnswer(): 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/tape/taskContractPersistence.test.ts b/test/main/tape/taskContractPersistence.test.ts index 34e34d042..7e174663f 100644 --- a/test/main/tape/taskContractPersistence.test.ts +++ b/test/main/tape/taskContractPersistence.test.ts @@ -1,6 +1,7 @@ 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 @@ -9,13 +10,22 @@ const tapeStoreModule = Database 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), + Boolean( + DeepChatTapeEntriesTableCtor && + DeepChatContractStoreCtor && + TaskContractServiceCtor && + TaskEvaluationServiceCtor + ), 'TaskContract persistence modules are unavailable' ) @@ -51,6 +61,7 @@ itIfSqlite( genericStore.createTable() const contractStore = new DeepChatContractStoreCtor(db) const service = new TaskContractServiceCtor(() => contractStore) + const evaluationService = new TaskEvaluationServiceCtor(() => contractStore) expect(() => genericStore.appendEvent({ @@ -105,20 +116,82 @@ itIfSqlite( ) ).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(buildEffectiveTapeView(rows).rows.map((row) => row.name)).not.toContain( - 'contract/task_frozen' - ) + 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) - ).toContain('contract/task_frozen') + ).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', 3, 'event', 'contract/future_fact', - '{"marker":"future-contract-marker"}', '{}', 300)` + ) 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( @@ -126,12 +199,12 @@ itIfSqlite( ) expect( contractStore.searchEffectiveSourcesAtHeads( - [{ sessionId: 'parent-1', maxEntryId: 3 }], + [{ sessionId: 'parent-1', maxEntryId: 4 }], 'future-contract-marker' ) ).toEqual([]) expect( - contractStore.getEffectiveContextRowsAtHead({ sessionId: 'parent-1', maxEntryId: 3 }, [3], { + contractStore.getEffectiveContextRowsAtHead({ sessionId: 'parent-1', maxEntryId: 4 }, [4], { before: 0, after: 0, limit: 10 diff --git a/test/main/tape/taskEvaluation.test.ts b/test/main/tape/taskEvaluation.test.ts new file mode 100644 index 000000000..ffebd631a --- /dev/null +++ b/test/main/tape/taskEvaluation.test.ts @@ -0,0 +1,280 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { + MAX_TASK_EVALUATION_CANDIDATE_BYTES, + type DeepChatTaskAcceptanceRequirement, + type DeepChatTaskEvaluationExecutionStatus +} from '@shared/types/task-contract' +import { hashJsonData } from '@/tape/domain/canonicalJson' +import { buildTaskContract } from '@/tape/domain/taskContract' +import { + buildTaskEvaluation, + projectTaskEvaluationSummary, + restoreTaskEvaluation, + serializeTaskEvaluation +} from '@/tape/domain/taskEvaluation' + +const DEFAULT_ACCEPTANCE: readonly DeepChatTaskAcceptanceRequirement[] = [ + { + id: 'sections', + kind: 'required_sections', + level: 2, + sections: ['Handoff', 'Validation'] + }, + { + id: 'result', + kind: 'result_schema', + section: 'Result', + schema: { + type: 'object', + properties: { decision: { type: 'string' } }, + required: ['decision'], + additionalProperties: false + } + } +] + +function createContract( + acceptance: readonly DeepChatTaskAcceptanceRequirement[] = DEFAULT_ACCEPTANCE +) { + 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') }, + acceptance, + predecessorEvaluationRef: null, + maxToolEffect: 'write', + maxSubagentDepth: 0 + }) +} + +function evaluate( + candidateResult: string | null, + executionStatus: DeepChatTaskEvaluationExecutionStatus = 'completed', + acceptance: readonly DeepChatTaskAcceptanceRequirement[] = DEFAULT_ACCEPTANCE +) { + return buildTaskEvaluation({ + contract: createContract(acceptance), + executionStatus, + candidateResult + }) +} + +describe('Task evaluation domain', () => { + it('evaluates required sections and one fenced JSON result as a canonical pass', () => { + const candidate = [ + '```markdown', + '## Handoff', + 'This heading is fenced and must not count.', + '```', + '## Handoff', + 'Use the reviewed result.', + '## Result', + '```json', + '{"decision":"accept"}', + '```', + '## Validation', + 'Focused tests passed.' + ].join('\n') + + const first = evaluate(candidate) + const second = evaluate(candidate) + + expect(first).toEqual(second) + expect(first).toMatchObject({ + verdict: 'passed', + disposition: 'accepted', + reasonCodes: [], + records: [ + { requirementId: 'result', code: 'result_schema_valid', outcome: 'passed' }, + { requirementId: 'sections', code: 'required_sections_present', outcome: 'passed' } + ] + }) + 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({ + verdict: 'passed', + disposition: 'accepted', + evidence: [], + omittedEvidenceCount: 0 + }) + expect(summary.evaluationRef).not.toBe(mutableRef) + expect(Object.isFrozen(mutableRef)).toBe(false) + }) + + it('lets a definite requirement failure win over an evaluator failure', () => { + const result = evaluate( + ['## Handoff', 'Review complete.', '## Result', '{"value":"aaaa"}'].join('\n'), + 'completed', + [ + { + id: 'schema', + kind: 'result_schema', + section: 'Result', + schema: { type: 'object', properties: { value: { type: 'string', pattern: '(a+)+$' } } } + }, + { + id: 'sections', + kind: 'required_sections', + level: 2, + sections: ['Handoff', 'Validation'] + } + ] + ) + + expect(result).toMatchObject({ + verdict: 'failed', + disposition: 'parked', + reasonCodes: ['evaluator_error', 'required_sections_missing'] + }) + expect(result.records).toEqual([ + expect.objectContaining({ requirementId: 'schema', code: 'evaluator_error' }), + 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: 'schema' })], + omittedEvidenceCount: 1 + }) + }) + + it('keeps a valid contract verdict independent from execution failure', () => { + const result = evaluate( + '## Handoff\nDone.\n## Result\n{"decision":"accept"}\n## Validation\nChecked.', + 'failed' + ) + + expect(result).toMatchObject({ + executionStatus: 'failed', + verdict: 'passed', + disposition: 'accepted', + reasonCodes: [] + }) + }) + + it('does not interpret JSON Schema const data as executable pattern syntax', () => { + const result = evaluate('## Result\n{"metadata":{"pattern":"(a+)+$"}}', 'completed', [ + { + id: 'result', + kind: 'result_schema', + section: 'Result', + schema: { + type: 'object', + properties: { metadata: { const: { pattern: '(a+)+$' } } }, + required: ['metadata'] + } + } + ]) + + expect(result).toMatchObject({ + verdict: 'passed', + disposition: 'accepted', + reasonCodes: [] + }) + }) + + it.each([ + { + name: 'missing result section', + candidate: '## Handoff\nDone.\n## Validation\nChecked.', + code: 'result_section_missing', + keyword: null + }, + { + name: 'malformed result JSON', + candidate: '## Handoff\nDone.\n## Result\n{nope}\n## Validation\nChecked.', + code: 'result_json_invalid', + keyword: null + }, + { + name: 'schema mismatch', + candidate: '## Handoff\nDone.\n## Result\n{}\n## Validation\nChecked.', + code: 'result_schema_mismatch', + keyword: 'required' + } + ])('parks a completed candidate with $name', ({ candidate, code, keyword }) => { + const result = evaluate(candidate) + + expect(result).toMatchObject({ verdict: 'failed', disposition: 'parked' }) + expect(result.records[0]).toMatchObject({ code, keyword }) + }) + + 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({ + verdict: 'indeterminate', + disposition: 'parked', + reasonCodes: [code], + records: [{ code, outcome: 'indeterminate' }] + }) + }) + + it('bounds parsed candidate structure before schema validation', () => { + const nested = `${'['.repeat(66)}0${']'.repeat(66)}` + const result = evaluate(`## Result\n${nested}`, 'completed', [ + { id: 'result', kind: 'result_schema', section: 'Result', schema: {} } + ]) + + expect(result).toMatchObject({ + verdict: 'indeterminate', + disposition: 'parked', + reasonCodes: ['candidate_too_complex'] + }) + }) + + it('rejects hash-valid projections that violate canonical reason evidence', () => { + const evaluation = evaluate( + '## Handoff\nDone.\n## Result\n{"decision":"accept"}\n## Validation\nChecked.' + ) + const { evaluationHash: _evaluationHash, ...draft } = evaluation + const forgedDraft = { + ...draft, + verdict: 'failed' as const, + disposition: 'parked' as const, + reasonCodes: ['candidate_missing' as const] + } + const forged = { ...forgedDraft, evaluationHash: hashJsonData(forgedDraft) } + + expect(restoreTaskEvaluation(forged)).toBeNull() + }) +}) From 36349a41b034272c541f4be85dd53619fc34ffcd Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 03:18:00 +0800 Subject: [PATCH 10/37] fix(tape): bind evaluation refs to parent --- src/main/tape/domain/taskContract.ts | 22 +++++++++++++++------- test/main/tape/taskContract.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/main/tape/domain/taskContract.ts b/src/main/tape/domain/taskContract.ts index 9ed854e1f..6b98a06c6 100644 --- a/src/main/tape/domain/taskContract.ts +++ b/src/main/tape/domain/taskContract.ts @@ -348,6 +348,19 @@ function buildTaskContractDraft( 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, @@ -360,19 +373,14 @@ function buildTaskContractDraft( completionMode: 'single_response', retryMode: 'parent_follow_up', creationReason, - predecessorEvaluationRef: normalizeEvaluationRef(input.predecessorEvaluationRef ?? null) + 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: requireString( - input.parentSessionId, - 'parentSessionId', - MAX_IDENTITY_BYTES, - 256 - ), + 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), diff --git a/test/main/tape/taskContract.test.ts b/test/main/tape/taskContract.test.ts index 3b645b3e6..90b95ec7c 100644 --- a/test/main/tape/taskContract.test.ts +++ b/test/main/tape/taskContract.test.ts @@ -111,6 +111,31 @@ describe('TaskContract domain', () => { ).toThrow(/creationReason is invalid/u) }) + 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, remote references, and bounded-input overflow', () => { expect(() => buildTaskContract( From 2ef87ce3bd86e772e0556d70b5ecb2faccd90c79 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 03:21:51 +0800 Subject: [PATCH 11/37] test(agent): model missing AGENTS file --- test/main/session/runtimeIntegration.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/main/session/runtimeIntegration.test.ts b/test/main/session/runtimeIntegration.test.ts index bddbc5478..7f5076aed 100644 --- a/test/main/session/runtimeIntegration.test.ts +++ b/test/main/session/runtimeIntegration.test.ts @@ -1,3 +1,4 @@ +import * as fs from 'node:fs' import { AppSessionService } from '@/agent/shared/appSessionService' import { describe, it, expect, vi, beforeEach } from 'vitest' import { createDeepChatAgentHarness, type DeepChatAgentHarness } from '@/agent/deepchat/harness' @@ -42,6 +43,12 @@ vi.mock('@/events', async (importOriginal) => { } }) +beforeEach(() => { + vi.mocked(fs.promises.readFile).mockRejectedValue( + Object.assign(new Error('AGENTS.md does not exist'), { code: 'ENOENT' }) + ) +}) + function createMockSqlitePresenter() { // In-memory storage for integration-level testing const sessionsStore = new Map() From 29d9471fbe64d990b528c6249d7adc98b497df4a Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 03:36:26 +0800 Subject: [PATCH 12/37] fix(agent): bind evaluation to tape identity --- .../orchestration/liveDelegationRepository.ts | 1 + src/shared/orchestration/liveDelegation.ts | 1 + .../liveDelegationRepository.test.ts | 27 +++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/src/main/orchestration/liveDelegationRepository.ts b/src/main/orchestration/liveDelegationRepository.ts index 70656b6eb..edd3f8f48 100644 --- a/src/main/orchestration/liveDelegationRepository.ts +++ b/src/main/orchestration/liveDelegationRepository.ts @@ -1088,6 +1088,7 @@ function toTurn(row: LiveDelegationTurnRow): LiveDelegationTurn { evaluation.taskContractHash !== taskContract.contractHash || evaluation.executionStatus !== row.status || evaluationRef?.sessionId !== taskContract.taskDescription.parentSessionId || + evaluationRef.tapeIdentity !== taskContractRef?.tapeIdentity || evaluationRef.evaluationHash !== evaluation.evaluationHash) ) { throw new Error(`Live delegation turn ${row.turn_id} has a misbound evaluation projection.`) diff --git a/src/shared/orchestration/liveDelegation.ts b/src/shared/orchestration/liveDelegation.ts index 04ef897bf..abcc97609 100644 --- a/src/shared/orchestration/liveDelegation.ts +++ b/src/shared/orchestration/liveDelegation.ts @@ -316,6 +316,7 @@ function validateLiveDelegationTurn( 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({ diff --git a/test/main/orchestration/liveDelegationRepository.test.ts b/test/main/orchestration/liveDelegationRepository.test.ts index 3190c7305..6bf5f9e83 100644 --- a/test/main/orchestration/liveDelegationRepository.test.ts +++ b/test/main/orchestration/liveDelegationRepository.test.ts @@ -998,6 +998,33 @@ describeIfSqlite('LiveDelegationRepository', () => { ).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: completeAcceptedAnswer(), + 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! From d6ded604fd870471cf62da82d1f68cc4ac8434ce Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 03:44:03 +0800 Subject: [PATCH 13/37] fix(tape): make workspace hashes portable --- src/main/tape/domain/executionContract.ts | 39 +++++------------ src/main/tape/domain/taskContract.ts | 7 +-- src/main/tape/domain/workspacePath.ts | 52 +++++++++++++++++++++++ test/main/tape/executionContract.test.ts | 33 ++++++++++++++ test/main/tape/taskContract.test.ts | 12 ++++++ 5 files changed, 112 insertions(+), 31 deletions(-) create mode 100644 src/main/tape/domain/workspacePath.ts diff --git a/src/main/tape/domain/executionContract.ts b/src/main/tape/domain/executionContract.ts index b5f12185f..2f540869c 100644 --- a/src/main/tape/domain/executionContract.ts +++ b/src/main/tape/domain/executionContract.ts @@ -1,5 +1,4 @@ import { createHash } from 'node:crypto' -import path from 'node:path' import type { ChatMessage } from '@shared/types/core/chat-message' import { stripToolExecutionContract, @@ -33,6 +32,11 @@ import { 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 @@ -591,10 +595,11 @@ function normalizeWorkspace( const workspacePath = requireString(workspace.path, 'workspace.path', MAX_WORKSPACE_PATH_BYTES, { preserveOuterWhitespace: true }) - if (!path.isAbsolute(workspacePath)) { + const normalized = normalizeAbsoluteWorkspacePath(workspacePath) + if (normalized === null) { throw new ExecutionContractError('workspace.path must be absolute.', 'invalid_input') } - return { kind: 'path', path: path.normalize(workspacePath) } + return { kind: 'path', path: normalized.path } } function normalizeMaxSubagentDepth(value: unknown): number { @@ -684,10 +689,7 @@ function isStoredWorkspace(value: unknown): value is DeepChatExecutionWorkspaceC ) { return false } - return ( - (path.posix.isAbsolute(value.path) && path.posix.normalize(value.path) === value.path) || - (path.win32.isAbsolute(value.path) && path.win32.normalize(value.path) === value.path) - ) + return normalizeAbsoluteWorkspacePath(value.path)?.path === value.path } function isStoredPromptSection(value: unknown): value is DeepChatPromptSectionProvenance { @@ -861,19 +863,6 @@ export function meetToolEffects(left: ToolEffect, right: ToolEffect): ToolEffect return isToolEffectWithinCeiling(left, right) ? left : right } -function normalizeWorkspaceForComparison( - workspace: DeepChatExecutionWorkspaceCeiling -): string | null { - if (workspace.kind === 'runtime_default') return null - if (path.win32.isAbsolute(workspace.path)) { - return `win32:${path.win32.resolve(workspace.path)}` - } - if (path.posix.isAbsolute(workspace.path)) { - return `posix:${path.posix.resolve(workspace.path)}` - } - return null -} - function executionWorkspacesMatch( current: DeepChatExecutionWorkspaceCeiling, ceiling: DeepChatExecutionWorkspaceCeiling @@ -881,9 +870,7 @@ function executionWorkspacesMatch( if (current.kind === 'runtime_default' || ceiling.kind === 'runtime_default') { return current.kind === ceiling.kind } - const currentPath = normalizeWorkspaceForComparison(current) - const ceilingPath = normalizeWorkspaceForComparison(ceiling) - return currentPath !== null && currentPath === ceilingPath + return workspacePathsMatch(current.path, ceiling.path) } function isExecutionWorkspaceWithinTaskCeiling( @@ -894,11 +881,7 @@ function isExecutionWorkspaceWithinTaskCeiling( return execution.kind === taskCeiling.kind } - const relative = path.relative(path.resolve(taskCeiling.path), path.resolve(execution.path)) - return ( - relative === '' || - (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) - ) + return isWorkspacePathWithin(execution.path, taskCeiling.path) } function normalizeTaskContractRef( diff --git a/src/main/tape/domain/taskContract.ts b/src/main/tape/domain/taskContract.ts index 6b98a06c6..160bb7ed6 100644 --- a/src/main/tape/domain/taskContract.ts +++ b/src/main/tape/domain/taskContract.ts @@ -1,5 +1,4 @@ import { Buffer } from 'node:buffer' -import path from 'node:path' import { DEEPCHAT_TASK_CONTRACT_HASH_VERSION, DEEPCHAT_TASK_CONTRACT_SCHEMA_VERSION, @@ -14,6 +13,7 @@ import { } from '@shared/types/task-contract' import type { JsonValue } from '@shared/contracts/json' import { canonicalJsonStringifyData, hashJsonData } from './canonicalJson' +import { normalizeAbsoluteWorkspacePath } from './workspacePath' const MAX_IDENTITY_BYTES = 1_024 const MAX_TITLE_BYTES = 1_024 @@ -124,15 +124,16 @@ function normalizeWorkspace(workspace: DeepChatTaskWorkspaceCeiling): DeepChatTa 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 || - !path.isAbsolute(workspace.path) + normalized === null ) { throw new TaskContractError('workspace.path must be a bounded absolute path.', 'invalid_input') } - return { kind: 'path', path: path.normalize(workspace.path) } + return { kind: 'path', path: normalized.path } } function normalizeEvaluationRef(value: DeepChatEvaluationRef | null): DeepChatEvaluationRef | null { 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/test/main/tape/executionContract.test.ts b/test/main/tape/executionContract.test.ts index a4d3a1523..5bd03dc45 100644 --- a/test/main/tape/executionContract.test.ts +++ b/test/main/tape/executionContract.test.ts @@ -664,5 +664,38 @@ describe('ExecutionContract domain', () => { 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 index 90b95ec7c..c07527d64 100644 --- a/test/main/tape/taskContract.test.ts +++ b/test/main/tape/taskContract.test.ts @@ -111,6 +111,18 @@ describe('TaskContract domain', () => { ).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, From 0105dc639eee1fc41e5c129d50c11fba16612d3c Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 03:55:03 +0800 Subject: [PATCH 14/37] fix(tape): reject asynchronous schemas --- src/main/tape/domain/taskContract.ts | 4 ++-- test/main/tape/taskContract.test.ts | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/tape/domain/taskContract.ts b/src/main/tape/domain/taskContract.ts index 160bb7ed6..93103fef4 100644 --- a/src/main/tape/domain/taskContract.ts +++ b/src/main/tape/domain/taskContract.ts @@ -241,8 +241,8 @@ function assertBoundedJsonSchema( if (!descriptor?.enumerable || !('value' in descriptor)) { throw new TaskContractError(`${label} must contain only data properties.`, 'invalid_input') } - if (key === '$ref') { - throw new TaskContractError(`${label} must not contain $ref.`, 'invalid_input') + if (key === '$ref' || key === '$async') { + throw new TaskContractError(`${label} must not contain ${key}.`, 'invalid_input') } assertBoundedJsonSchema(descriptor.value, label, depth + 1, state) } diff --git a/test/main/tape/taskContract.test.ts b/test/main/tape/taskContract.test.ts index c07527d64..7860e7a0c 100644 --- a/test/main/tape/taskContract.test.ts +++ b/test/main/tape/taskContract.test.ts @@ -148,7 +148,7 @@ describe('TaskContract domain', () => { ).toThrow(/must belong to the parent Session/u) }) - it('rejects duplicate sections, remote references, and bounded-input overflow', () => { + it('rejects duplicate sections, asynchronous schemas, and bounded-input overflow', () => { expect(() => buildTaskContract( buildInput({ @@ -177,6 +177,20 @@ describe('TaskContract domain', () => { }) ) ).toThrow(/must not contain \$ref/u) + expect(() => + buildTaskContract( + buildInput({ + acceptance: [ + { + id: 'async-schema', + kind: 'result_schema', + section: 'Result', + schema: { $async: true, type: 'object' } + } + ] + }) + ) + ).toThrow(/must not contain \$async/u) expect(() => buildTaskContract( buildInput({ From 56cc37aa7e5a69ec37102fce1ef74d1fe1d5cce7 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 03:55:40 +0800 Subject: [PATCH 15/37] fix(tape): isolate contract facts from forks --- src/main/tape/application/forkService.ts | 2 ++ test/main/session/data/tapeFork.test.ts | 20 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) 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/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() From a2fc2b168fea0489ad4ca43de38bff9c437ec71c Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 04:07:41 +0800 Subject: [PATCH 16/37] fix(agent): bound delegation summaries --- src/main/orchestration/liveDelegationService.ts | 12 +++++++++++- .../main/orchestration/liveDelegationService.test.ts | 7 ++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main/orchestration/liveDelegationService.ts b/src/main/orchestration/liveDelegationService.ts index 5204f0f05..15b2d0262 100644 --- a/src/main/orchestration/liveDelegationService.ts +++ b/src/main/orchestration/liveDelegationService.ts @@ -1912,7 +1912,17 @@ function projectDelegationSummary(delegation: LiveDelegation): LiveDelegationSum } function projectTurnSummary(turn: LiveDelegationTurn): LiveDelegationTurnSummary { - const { prompt, resultSummary, error, evaluation, evaluationRef, ...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), diff --git a/test/main/orchestration/liveDelegationService.test.ts b/test/main/orchestration/liveDelegationService.test.ts index d8b0ca53c..c62818e06 100644 --- a/test/main/orchestration/liveDelegationService.test.ts +++ b/test/main/orchestration/liveDelegationService.test.ts @@ -3,7 +3,8 @@ 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' @@ -190,6 +191,10 @@ describeIfSqlite('LiveDelegationService', () => { 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 From e463355ea53cf5f063063acbede073c9977f6125 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 04:15:53 +0800 Subject: [PATCH 17/37] docs(tape): document contract lineage --- .../spec.md | 95 +++++++++++++++- .../tape-contract-lineage/plan.md | 14 ++- .../tape-contract-lineage/spec.md | 41 ++++--- .../tape-contract-lineage/tasks.md | 37 ++++++- docs/architecture/tape-system.md | 104 +++++++++++++++--- 5 files changed, 246 insertions(+), 45 deletions(-) diff --git a/docs/architecture/proactive-multi-agent-orchestration/spec.md b/docs/architecture/proactive-multi-agent-orchestration/spec.md index 26532d9b4..d893620f2 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`. Verdict, disposition, 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,49 @@ 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 acceptance/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 child Views fail closed before provider dispatch when +the manifest or TaskContract binding cannot be persisted. Ordinary interactive chat preserves its +existing fail-open manifest behavior. + +Terminal settlement evaluates the persisted complete child answer against required level-two +Markdown sections and optional bounded local JSON Schema requirements. It keeps three independent +axes: + +```text +executionStatus = completed | failed | cancelled | interrupted +verdict = passed | failed | indeterminate +disposition = accepted | parked +``` + +Only `passed` is accepted. A generated answer that fails acceptance remains `completed`, is parked, +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 a verdict. + ## Consent And Permissions Host enforcement, not prompt wording, owns delegation consent. @@ -115,6 +164,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 +189,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 +229,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 automatically reinterpret parked output as accepted. 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 +262,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 terminal verdict 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 +294,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. New schema-v5 manifests bind one ExecutionContract to + the exact request, and contract-bearing child dispatch fails closed on a missing or conflicting + binding. +- 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 +316,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 +332,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 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, verdict, and disposition remain + independent. +16. `wait`, `inspect`, and `read_result` expose bounded structured evaluation metadata outside + untrusted child text. ## Non-Goals @@ -270,6 +353,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 index 580a0db7e..746f6809c 100644 --- a/docs/architecture/tape-contract-lineage/plan.md +++ b/docs/architecture/tape-contract-lineage/plan.md @@ -6,7 +6,7 @@ ExecutionContract, evaluation, verdict, and disposition. - Add main-process canonical builders and versioned hashes using the existing canonical JSON helper. - Add Ajv as a direct runtime dependency for bounded local result-schema validation; disable remote - loading, `$ref`, custom executable formats, and unbounded error collection. + loading, `$ref`, `$async`, custom executable formats, and unbounded error collection. - 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 @@ -29,7 +29,8 @@ - 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 ViewManifest writes to schema 5 and the next hash version while preserving v1-v4 readers. +- Upgrade normal DeepChat ViewManifest writes to schema 5 and the next hash version while preserving + v1-v4 readers plus ACP and explicit ordinary-interactive schema-v4 fallback writes. - Include full ExecutionContract content in `view/assembled`; reference the TaskContract by durable local/origin identity where present. - Keep interactive writes fail-open with explicit degradation and make contract-bearing child View @@ -41,10 +42,11 @@ 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, workspace scope, and nesting ceiling before - crossing ToolService dispatch. -- Retain existing live permission, workdir, deletion, and Subagent-authority checks as the current - runtime side of the meet. +- 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. diff --git a/docs/architecture/tape-contract-lineage/spec.md b/docs/architecture/tape-contract-lineage/spec.md index 6246fbc77..7cec145cf 100644 --- a/docs/architecture/tape-contract-lineage/spec.md +++ b/docs/architecture/tape-contract-lineage/spec.md @@ -2,9 +2,10 @@ ## Status -In implementation. P0 is complete and P1 is in progress. This architecture extends DeepChat's -existing Tape, provider View, and live-delegation execution planes with explicit task and execution -contracts. It does not add a second scheduler or make Tape an online permission service. +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. @@ -84,8 +85,9 @@ V1 supports two acceptance requirement kinds: - `result_schema`: a bounded JSON Schema applied to the body of a named Markdown section. `result_schema` accepts one JSON value after removing at most one enclosing Markdown code fence. -It uses Ajv strict validation with remote loading disabled, rejects every `$ref`, and stops after a -bounded error set. It does not execute custom formats or schema-provided code. +It uses synchronous Ajv strict validation with remote loading disabled, rejects every `$ref` and +nested `$async`, and stops after a bounded error set. It does not execute custom formats or +schema-provided code. Ajv and regex-safety dependencies are pinned. Any semantic change to those validators, Markdown section extraction, evidence normalization, or verdict reduction must bump `evaluatorVersion`. @@ -134,8 +136,8 @@ recoverable and non-terminal. Every schema-v5 ViewManifest embeds one ExecutionContract with three structural groups: -- `ceilings`: provider-visible tool identities, reviewed effect ceiling, workspace scope, and - Subagent nesting ceiling; +- `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 @@ -170,9 +172,15 @@ Meet semantics are field-specific: - sets use intersection; - numeric maxima use `min`; - side-effect classes use the declared partial order; -- workspace changes must remain within both the frozen and current scopes; +- 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. @@ -219,7 +227,9 @@ The Tape fact is historical evidence, not a model-facing delivery mechanism. Exi 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. It is not an automatic replay of the previous attempt. +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 @@ -237,7 +247,9 @@ 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. -- New writes use ViewManifest schema 5 and a new manifest hash version. +- Normal DeepChat contract-bearing writes use ViewManifest schema 5 and manifest hash version 3. + ACP compatibility and an explicitly degraded ordinary interactive request may still write schema + 4; contract-bearing 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. - A legacy active turn without a TaskContract must freeze a compatibility contract before it may @@ -258,8 +270,8 @@ This table describes write disciplines, not a count of all Tape event families. - 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, workspace, Session lineage, and tool authority immediately - before dispatch. +- Runtime revalidates current permission, workdir identity, Session lineage, and tool authority + immediately before dispatch. - Child output remains untrusted even when its contract passes. - JSON Schema evaluation is bounded by accepted schema size, candidate size, and evaluator work; remote references and executable formats are forbidden. @@ -267,8 +279,9 @@ This table describes write disciplines, not a count of all Tape event families. ## Acceptance Criteria -1. Every new DeepChat-owned provider View has a schema-v5 manifest containing a verifiable - ExecutionContract built from the exact request inputs. +1. Every successfully assembled normal DeepChat View has a schema-v5 manifest containing a + verifiable ExecutionContract built from the exact request inputs; ACP compatibility and bounded + ordinary-interactive degradation retain their documented schema-v4 behavior. 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. diff --git a/docs/architecture/tape-contract-lineage/tasks.md b/docs/architecture/tape-contract-lineage/tasks.md index 898f84da9..c47f1ca2c 100644 --- a/docs/architecture/tape-contract-lineage/tasks.md +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -21,7 +21,8 @@ - [x] Embed ExecutionContract in ViewManifest schema 5 and preserve v1-v4 reads. - [x] Keep interactive manifest persistence fail-open and require contract-bearing child manifests. - [x] Carry the exact View contract to tool dispatch without Session-global mutable state. -- [x] Enforce stable tool target, effect, workspace, and nesting ceilings with current authority. +- [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. @@ -56,8 +57,32 @@ ## Documentation And Final Validation -- [ ] Update retained Tape and proactive multi-Agent architecture references. -- [ ] Run format, i18n, lint, Node/web typecheck, focused tests, and relevant main suites. -- [ ] Review the complete `dev...HEAD` diff and fix findings by severity. -- [ ] Confirm every task and acceptance criterion is represented in code or documented as deferred. -- [ ] Confirm the branch has not been pushed. +- [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] Confirm the branch has not been pushed. + +## Validation Record + +Completed on 2026-08-09: + +| Gate | Result | +| --- | --- | +| `pnpm run format` and `pnpm run format:check` | Passed | +| `pnpm run i18n` | Passed with no missing or invalid translations | +| `pnpm run lint` | Passed | +| `pnpm run typecheck:node` and `pnpm run typecheck:web` | Passed | +| Focused prompt, View, Tape, dispatch, orchestration, and integration suites | 22 files and 660 tests passed | +| `pnpm run test:main` | Did not pass because of three independently reproduced baseline assertions described below | + +Independent single-file reruns confirmed the same unrelated failures already documented at the +`dev` merge base in the Agent Memory architecture validation record: + +- `test/main/scheduler/schedulerService.test.ts`: one provider-config snapshot expectation; +- `test/main/app/startupMigrations/sessionDataMigrations.sqlite.test.ts`: two fixture failures because + `new_session_active_skills` is absent. + +This branch does not modify either failing test or its scheduler, startup-migration, or +`NewSessionsTable` owner paths. The final severity-ordered review found no unresolved actionable +findings. The branch has no upstream and was not pushed. diff --git a/docs/architecture/tape-system.md b/docs/architecture/tape-system.md index 706ea6164..14de86a00 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,34 @@ 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 和该请求的 `ExecutionContract`。正常 chat、 +resume、tool loop 和 context pressure recovery 都必须记录自己的 view;不得依赖无法复现的隐式 context +builder 状态。summary、reconstruction 和 Memory 生成的 synthetic user contribution 只记录 source +entry ID 与 content hash,不在 manifest 中复制原文。 + +正常 DeepChat 新写入默认使用 `cache_aware_context_v1` / `cache-aware-v1`、schema version 5 和 +manifest hash version 3。schema version 1-4 与其历史 hash 语义继续兼容读取且不得原地重写;ACP +compatibility 与 ExecutionContract 构造失败后显式降级的普通 interactive request 仍可写 schema version +4,contract-bearing child 不得走该 fallback。`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 +216,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 sections 与 +指定 section 的 bounded synchronous local JSON Schema;所有层级的 `$ref` 与 `$async` 均被拒绝,不支持 +自动 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`。执行状态、合同裁决和消费 +决策是三个正交维度: + +```text +executionStatus = completed | failed | cancelled | interrupted +verdict = passed | failed | indeterminate +disposition = accepted | parked +``` + +只有 `passed` 可 `accepted`;`failed` 和 `indeterminate` 均 `parked`。`parked` 是 evaluation +disposition,不是 delegation/turn status。一个生成成功但验收失败的 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; From 80e2ba61722c83519c64dc8e1e23846bdc177151 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 10:57:11 +0800 Subject: [PATCH 18/37] fix(main): preserve cleanup migration recency --- .../sessionDataMigrations.ts | 10 +-- .../sessionDataMigrations.sqlite.test.ts | 19 ++++- .../sessionDataMigrations.test.ts | 13 ++-- test/main/data/mainDatabase.test.ts | 78 ++++++++++++------- test/main/scheduler/schedulerService.test.ts | 6 +- 5 files changed, 77 insertions(+), 49 deletions(-) diff --git a/src/main/app/startupMigrations/sessionDataMigrations.ts b/src/main/app/startupMigrations/sessionDataMigrations.ts index d79f36d03..436c4ced0 100644 --- a/src/main/app/startupMigrations/sessionDataMigrations.ts +++ b/src/main/app/startupMigrations/sessionDataMigrations.ts @@ -441,16 +441,12 @@ export async function runDisabledAgentToolCapabilityCleanupMigration( ORDER BY id ASC LIMIT ?` ) - const updateSessionDisabledTools = db.prepare<[string, number, string]>( - 'UPDATE new_sessions SET disabled_agent_tools = ?, updated_at = ?, revision = revision + 1 WHERE id = ?' + const updateSessionDisabledTools = db.prepare<[string, string]>( + 'UPDATE new_sessions SET disabled_agent_tools = ?, revision = revision + 1 WHERE id = ?' ) const persistSessionDisabledTools = db.transaction( (sessionId: string, disabledAgentTools: string[]): boolean => { - const result = updateSessionDisabledTools.run( - JSON.stringify(disabledAgentTools), - Date.now(), - sessionId - ) + const result = updateSessionDisabledTools.run(JSON.stringify(disabledAgentTools), sessionId) if (result.changes === 0) return false sqlitePresenter.newSessionDisabledAgentToolsTable.replaceForSession( sessionId, diff --git a/test/main/app/startupMigrations/sessionDataMigrations.sqlite.test.ts b/test/main/app/startupMigrations/sessionDataMigrations.sqlite.test.ts index a19c60ca0..00cfd393f 100644 --- a/test/main/app/startupMigrations/sessionDataMigrations.sqlite.test.ts +++ b/test/main/app/startupMigrations/sessionDataMigrations.sqlite.test.ts @@ -9,6 +9,9 @@ const sqliteModule = await import('better-sqlite3-multiple-ciphers').catch(() => const sessionsModule = sqliteModule ? await import('@/session/data/tables/newSessions').catch(() => null) : null +const activeSkillsModule = sqliteModule + ? await import('@/session/data/tables/newSessionActiveSkills').catch(() => null) + : null const disabledToolsModule = sqliteModule ? await import('@/session/data/tables/newSessionDisabledAgentTools').catch(() => null) : null @@ -18,10 +21,12 @@ const environmentsModule = sqliteModule const Database = sqliteModule?.default const NewSessionsTable = sessionsModule?.NewSessionsTable +const NewSessionActiveSkillsTable = activeSkillsModule?.NewSessionActiveSkillsTable const NewSessionDisabledAgentToolsTable = disabledToolsModule?.NewSessionDisabledAgentToolsTable const NewEnvironmentsTable = environmentsModule?.NewEnvironmentsTable const DatabaseCtor = Database! const NewSessionsTableCtor = NewSessionsTable! +const NewSessionActiveSkillsTableCtor = NewSessionActiveSkillsTable! const NewSessionDisabledAgentToolsTableCtor = NewSessionDisabledAgentToolsTable! const NewEnvironmentsTableCtor = NewEnvironmentsTable! @@ -37,7 +42,11 @@ if (Database) { } const describeIfSqlite = - sqliteAvailable && NewSessionsTable && NewSessionDisabledAgentToolsTable && NewEnvironmentsTable + sqliteAvailable && + NewSessionsTable && + NewSessionActiveSkillsTable && + NewSessionDisabledAgentToolsTable && + NewEnvironmentsTable ? describe : describe.skip @@ -54,9 +63,11 @@ describeIfSqlite('disabled Agent tool capability cleanup SQLite integration', () `) const sessions = new NewSessionsTableCtor(db) + const activeSkills = new NewSessionActiveSkillsTableCtor(db) const disabledTools = new NewSessionDisabledAgentToolsTableCtor(db) const environments = new NewEnvironmentsTableCtor(db) sessions.createTable() + activeSkills.createTable() disabledTools.createTable() environments.createTable() @@ -72,6 +83,7 @@ describeIfSqlite('disabled Agent tool capability cleanup SQLite integration', () }) environments.rebuildFromSessions() const environmentBefore = environments.list() + const olderRevisionBefore = sessions.get('older')!.revision const settings = new Map() const sqlitePresenter = { @@ -100,7 +112,8 @@ describeIfSqlite('disabled Agent tool capability cleanup SQLite integration', () expect(sessions.list().map((row) => row.id)).toEqual(['newer', 'older']) expect(sessions.get('older')).toMatchObject({ disabled_agent_tools: JSON.stringify(['read']), - updated_at: 100 + updated_at: 100, + revision: olderRevisionBefore + 1 }) expect(disabledTools.listBySession('older')).toEqual([ { session_id: 'older', ordinal: 0, tool_name: 'read' } @@ -118,8 +131,10 @@ describeIfSqlite('disabled Agent tool capability cleanup SQLite integration', () const db = new DatabaseCtor(':memory:') try { const sessions = new NewSessionsTableCtor(db) + const activeSkills = new NewSessionActiveSkillsTableCtor(db) const disabledTools = new NewSessionDisabledAgentToolsTableCtor(db) sessions.createTable() + activeSkills.createTable() disabledTools.createTable() const originalDisabledTools = [TAPE_TOOL_NAMES.search, 'read'] diff --git a/test/main/app/startupMigrations/sessionDataMigrations.test.ts b/test/main/app/startupMigrations/sessionDataMigrations.test.ts index 40f4a7564..7ef95585a 100644 --- a/test/main/app/startupMigrations/sessionDataMigrations.test.ts +++ b/test/main/app/startupMigrations/sessionDataMigrations.test.ts @@ -16,12 +16,10 @@ function createFixture() { const sessionRows: Array<{ id: string }> = [] const sessionDisabledTools = new Map() const statements: string[] = [] - const updateSessionDisabledTools = vi.fn( - (serialized: string, _updatedAt: number, sessionId: string) => ({ - changes: sessionRows.some((row) => row.id === sessionId) ? 1 : 0, - serialized - }) - ) + const updateSessionDisabledTools = vi.fn((serialized: string, sessionId: string) => ({ + changes: sessionRows.some((row) => row.id === sessionId) ? 1 : 0, + serialized + })) const replaceSessionDisabledTools = vi.fn((sessionId: string, disabledAgentTools: string[]) => { sessionDisabledTools.set(sessionId, disabledAgentTools) }) @@ -234,7 +232,6 @@ describe('session data migrations', () => { ]) expect(fixture.updateSessionDisabledTools).toHaveBeenCalledWith( JSON.stringify(['cdp_send', 'custom_tool', 'exec']), - expect.any(Number), 'session-1' ) expect(fixture.providerSettings.updateDeepChatAgent).toHaveBeenCalledWith('deepchat', { @@ -260,7 +257,7 @@ describe('session data migrations', () => { sql.startsWith('UPDATE new_sessions SET disabled_agent_tools') ) expect(sessionUpdate).toBe( - 'UPDATE new_sessions SET disabled_agent_tools = ?, updated_at = ?, revision = revision + 1 WHERE id = ?' + 'UPDATE new_sessions SET disabled_agent_tools = ?, revision = revision + 1 WHERE id = ?' ) }) diff --git a/test/main/data/mainDatabase.test.ts b/test/main/data/mainDatabase.test.ts index 7bad88b46..3cc263e2d 100644 --- a/test/main/data/mainDatabase.test.ts +++ b/test/main/data/mainDatabase.test.ts @@ -15,9 +15,17 @@ const sqlitePresenterModule = sqliteModule const schemaCatalogModule = sqliteModule ? await import('../../../src/main/data/schemaCatalog').catch(() => null) : null +const sessionDatabaseModule = sqliteModule + ? await import('../../../src/main/session/data/database').catch(() => null) + : null +const agentDatabaseModule = sqliteModule + ? await import('../../../src/main/agent/data/database').catch(() => null) + : null const Database = sqliteModule?.default const MainDatabase = sqlitePresenterModule?.MainDatabase const getStartupSchemaCatalog = schemaCatalogModule?.getStartupSchemaCatalog +const SessionDatabase = sessionDatabaseModule?.SessionDatabase +const AgentDatabase = agentDatabaseModule?.AgentDatabase const sqliteSkipReason = 'skipped: better-sqlite3-multiple-ciphers is unavailable' const requireNativeSqlite = process.env.DEEPCHAT_REQUIRE_NATIVE_SQLITE === '1' let sqliteAvailable = false @@ -32,9 +40,12 @@ if (Database) { } const DatabaseCtor = Database! const MainDatabaseCtor = MainDatabase! -const sqliteHarnessAvailable = sqliteAvailable && MainDatabase && getStartupSchemaCatalog +const SessionDatabaseCtor = SessionDatabase! +const AgentDatabaseCtor = AgentDatabase! +const sqliteHarnessAvailable = + sqliteAvailable && MainDatabase && getStartupSchemaCatalog && SessionDatabase && AgentDatabase const sqliteHarnessSkipReason = sqliteAvailable - ? 'skipped: MainDatabase startup schema catalog is unavailable' + ? 'skipped: MainDatabase test dependencies are unavailable' : sqliteSkipReason const describeIfSqlite = sqliteHarnessAvailable ? describe @@ -74,6 +85,7 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { updated_at INTEGER NOT NULL ); `) + new SessionDatabaseCtor({ getDatabase: () => db }).deepchatSessionsTable.createTable() db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run( schemaVersion, Date.now() @@ -109,7 +121,7 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { const repairReport = await presenter.repairSchema() expect(repairReport.status).toBe('repaired') - const conversationList = await presenter.getConversationList(1, 20) + const conversationList = await new SessionDatabaseCtor(presenter).getConversationList(1, 20) expect(conversationList.total).toBe(0) expect(conversationList.list).toEqual([]) presenter.close() @@ -223,8 +235,9 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { bootstrapDb.close() const presenter = new MainDatabaseCtor(dbPath) + const sessions = new SessionDatabaseCtor(presenter) expect(presenter.getLatestSchemaVersion()).toBeGreaterThanOrEqual(44) - expect(presenter.newSessionsTable.get('session-1')).toMatchObject({ + expect(sessions.newSessionsTable.get('session-1')).toMatchObject({ title: 'Existing session', project_dir: '/work/app', is_pinned: 1, @@ -233,8 +246,8 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { revision: 0 }) - presenter.newSessionsTable.update('session-1', { title: 'Generated title' }) - expect(presenter.newSessionsTable.get('session-1')).toMatchObject({ + sessions.newSessionsTable.update('session-1', { title: 'Generated title' }) + expect(sessions.newSessionsTable.get('session-1')).toMatchObject({ title: 'Generated title', revision: 1 }) @@ -348,30 +361,32 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { const dbPath = path.join(tempDir, 'agent.db') const presenter = new MainDatabaseCtor(dbPath) + const sessions = new SessionDatabaseCtor(presenter) + const agents = new AgentDatabaseCtor(presenter) vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) - presenter.newSessionsTable.create('session-1', 'kimi-cli', 'Recovered session', null) - presenter.deepchatSessionsTable.create('session-1', 'acp', 'kimi-cli', 'full_access') - await presenter.upsertAcpSession('conversation-1', 'kimi-cli', { + sessions.newSessionsTable.create('session-1', 'kimi-cli', 'Recovered session', null) + sessions.deepchatSessionsTable.create('session-1', 'acp', 'kimi-cli', 'full_access') + await agents.upsertAcpSession('conversation-1', 'kimi-cli', { sessionId: 'acp-session-1', status: 'active' }) vi.setSystemTime(new Date('2026-01-01T00:00:01.000Z')) await expect( - presenter.migrateAcpAgentReferences({ + agents.migrateAcpAgentReferences({ 'kimi-cli': 'kimi' }) ).resolves.toBeUndefined() - expect(presenter.newSessionsTable.get('session-1')).toMatchObject({ + expect(sessions.newSessionsTable.get('session-1')).toMatchObject({ agent_id: 'kimi', revision: 1, updated_at: Date.parse('2026-01-01T00:00:01.000Z') }) - expect(presenter.deepchatSessionsTable.get('session-1')?.model_id).toBe('kimi') - expect(await presenter.getAcpSession('conversation-1', 'kimi-cli')).toBeNull() - expect(await presenter.getAcpSession('conversation-1', 'kimi')).toMatchObject({ + expect(sessions.deepchatSessionsTable.get('session-1')?.model_id).toBe('kimi') + expect(await agents.getAcpSession('conversation-1', 'kimi-cli')).toBeNull() + expect(await agents.getAcpSession('conversation-1', 'kimi')).toMatchObject({ conversationId: 'conversation-1', agentId: 'kimi', sessionId: 'acp-session-1' @@ -399,7 +414,12 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { bootstrapDb.close() const presenter = new MainDatabaseCtor(dbPath) - presenter.newSessionsTable.create('session-1', 'agent-1', 'Recovered session', null) + new SessionDatabaseCtor(presenter).newSessionsTable.create( + 'session-1', + 'agent-1', + 'Recovered session', + null + ) presenter.close() const checkDb = new DatabaseCtor(dbPath) @@ -693,7 +713,12 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { bootstrapDb.close() const presenter = new MainDatabaseCtor(dbPath) - presenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o', 'full_access') + new SessionDatabaseCtor(presenter).deepchatSessionsTable.create( + 'session-1', + 'openai', + 'gpt-4o', + 'full_access' + ) presenter.close() const checkDb = new DatabaseCtor(dbPath) @@ -787,7 +812,7 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { bootstrapDb.close() const presenter = new MainDatabaseCtor(dbPath) - presenter.deepchatSessionsTable.updateGenerationSettings('session-1', { + new SessionDatabaseCtor(presenter).deepchatSessionsTable.updateGenerationSettings('session-1', { forceInterleavedThinkingCompat: true }) presenter.close() @@ -1054,25 +1079,20 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { const dbPath = path.join(tempDir, 'agent.db') const presenter = new MainDatabaseCtor(dbPath) + const sessions = new SessionDatabaseCtor(presenter) - presenter.newSessionsTable.create( - 'parent-session', - 'deepchat', - 'Parent session', - '/workspace', - { - sessionKind: 'regular' - } - ) - presenter.newSessionsTable.create('child-session', 'deepchat', 'Child session', '/workspace', { + sessions.newSessionsTable.create('parent-session', 'deepchat', 'Parent session', '/workspace', { + sessionKind: 'regular' + }) + sessions.newSessionsTable.create('child-session', 'deepchat', 'Child session', '/workspace', { sessionKind: 'subagent', parentSessionId: 'parent-session' }) - const childRows = presenter.newSessionsTable.list({ + const childRows = sessions.newSessionsTable.list({ parentSessionId: 'parent-session' }) - const defaultRows = presenter.newSessionsTable.list() + const defaultRows = sessions.newSessionsTable.list() expect(childRows.map((row) => row.id)).toEqual(['child-session']) expect(defaultRows.map((row) => row.id)).toEqual(['parent-session']) diff --git a/test/main/scheduler/schedulerService.test.ts b/test/main/scheduler/schedulerService.test.ts index 438763671..794671713 100644 --- a/test/main/scheduler/schedulerService.test.ts +++ b/test/main/scheduler/schedulerService.test.ts @@ -876,7 +876,7 @@ describeIfSqlite('Cron Jobs persistence and service', () => { enabled: true } ] - const providerSettings = { + const agentSettings = { listAgents: vi.fn(async () => agents), resolveDeepChatAgentConfig: vi.fn(async () => ({ systemPrompt: 'system' })) } @@ -884,7 +884,7 @@ describeIfSqlite('Cron Jobs persistence and service', () => { ...createRequiredSchedulerDeps(), database: sqlitePresenter as never, schedulerManager: schedulerManager as never, - providerSettings: providerSettings as never + agentSettings: agentSettings as never }) const follow = await service.upsert({ @@ -897,7 +897,7 @@ describeIfSqlite('Cron Jobs persistence and service', () => { }) expect(follow.job.agentSnapshot).toBeNull() - expect(providerSettings.resolveDeepChatAgentConfig).toHaveBeenCalledWith('agent-1') + expect(agentSettings.resolveDeepChatAgentConfig).toHaveBeenCalledWith('agent-1') const { job } = await service.upsert({ name: 'Snapshot job', From 079e2cfffaf4434db50fe23f05ed4713d125c139 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 11:01:02 +0800 Subject: [PATCH 19/37] fix(db): repair delegation contract columns --- src/main/data/schemaCatalog.ts | 22 +++- .../liveDelegationMigration.test.ts | 117 ++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) 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/test/main/orchestration/liveDelegationMigration.test.ts b/test/main/orchestration/liveDelegationMigration.test.ts index 2e0736ad7..5954203cf 100644 --- a/test/main/orchestration/liveDelegationMigration.test.ts +++ b/test/main/orchestration/liveDelegationMigration.test.ts @@ -207,6 +207,123 @@ describeIfSqlite('live delegation schema migration', () => { 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() + }) + it('retires Workflow tables and triggers from a v63 feature database', () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'deepchat-workflow-retirement-')) tempDirectories.push(directory) From ad31b5daba553d2081be084ea8e3a773ca6a219b Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 11:03:36 +0800 Subject: [PATCH 20/37] fix(tape): enforce synchronous schema results --- src/main/tape/domain/taskEvaluation.ts | 9 +++++- test/main/tape/taskEvaluation.test.ts | 39 +++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/main/tape/domain/taskEvaluation.ts b/src/main/tape/domain/taskEvaluation.ts index a00f887ab..e0ed0b5ec 100644 --- a/src/main/tape/domain/taskEvaluation.ts +++ b/src/main/tape/domain/taskEvaluation.ts @@ -325,7 +325,14 @@ function evaluateRequirements( try { assertSafeSchemaRegexes(requirement.schema) const validate = ajv.compile(requirement.schema as AnySchema) - schemaEvaluation = validate(parsedSection.value) + if ('$async' in validate && validate.$async) { + throw new Error('Asynchronous result schema validators are not supported.') + } + const validationResult = validate(parsedSection.value) + if (typeof validationResult !== 'boolean') { + throw new Error('Result schema validator returned a non-boolean value.') + } + schemaEvaluation = validationResult ? { outcome: 'passed', code: 'result_schema_valid', diff --git a/test/main/tape/taskEvaluation.test.ts b/test/main/tape/taskEvaluation.test.ts index ffebd631a..9f674e757 100644 --- a/test/main/tape/taskEvaluation.test.ts +++ b/test/main/tape/taskEvaluation.test.ts @@ -1,5 +1,6 @@ import path from 'node:path' -import { describe, expect, it } from 'vitest' +import Ajv from 'ajv' +import { afterEach, describe, expect, it, vi } from 'vitest' import { MAX_TASK_EVALUATION_CANDIDATE_BYTES, type DeepChatTaskAcceptanceRequirement, @@ -68,6 +69,10 @@ function evaluate( } describe('Task evaluation domain', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + it('evaluates required sections and one fenced JSON result as a canonical pass', () => { const candidate = [ '```markdown', @@ -170,6 +175,38 @@ describe('Task evaluation domain', () => { }) }) + it.each([ + { + name: 'an asynchronous validator', + validate: Object.assign( + vi.fn(async () => { + throw new Error('must not run') + }), + { $async: true as const } + ), + expectedCalls: 0 + }, + { + name: 'a validator returning a non-boolean value', + validate: vi.fn(() => Promise.resolve(true)), + expectedCalls: 1 + } + ])('records evaluator_error for $name', ({ validate, expectedCalls }) => { + vi.spyOn(Ajv.prototype, 'compile').mockReturnValue(validate as never) + + const result = evaluate('## Result\n{}', 'completed', [ + { id: 'result', kind: 'result_schema', section: 'Result', schema: {} } + ]) + + expect(result).toMatchObject({ + verdict: 'indeterminate', + disposition: 'parked', + reasonCodes: ['evaluator_error'], + records: [{ code: 'evaluator_error', outcome: 'indeterminate' }] + }) + expect(validate).toHaveBeenCalledTimes(expectedCalls) + }) + it('keeps a valid contract verdict independent from execution failure', () => { const result = evaluate( '## Handoff\nDone.\n## Result\n{"decision":"accept"}\n## Validation\nChecked.', From 31f548b8af03dba4aeb9e7afb68c63bad6583e2c Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 11:21:48 +0800 Subject: [PATCH 21/37] fix(orchestration): quarantine invalid children --- .../orchestration/liveDelegationService.ts | 66 ++++++++++- .../liveDelegationService.test.ts | 107 ++++++++++++++++++ 2 files changed, 168 insertions(+), 5 deletions(-) diff --git a/src/main/orchestration/liveDelegationService.ts b/src/main/orchestration/liveDelegationService.ts index 15b2d0262..37d6becda 100644 --- a/src/main/orchestration/liveDelegationService.ts +++ b/src/main/orchestration/liveDelegationService.ts @@ -175,6 +175,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 @@ -186,6 +188,8 @@ export class LiveDelegationService { start(): void { if (this.started) return const activeRecords = this.options.repository.listActiveTurns() + this.childToTurn.clear() + this.quarantinedTurns.clear() for (const record of activeRecords) { if (record.delegation.childSessionId) { this.childToTurn.set(record.delegation.childSessionId, record.turn.id) @@ -205,8 +209,13 @@ export class LiveDelegationService { } prepareTaskContractContext(childSessionId: string): DeepChatTaskContractContext | null { - const context = this.options.repository.prepareActiveTaskContractContext(childSessionId) 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) { @@ -237,7 +246,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, { @@ -247,7 +260,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() @@ -771,6 +786,7 @@ export class LiveDelegationService { }) this.childToTurn.delete(childSessionId) } + this.quarantinedTurns.delete(turn.id) } return this.inspect(delegation.parentSessionId, delegation.id) } @@ -1150,12 +1166,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() @@ -1437,8 +1463,38 @@ export class LiveDelegationService { }) if (!this.started) return if (error instanceof LiveDelegationTaskContractError) { - if (record.delegation.childSessionId) { - this.childToTurn.delete(record.delegation.childSessionId) + let turnId = record.turn.id + let childSessionId = record.delegation.childSessionId + try { + const current = this.options.repository.getTurn(record.turn.id) + if (!current || !isActiveTurnStatus(current.status)) { + if (childSessionId) this.childToTurn.delete(childSessionId) + return + } + turnId = current.id + childSessionId = + this.options.repository.get(record.delegation.id)?.childSessionId ?? childSessionId + } catch (lookupError) { + console.error('[LiveDelegationService] Failed to resolve reconciliation quarantine:', { + delegationId: record.delegation.id, + 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 } diff --git a/test/main/orchestration/liveDelegationService.test.ts b/test/main/orchestration/liveDelegationService.test.ts index c62818e06..3663420b5 100644 --- a/test/main/orchestration/liveDelegationService.test.ts +++ b/test/main/orchestration/liveDelegationService.test.ts @@ -2066,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({ From 3aa2838e4116887edeeca6f57ac6b234a6a48feb Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 11:45:26 +0800 Subject: [PATCH 22/37] fix(agent): scope child contracts to runs --- .../harness/createDeepChatAgentHarness.ts | 1 - .../agent/deepchat/loop/contextCoordinator.ts | 1 + .../deepchat/runtime/deepChatLoopRunner.ts | 16 ++----- .../agent/deepchat/runtime/turnCoordinator.ts | 5 +++ .../harness/deepChatAgentHarness.test.ts | 43 +++++++++++++++---- .../deepchat/loop/contextCoordinator.test.ts | 37 ++++++++++++++++ 6 files changed, 82 insertions(+), 21 deletions(-) diff --git a/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts b/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts index d68f4fd72..89ed8bb97 100644 --- a/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts +++ b/src/main/agent/deepchat/harness/createDeepChatAgentHarness.ts @@ -359,7 +359,6 @@ function createDeepChatRuntimeServices(deps: DeepChatHarnessDependencies): DeepC identity, sessionPermissionPort, reviewToolPermission: createToolPermissionReviewer(toolRuntimeBindings), - taskContractContext: deps.taskContractContext, hookSink, compaction }) diff --git a/src/main/agent/deepchat/loop/contextCoordinator.ts b/src/main/agent/deepchat/loop/contextCoordinator.ts index ffb97480e..0707b659a 100644 --- a/src/main/agent/deepchat/loop/contextCoordinator.ts +++ b/src/main/agent/deepchat/loop/contextCoordinator.ts @@ -604,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') && diff --git a/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts b/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts index 1ec864a50..0ad8eb404 100644 --- a/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts +++ b/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts @@ -9,6 +9,7 @@ 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, @@ -92,11 +93,7 @@ import type { import type { InputPreparationCoordinator } from '@/agent/deepchat/loop/inputPreparationCoordinator' import type { DeepChatContextCoordinator } from '@/agent/deepchat/loop/contextCoordinator' import { createLoopRun } from '@/agent/deepchat/loop/loopRun' -import type { - DeepChatTaskContractContextPort, - ToolExecutionPort, - ToolResultPort -} from '@/agent/deepchat/loop/ports' +import type { ToolExecutionPort, ToolResultPort } from '@/agent/deepchat/loop/ports' import { buildContextCheckpoint, createEmptyContextRuntimeContributions, @@ -194,6 +191,7 @@ export type DeepChatLoopRunInput = { projectDir: string | null resourceInstance?: DeepChatAgentInstance providerModelFacts?: ProviderModelRuntimeFacts + taskContractContext: DeepChatTaskContractContext | null tools?: MCPToolDefinition[] baseSystemPrompt?: string basePromptAssembly?: DeepChatPromptAssembly @@ -263,7 +261,6 @@ export interface DeepChatLoopRunnerPorts { identity: Pick sessionPermissionPort: SessionPermissionPort reviewToolPermission: ToolPermissionReviewer - taskContractContext: DeepChatTaskContractContextPort hookSink: Pick compaction: Pick } @@ -368,6 +365,7 @@ export class DeepChatLoopRunner { projectDir, resourceInstance: providedResourceInstance, providerModelFacts: providedProviderModelFacts, + taskContractContext, tools: providedTools, baseSystemPrompt, basePromptAssembly, @@ -503,9 +501,6 @@ export class DeepChatLoopRunner { const toolCatalog = { resolve: async (request?: { activeSkillNames?: string[] }) => { const resolved = await unconstrainedToolCatalog.resolve(request) - const taskContractContext = strictViewContract - ? this.ports.taskContractContext.prepare(sessionId) - : null return meetTaskContractToolDefinitions(sessionId, resolved, taskContractContext) } } @@ -749,9 +744,6 @@ export class DeepChatLoopRunner { effectiveSystemPrompt ) const cancellationRequested = abortSignal.aborted - const taskContractContext = strictViewContract - ? ports.taskContractContext.prepare(sessionId) - : null return buildExecutionContract({ request: { sessionId, diff --git a/src/main/agent/deepchat/runtime/turnCoordinator.ts b/src/main/agent/deepchat/runtime/turnCoordinator.ts index 62cc280dc..1c5ca81bf 100644 --- a/src/main/agent/deepchat/runtime/turnCoordinator.ts +++ b/src/main/agent/deepchat/runtime/turnCoordinator.ts @@ -283,6 +283,7 @@ export class TurnCoordinator { contextBudgetLength, maxTokens, activeSkillNames, + taskContractContext, tools, toolReserveTokens, basePromptAssembler, @@ -515,6 +516,7 @@ export class TurnCoordinator { contextBudgetLength, maxTokens, activeSkillNames: effectiveActiveSkillNames, + taskContractContext, tools, toolReserveTokens, basePromptAssembler, @@ -843,6 +845,7 @@ export class TurnCoordinator { contextContributions, resourceInstance: instance, providerModelFacts, + taskContractContext, providerReplayProjector, abortController: preStreamAbortController, maxProviderRounds: context?.maxProviderRounds, @@ -1239,6 +1242,7 @@ export class TurnCoordinator { contextBudgetLength, maxTokens, activeSkillNames: effectiveActiveSkillNames, + taskContractContext, tools, toolReserveTokens, basePromptAssembler, @@ -1476,6 +1480,7 @@ export class TurnCoordinator { projectDir, resourceInstance: instance, providerModelFacts, + taskContractContext, abortController: preStreamAbortController, tools, baseSystemPrompt, diff --git a/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts b/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts index 57ff75e22..1164e3fab 100644 --- a/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts +++ b/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts @@ -4080,7 +4080,7 @@ describe('DeepChatAgentHarness', () => { ) }) - it('resolves the child-local TaskContract independently for every provider View', async () => { + it('reuses one child-local TaskContract context across provider Views in a run', async () => { const taskContract = buildTaskContract({ delegationId: 'delegation-1', turnId: 'turn-1', @@ -4121,9 +4121,7 @@ describe('DeepChatAgentHarness', () => { const prepareTaskContract = vi.mocked(runtimeDependencies.taskContractContext.prepare) prepareTaskContract .mockReturnValueOnce(contextForTape('c'.repeat(64), 2)) - .mockReturnValueOnce(contextForTape('c'.repeat(64), 2)) - .mockReturnValueOnce(contextForTape('d'.repeat(64), 3)) - .mockReturnValueOnce(null) + .mockReturnValue(contextForTape('d'.repeat(64), 3)) const agentTool = ( name: string, execution: MCPToolDefinition['execution'] @@ -4151,6 +4149,12 @@ describe('DeepChatAgentHarness', () => { 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, @@ -4158,7 +4162,7 @@ describe('DeepChatAgentHarness', () => { callArgs.modelConfig, callArgs.temperature, callArgs.maxTokens, - callArgs.run.resources.toolDefinitions + index === 0 ? callArgs.run.resources.toolDefinitions : refreshedTools )) { } } @@ -4167,15 +4171,38 @@ describe('DeepChatAgentHarness', () => { .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(4) + expect(prepareTaskContract).toHaveBeenCalledTimes(1) expect(prepareTaskContract).toHaveBeenNthCalledWith(1, 's1') expect( manifests.map((manifest: any) => manifest.executionContract.provenance.taskContractRef) ).toEqual([ contextForTape('c'.repeat(64), 2).localRef, - contextForTape('d'.repeat(64), 3).localRef, - null + 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 () => { diff --git a/test/main/agent/deepchat/loop/contextCoordinator.test.ts b/test/main/agent/deepchat/loop/contextCoordinator.test.ts index 1fe7e3d10..f91281386 100644 --- a/test/main/agent/deepchat/loop/contextCoordinator.test.ts +++ b/test/main/agent/deepchat/loop/contextCoordinator.test.ts @@ -647,6 +647,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* () { From 6979f01506533af8425cef170ff4664a474fa475 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 12:03:13 +0800 Subject: [PATCH 23/37] fix(agent): drop undurable view contracts --- .../agent/deepchat/loop/contextCoordinator.ts | 28 ++++++++-- .../harness/deepChatAgentHarness.test.ts | 12 ++++- .../deepchat/loop/contextCoordinator.test.ts | 54 +++++++++++++++++-- .../agent/deepchat/runtime/process.test.ts | 5 ++ 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/main/agent/deepchat/loop/contextCoordinator.ts b/src/main/agent/deepchat/loop/contextCoordinator.ts index 0707b659a..88fc73e80 100644 --- a/src/main/agent/deepchat/loop/contextCoordinator.ts +++ b/src/main/agent/deepchat/loop/contextCoordinator.ts @@ -640,7 +640,12 @@ export class DeepChatContextCoordinator { if (input.strictViewContract) throw error } } - bindActiveRequestContract(input.run, requestSeq, executionContract) + + const reportManifestError = (error: unknown): void => { + try { + input.manifest.onAppendError(error) + } catch {} + } try { input.manifest.append({ requestSeq, @@ -669,11 +674,24 @@ export class DeepChatContextCoordinator { ...(executionContract ? { executionContract } : {}) }) } catch (error) { - try { - input.manifest.onAppendError(error) - } catch {} - if (input.strictViewContract) throw 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, executionContract } } diff --git a/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts b/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts index 1164e3fab..fd60cf204 100644 --- a/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts +++ b/test/main/agent/deepchat/harness/deepChatAgentHarness.test.ts @@ -4228,8 +4228,18 @@ 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( + 'ExecutionContract disabled for request 1 because durable ViewManifest persistence could not be confirmed' + ) ) }) diff --git a/test/main/agent/deepchat/loop/contextCoordinator.test.ts b/test/main/agent/deepchat/loop/contextCoordinator.test.ts index f91281386..75e0b6b04 100644 --- a/test/main/agent/deepchat/loop/contextCoordinator.test.ts +++ b/test/main/agent/deepchat/loop/contextCoordinator.test.ts @@ -485,9 +485,53 @@ 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([ @@ -500,7 +544,8 @@ describe('DeepChatContextCoordinator', () => { throw new Error('contract unavailable') } }), - message: 'contract unavailable' + message: 'contract unavailable', + manifestAttempts: 0 }, { name: 'manifest persistence', @@ -511,7 +556,8 @@ describe('DeepChatContextCoordinator', () => { throw new Error('manifest unavailable') } }), - message: 'manifest unavailable' + message: 'manifest unavailable', + manifestAttempts: 1 } ])('fails a strict child View before provider admission on $name failure', async (scenario) => { const fixture = scenario.create() @@ -521,6 +567,8 @@ describe('DeepChatContextCoordinator', () => { ).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 () => { diff --git a/test/main/agent/deepchat/runtime/process.test.ts b/test/main/agent/deepchat/runtime/process.test.ts index ba98313a9..8fd456b77 100644 --- a/test/main/agent/deepchat/runtime/process.test.ts +++ b/test/main/agent/deepchat/runtime/process.test.ts @@ -1046,11 +1046,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, From d0e242991d9509465fbed33f224858a448a9cf33 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 12:06:39 +0800 Subject: [PATCH 24/37] docs(tape): update validation results --- .../architecture/tape-contract-lineage/tasks.md | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/docs/architecture/tape-contract-lineage/tasks.md b/docs/architecture/tape-contract-lineage/tasks.md index c47f1ca2c..ae8134e66 100644 --- a/docs/architecture/tape-contract-lineage/tasks.md +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -73,16 +73,9 @@ Completed on 2026-08-09: | `pnpm run i18n` | Passed with no missing or invalid translations | | `pnpm run lint` | Passed | | `pnpm run typecheck:node` and `pnpm run typecheck:web` | Passed | -| Focused prompt, View, Tape, dispatch, orchestration, and integration suites | 22 files and 660 tests passed | -| `pnpm run test:main` | Did not pass because of three independently reproduced baseline assertions described below | +| Focused prompt, View, Tape, dispatch, orchestration, and integration suites | Passed | +| `pnpm run test:main` | 570 files and 6,933 tests passed; 1 file and 5 tests skipped | -Independent single-file reruns confirmed the same unrelated failures already documented at the -`dev` merge base in the Agent Memory architecture validation record: - -- `test/main/scheduler/schedulerService.test.ts`: one provider-config snapshot expectation; -- `test/main/app/startupMigrations/sessionDataMigrations.sqlite.test.ts`: two fixture failures because - `new_session_active_skills` is absent. - -This branch does not modify either failing test or its scheduler, startup-migration, or -`NewSessionsTable` owner paths. The final severity-ordered review found no unresolved actionable -findings. The branch has no upstream and was not pushed. +The three previously recorded baseline failures were repaired before the final validation run. The +final severity-ordered review found no unresolved merge blockers. The branch has no upstream and +was not pushed. From 9cf31855218980e805ddb61bb13a1f648e9f5851 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 13:56:51 +0800 Subject: [PATCH 25/37] fix(agent): snapshot prompt provenance --- .../deepchat/resources/promptAssembly.ts | 85 ++++++++++++++----- .../deepchat/resources/promptAssembly.test.ts | 73 ++++++++++++++-- 2 files changed, 126 insertions(+), 32 deletions(-) diff --git a/src/main/agent/deepchat/resources/promptAssembly.ts b/src/main/agent/deepchat/resources/promptAssembly.ts index a43e46f50..533536e82 100644 --- a/src/main/agent/deepchat/resources/promptAssembly.ts +++ b/src/main/agent/deepchat/resources/promptAssembly.ts @@ -9,6 +9,7 @@ import type { 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') @@ -21,6 +22,41 @@ function normalizeDegradationCodes( 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 @@ -38,13 +74,9 @@ export function createPromptAssemblySection(input: { : input.content.trim() const hasContent = content.trim().length > 0 const degradationCodes = normalizeDegradationCodes(input.degradationCodes) - const inclusion = !hasContent - ? 'omitted' - : degradationCodes - ? 'degraded' - : 'included' + const inclusion = !hasContent ? 'omitted' : degradationCodes ? 'degraded' : 'included' - return Object.freeze({ + const section = Object.freeze({ kind: input.kind, sourceRef: input.sourceRef, inclusion, @@ -54,6 +86,8 @@ export function createPromptAssemblySection(input: { content, ...(input.separatorBefore ? { separatorBefore: input.separatorBefore } : {}) }) + promptAssemblySectionSnapshots.add(section) + return section } export function assemblePromptSections( @@ -63,8 +97,9 @@ export function assemblePromptSections( throw new RangeError(`System prompt has more than ${MAX_PROMPT_SECTIONS} provenance sections.`) } + const sectionSnapshots = snapshotPromptAssemblySections(sections) let prompt = '' - for (const section of sections) { + for (const section of sectionSnapshots) { if (!section.content.trim()) continue if (!prompt) { prompt = section.content @@ -75,7 +110,7 @@ export function assemblePromptSections( return Object.freeze({ prompt, - sections: Object.freeze([...sections]) + sections: sectionSnapshots }) } @@ -83,27 +118,29 @@ export function appendPromptAssemblySection( assembly: DeepChatPromptAssembly, section: DeepChatPromptAssemblySection ): DeepChatPromptAssembly { + const assemblySnapshot = snapshotPromptAssembly(assembly) + const sectionSnapshot = snapshotPromptAssemblySection(section) if ( - assembly.sections.some( + assemblySnapshot.sections.some( (candidate) => - candidate.kind === section.kind && - candidate.sourceRef === section.sourceRef && - candidate.contentHash === section.contentHash + candidate.kind === sectionSnapshot.kind && + candidate.sourceRef === sectionSnapshot.sourceRef && + candidate.contentHash === sectionSnapshot.contentHash ) ) { - return assembly + return assemblySnapshot } - if (assembly.sections.length >= MAX_PROMPT_SECTIONS) { + if (assemblySnapshot.sections.length >= MAX_PROMPT_SECTIONS) { throw new RangeError(`System prompt has more than ${MAX_PROMPT_SECTIONS} provenance sections.`) } - const prompt = !section.content.trim() - ? assembly.prompt - : assembly.prompt - ? `${assembly.prompt}${section.separatorBefore ?? '\n\n'}${section.content}` - : section.content + 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([...assembly.sections, section]) + sections: Object.freeze([...assemblySnapshot.sections, sectionSnapshot]) }) } @@ -111,12 +148,14 @@ export function recordPromptAssemblyObservation( assembly: DeepChatPromptAssembly, section: DeepChatPromptAssemblySection ): DeepChatPromptAssembly { - if (assembly.sections.length >= MAX_PROMPT_SECTIONS) { + 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: assembly.prompt, - sections: Object.freeze([...assembly.sections, section]) + prompt: assemblySnapshot.prompt, + sections: Object.freeze([...assemblySnapshot.sections, sectionSnapshot]) }) } diff --git a/test/main/agent/deepchat/resources/promptAssembly.test.ts b/test/main/agent/deepchat/resources/promptAssembly.test.ts index 3f1ecf638..6f8808134 100644 --- a/test/main/agent/deepchat/resources/promptAssembly.test.ts +++ b/test/main/agent/deepchat/resources/promptAssembly.test.ts @@ -7,6 +7,7 @@ import { 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', () => { @@ -45,17 +46,71 @@ describe('promptAssembly', () => { kind: 'tooling', sourceRef: 'tooling', content: 'Tools', - degradationCodes: [ - 'tooling_build_failed', - 'environment_build_failed', - 'tooling_build_failed' - ] + degradationCodes: ['tooling_build_failed', 'environment_build_failed', 'tooling_build_failed'] }) - expect(section.degradationCodes).toEqual([ - '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', () => { From 6ab1acf363326f2e526cf8226dd0b85aae48243f Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 13:58:20 +0800 Subject: [PATCH 26/37] fix(orchestration): isolate corrupt recovery --- .../orchestration/liveDelegationRepository.ts | 96 ++++++++++++++++--- .../orchestration/liveDelegationService.ts | 52 +++++----- .../liveDelegationRepository.test.ts | 22 +++++ .../liveDelegationService.test.ts | 66 +++++++++++++ 4 files changed, 199 insertions(+), 37 deletions(-) diff --git a/src/main/orchestration/liveDelegationRepository.ts b/src/main/orchestration/liveDelegationRepository.ts index edd3f8f48..da5a246d4 100644 --- a/src/main/orchestration/liveDelegationRepository.ts +++ b/src/main/orchestration/liveDelegationRepository.ts @@ -98,6 +98,13 @@ 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) @@ -275,6 +282,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)) } @@ -1056,12 +1088,14 @@ function toTurn(row: LiveDelegationTurnRow): LiveDelegationTurn { const evaluation = parseTaskEvaluation(row.evaluation_json) const evaluationRef = parseEvaluationRef(row.evaluation_ref_json) if ((taskContract === null) !== (taskContractRef === null)) { - throw new Error( + throw new LiveDelegationTaskContractError( `Live delegation turn ${row.turn_id} has an incomplete TaskContract projection.` ) } if (taskContract && taskContractRef?.contractHash !== taskContract.contractHash) { - throw new Error(`Live delegation turn ${row.turn_id} has a conflicting TaskContract reference.`) + throw new LiveDelegationTaskContractError( + `Live delegation turn ${row.turn_id} has a conflicting TaskContract reference.` + ) } if (taskContract) { const description = taskContract.taskDescription @@ -1075,11 +1109,15 @@ function toTurn(row: LiveDelegationTurnRow): LiveDelegationTurn { (inheritedTaskContractRef !== null && inheritedTaskContractRef.contractHash !== taskContract.contractHash) ) { - throw new Error(`Live delegation turn ${row.turn_id} has a misbound TaskContract projection.`) + throw new LiveDelegationTaskContractError( + `Live delegation turn ${row.turn_id} has a misbound TaskContract projection.` + ) } } if ((evaluation === null) !== (evaluationRef === null)) { - throw new Error(`Live delegation turn ${row.turn_id} has an incomplete evaluation projection.`) + throw new LiveDelegationTaskContractError( + `Live delegation turn ${row.turn_id} has an incomplete evaluation projection.` + ) } if ( evaluation && @@ -1091,7 +1129,9 @@ function toTurn(row: LiveDelegationTurnRow): LiveDelegationTurn { evaluationRef.tapeIdentity !== taskContractRef?.tapeIdentity || evaluationRef.evaluationHash !== evaluation.evaluationHash) ) { - throw new Error(`Live delegation turn ${row.turn_id} has a misbound evaluation projection.`) + throw new LiveDelegationTaskContractError( + `Live delegation turn ${row.turn_id} has a misbound evaluation projection.` + ) } const parsed = LiveDelegationTurnSchema.parse({ id: row.turn_id, @@ -1255,31 +1295,61 @@ 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(JSON.parse(value)) - if (!contract) throw new Error('Stored live delegation TaskContract is malformed.') + 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(JSON.parse(value)) - if (!ref) throw new Error('Stored live delegation TaskContract reference is malformed.') + 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 = restoreTaskEvaluation(JSON.parse(value)) - if (!evaluation) throw new Error('Stored live delegation Task evaluation is malformed.') + const evaluation = restoreTaskEvaluation( + parseStoredContractProjectionJson(value, 'Task evaluation') + ) + if (!evaluation) { + throw new LiveDelegationTaskContractError( + 'Stored live delegation Task evaluation is malformed.' + ) + } return evaluation } function parseEvaluationRef(value: string | null) { if (!value) return null - const ref = restoreEvaluationRef(JSON.parse(value)) - if (!ref) throw new Error('Stored live delegation Task evaluation reference is malformed.') + const ref = restoreEvaluationRef( + parseStoredContractProjectionJson(value, 'Task evaluation reference') + ) + if (!ref) { + throw new LiveDelegationTaskContractError( + 'Stored live delegation Task evaluation reference is malformed.' + ) + } return ref } diff --git a/src/main/orchestration/liveDelegationService.ts b/src/main/orchestration/liveDelegationService.ts index 37d6becda..28dd6cc38 100644 --- a/src/main/orchestration/liveDelegationService.ts +++ b/src/main/orchestration/liveDelegationService.ts @@ -48,6 +48,7 @@ import { resolveToolPermissionMode } from '@/tool/permission/permissionMode' import { LiveDelegationTaskContractError, type ActiveLiveDelegationTurn, + type ActiveLiveDelegationTurnIdentity, type LiveDelegationRepository } from './liveDelegationRepository' import type { @@ -187,12 +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 @@ -559,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()) { @@ -1362,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) } @@ -1455,28 +1459,28 @@ 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.turn.id - let childSessionId = record.delegation.childSessionId + let turnId = record.turnId + let childSessionId = record.childSessionId try { - const current = this.options.repository.getTurn(record.turn.id) + 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.delegation.id)?.childSessionId ?? childSessionId + this.options.repository.get(record.delegationId)?.childSessionId ?? childSessionId } catch (lookupError) { console.error('[LiveDelegationService] Failed to resolve reconciliation quarantine:', { - delegationId: record.delegation.id, + delegationId: record.delegationId, turnId, error: lookupError }) @@ -1499,10 +1503,10 @@ export class LiveDelegationService { 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 } @@ -1514,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 }) } diff --git a/test/main/orchestration/liveDelegationRepository.test.ts b/test/main/orchestration/liveDelegationRepository.test.ts index 6bf5f9e83..e0f5c71e5 100644 --- a/test/main/orchestration/liveDelegationRepository.test.ts +++ b/test/main/orchestration/liveDelegationRepository.test.ts @@ -37,6 +37,7 @@ 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! @@ -48,6 +49,7 @@ const describeIfSqlite = nativeSqliteDescribeIf( LiveDelegationTurnsTableCtor && LiveDelegationEventsTableCtor && LiveDelegationRepositoryCtor && + LiveDelegationTaskContractErrorCtor && DeepChatContractStoreCtor && TaskContractServiceCtor && TaskEvaluationServiceCtor @@ -295,9 +297,29 @@ describeIfSqlite('LiveDelegationRepository', () => { ) .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 { diff --git a/test/main/orchestration/liveDelegationService.test.ts b/test/main/orchestration/liveDelegationService.test.ts index 3663420b5..413e05841 100644 --- a/test/main/orchestration/liveDelegationService.test.ts +++ b/test/main/orchestration/liveDelegationService.test.ts @@ -2343,6 +2343,72 @@ 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 completeAcceptedAnswer(): string { From 46a99239eefb49b5db010cd59653084dc0130817 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 13:59:11 +0800 Subject: [PATCH 27/37] fix(tape): harden evaluation schemas --- src/main/tape/domain/taskContract.ts | 61 +++++++++++++++++++++++++-- src/shared/types/task-contract.ts | 8 +++- test/main/tape/taskContract.test.ts | 52 +++++++++++++++++++++++ test/main/tape/taskEvaluation.test.ts | 26 ++++++++++++ 4 files changed, 141 insertions(+), 6 deletions(-) diff --git a/src/main/tape/domain/taskContract.ts b/src/main/tape/domain/taskContract.ts index 93103fef4..e02c4bd42 100644 --- a/src/main/tape/domain/taskContract.ts +++ b/src/main/tape/domain/taskContract.ts @@ -25,6 +25,30 @@ const MAX_SUBAGENT_DEPTH = 1 const MAX_RESULT_SCHEMA_DEPTH = 64 const MAX_RESULT_SCHEMA_NODES = 4_096 const SHA_256_PATTERN = /^[0-9a-f]{64}$/u +const FORBIDDEN_RESULT_SCHEMA_KEYS = new Set(['$ref', '$dynamicRef', '$recursiveRef', '$async']) +const SINGLE_RESULT_SCHEMA_KEYWORDS = new Set([ + 'additionalItems', + 'additionalProperties', + 'contains', + 'contentSchema', + 'else', + 'if', + 'not', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties' +]) +const ARRAY_RESULT_SCHEMA_KEYWORDS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']) +const MAP_RESULT_SCHEMA_KEYWORDS = new Set([ + '$defs', + 'definitions', + 'dependentSchemas', + 'patternProperties', + 'properties' +]) + +type ResultSchemaPosition = 'schema' | 'schema_array' | 'schema_map' | 'dependency_map' | 'data' const TASK_CONTRACT_KEYS = [ 'schemaVersion', @@ -185,7 +209,8 @@ function assertBoundedJsonSchema( value: unknown, label: string, depth: number, - state: { nodes: number; ancestors: Set } + state: { nodes: number; ancestors: Set }, + position: ResultSchemaPosition = 'schema' ): void { state.nodes += 1 if (depth > MAX_RESULT_SCHEMA_DEPTH || state.nodes > MAX_RESULT_SCHEMA_NODES) { @@ -227,7 +252,13 @@ function assertBoundedJsonSchema( 'invalid_input' ) } - assertBoundedJsonSchema(descriptor.value, label, depth + 1, state) + assertBoundedJsonSchema( + descriptor.value, + label, + depth + 1, + state, + position === 'schema_array' ? 'schema' : 'data' + ) } return } @@ -241,16 +272,38 @@ function assertBoundedJsonSchema( if (!descriptor?.enumerable || !('value' in descriptor)) { throw new TaskContractError(`${label} must contain only data properties.`, 'invalid_input') } - if (key === '$ref' || key === '$async') { + if (position === 'schema' && FORBIDDEN_RESULT_SCHEMA_KEYS.has(key)) { throw new TaskContractError(`${label} must not contain ${key}.`, 'invalid_input') } - assertBoundedJsonSchema(descriptor.value, label, depth + 1, state) + assertBoundedJsonSchema( + descriptor.value, + label, + depth + 1, + state, + nestedResultSchemaPosition(position, key, descriptor.value) + ) } } finally { state.ancestors.delete(value) } } +function nestedResultSchemaPosition( + position: ResultSchemaPosition, + key: string, + value: unknown +): ResultSchemaPosition { + if (position === 'schema_map') return 'schema' + if (position === 'dependency_map') return Array.isArray(value) ? 'data' : 'schema' + if (position !== 'schema') return 'data' + if (key === 'items') return Array.isArray(value) ? 'schema_array' : 'schema' + if (key === 'dependencies') return 'dependency_map' + if (SINGLE_RESULT_SCHEMA_KEYWORDS.has(key)) return 'schema' + if (ARRAY_RESULT_SCHEMA_KEYWORDS.has(key)) return 'schema_array' + if (MAP_RESULT_SCHEMA_KEYWORDS.has(key)) return 'schema_map' + return 'data' +} + function normalizeAcceptance( requirements: readonly DeepChatTaskAcceptanceRequirement[] ): DeepChatTaskAcceptanceRequirement[] { diff --git a/src/shared/types/task-contract.ts b/src/shared/types/task-contract.ts index 49e7713d6..e7c38d5ab 100644 --- a/src/shared/types/task-contract.ts +++ b/src/shared/types/task-contract.ts @@ -243,7 +243,9 @@ export const DeepChatTaskEvaluationProjectionSchema: z.ZodType { }) ) ).toThrow(/must not contain \$async/u) + for (const key of ['$dynamicRef', '$recursiveRef'] as const) { + expect(() => + buildTaskContract( + buildInput({ + acceptance: [ + { + id: 'dynamic-schema', + kind: 'result_schema', + section: 'Result', + schema: { [key]: '#result' } + } + ] + }) + ) + ).toThrow(`must not contain ${key}`) + expect(() => + buildTaskContract( + buildInput({ + acceptance: [ + { + id: 'nested-dynamic-schema', + kind: 'result_schema', + section: 'Result', + schema: { properties: { result: { [key]: '#result' } } } + } + ] + }) + ) + ).toThrow(`must not contain ${key}`) + } + expect(() => + buildTaskContract( + buildInput({ + acceptance: [ + { + id: 'schema-property-names', + kind: 'result_schema', + section: 'Result', + schema: { + $id: 'https://example.invalid/result.schema.json', + $schema: 'http://json-schema.org/draft-07/schema#', + properties: { + $dynamicRef: { type: 'string' }, + $recursiveRef: { type: 'number' } + }, + type: 'object' + } + } + ] + }) + ) + ).not.toThrow() expect(() => buildTaskContract( buildInput({ diff --git a/test/main/tape/taskEvaluation.test.ts b/test/main/tape/taskEvaluation.test.ts index 9f674e757..10b4fdca9 100644 --- a/test/main/tape/taskEvaluation.test.ts +++ b/test/main/tape/taskEvaluation.test.ts @@ -2,6 +2,9 @@ import path from 'node:path' import Ajv from 'ajv' import { afterEach, describe, expect, it, vi } from 'vitest' import { + DEEPCHAT_TASK_EVALUATION_REASON_CODES, + DeepChatTaskEvaluationProjectionSchema, + DeepChatTaskEvaluationSummarySchema, MAX_TASK_EVALUATION_CANDIDATE_BYTES, type DeepChatTaskAcceptanceRequirement, type DeepChatTaskEvaluationExecutionStatus @@ -314,4 +317,27 @@ describe('Task evaluation domain', () => { 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 + ) + }) }) From 1d07cf9747635b1f63c79278b6335dde08526a8b Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 14:00:09 +0800 Subject: [PATCH 28/37] style(agent): format system prompt builder --- .../deepchat/resources/systemPromptBuilder.ts | 492 +++++++++--------- 1 file changed, 243 insertions(+), 249 deletions(-) diff --git a/src/main/agent/deepchat/resources/systemPromptBuilder.ts b/src/main/agent/deepchat/resources/systemPromptBuilder.ts index 016f85091..50ed30ad3 100644 --- a/src/main/agent/deepchat/resources/systemPromptBuilder.ts +++ b/src/main/agent/deepchat/resources/systemPromptBuilder.ts @@ -1,22 +1,22 @@ import type { ProviderModelResolutionPort } from '@/provider/settings' -import fs from "fs"; -import path from "path"; +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 { 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"; +} from './systemEnvPromptBuilder' import { assemblePromptSections, createPromptAssemblySection } from './promptAssembly' -import type { SkillSettingsPort } from "@/skill/settings"; +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 { @@ -25,87 +25,87 @@ 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; + 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 buildSystemPromptAssemblyWithSkills( dependencies: SystemPromptBuilderDependencies, - input: SystemPromptBuildInput, + input: SystemPromptBuildInput ): Promise { const { sessionId, basePrompt, toolDefinitions, activeSkillNamesOverride, resourceInstance } = - input; - 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"; + input + 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 assemblePromptSections([ createPromptAssemblySection({ @@ -113,166 +113,160 @@ export async function buildSystemPromptAssemblyWithSkills( 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; - const skillsMetadataDegradations: DeepChatPromptDegradationCode[] = []; - const pinnedSkillsDegradations: DeepChatPromptDegradationCode[] = []; - 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'); + 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[] + }> = [] + 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, - ); - skillsMetadataDegradations.push('skill_metadata_unavailable'); + error + ) + 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, - ); - pinnedSkillsDegradations.push('active_skills_unavailable'); + 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 requestedActiveSkills = normalizeStringList(activeSkillNames); + let stepStartedAt = Date.now() + const normalizedAvailableSkills = normalizeSkillMetadata(availableSkills) + const availableSkillNames = new Set(normalizedAvailableSkills.map((skill) => skill.name)) + const requestedActiveSkills = normalizeStringList(activeSkillNames) const normalizedActiveSkills = requestedActiveSkills.filter((skillName) => - availableSkillNames.has(skillName), - ); + availableSkillNames.has(skillName) + ) if (normalizedActiveSkills.length !== requestedActiveSkills.length) { - pinnedSkillsDegradations.push('pinned_skill_unavailable'); + pinnedSkillsDegradations.push('pinned_skill_unavailable') } - const agentToolNames = getAgentToolNames(toolDefinitions); + 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'); + pinnedSkillsDegradations.push('pinned_skill_unavailable') } } catch (error) { console.warn( `[DeepChatAgent] Failed to load skill content for "${skillName}" in session ${sessionId}:`, - error, - ); - pinnedSkillsDegradations.push('pinned_skill_load_failed'); + 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 envSections: readonly DeepChatPromptAssemblySection[] = []; + let envSections: readonly DeepChatPromptAssemblySection[] = [] try { - stepStartedAt = Date.now(); + stepStartedAt = Date.now() envSections = ( await buildSystemEnvPromptAssembly({ - providerId, - modelId, - workdir, - now, - modelLookup: dependencies.providerCatalogPort, + providerId, + modelId, + workdir, + now, + modelLookup: dependencies.providerCatalogPort }) - ).sections; - dependencies.logSlowStep(sessionId, "system-prompt.env-prompt", stepStartedAt); + ).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', @@ -286,24 +280,24 @@ export async function buildSystemPromptAssemblyWithSkills( content: '', degradationCodes: ['environment_build_failed'] }) - ]; + ] } - let toolingPrompt = ""; - const toolingDegradations: DeepChatPromptDegradationCode[] = []; + 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); - toolingDegradations.push('tooling_build_failed'); + console.warn(`[DeepChatAgent] Failed to build tooling prompt for session ${sessionId}:`, error) + toolingDegradations.push('tooling_build_failed') } - stepStartedAt = Date.now(); + stepStartedAt = Date.now() const assembly = assemblePromptSections([ createPromptAssemblySection({ kind: 'configured_prompt', @@ -349,18 +343,18 @@ export async function buildSystemPromptAssemblyWithSkills( sourceRef: 'runtime:workspace-verification-policy', content: buildVerificationPolicyPrompt(workdir) }) - ]); - dependencies.logSlowStep(sessionId, "system-prompt.compose", stepStartedAt); + ]) + dependencies.logSlowStep(sessionId, 'system-prompt.compose', stepStartedAt) - dependencies.assertCurrent(sessionId, resourceInstance); - return assembly; + dependencies.assertCurrent(sessionId, resourceInstance) + return assembly } export async function buildSystemPromptWithSkills( dependencies: SystemPromptBuilderDependencies, - input: SystemPromptBuildInput, + input: SystemPromptBuildInput ): Promise { - return (await buildSystemPromptAssemblyWithSkills(dependencies, input)).prompt; + return (await buildSystemPromptAssemblyWithSkills(dependencies, input)).prompt } function buildOrchestrationPolicyPrompt( @@ -400,83 +394,83 @@ function buildOrchestrationPolicyPrompt( } 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 && @@ -484,128 +478,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) + ) } From 3b2f18cb21b818cb553e7e746e04a2858dc0671f Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 14:00:31 +0800 Subject: [PATCH 29/37] test(agent): tighten prompt contract checks --- .../agent/deepchat/resources/systemPromptBuilder.test.ts | 5 ++--- test/main/agent/deepchat/runtime/process.test.ts | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/main/agent/deepchat/resources/systemPromptBuilder.test.ts b/test/main/agent/deepchat/resources/systemPromptBuilder.test.ts index 8331568a0..aee2277fc 100644 --- a/test/main/agent/deepchat/resources/systemPromptBuilder.test.ts +++ b/test/main/agent/deepchat/resources/systemPromptBuilder.test.ts @@ -23,8 +23,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 @@ -160,7 +159,7 @@ describe('DeepChat system prompt builder', () => { expect(assembly.sections.find((section) => section.kind === 'configured_prompt')).toMatchObject( { inclusion: 'included', - contentHash: expect.stringMatching(/^[a-f0-9]{64}$/) + contentHash: '2f438783cf88972d8d9fd3394aac256edde99cd6d9a8e9166aff93ec5bcfc2c4' } ) expect(explicit).toContain('## Multi-Agent Orchestration Policy') diff --git a/test/main/agent/deepchat/runtime/process.test.ts b/test/main/agent/deepchat/runtime/process.test.ts index 8fd456b77..8b42d80b8 100644 --- a/test/main/agent/deepchat/runtime/process.test.ts +++ b/test/main/agent/deepchat/runtime/process.test.ts @@ -1022,9 +1022,11 @@ describe('processStream', () => { 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) ) From 51c50df911d0e918c58323831bc37dad25400d40 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 14:33:48 +0800 Subject: [PATCH 30/37] fix(agent): preserve active skill loading --- .../deepchat/resources/systemPromptBuilder.ts | 13 ++-- .../resources/systemPromptBuilder.test.ts | 62 +++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/main/agent/deepchat/resources/systemPromptBuilder.ts b/src/main/agent/deepchat/resources/systemPromptBuilder.ts index 50ed30ad3..27aa42e79 100644 --- a/src/main/agent/deepchat/resources/systemPromptBuilder.ts +++ b/src/main/agent/deepchat/resources/systemPromptBuilder.ts @@ -146,6 +146,7 @@ export async function buildSystemPromptAssemblyWithSkills( category?: string | null platforms?: string[] }> = [] + let skillMetadataLookupFailed = false const activeSkillNames: string[] = activeSkillNamesOverride ? [...activeSkillNamesOverride] : [] const skillDraftSuggestionsEnabled = dependencies.skillSettings.isDraftSuggestionsEnabled() @@ -169,6 +170,7 @@ export async function buildSystemPromptAssemblyWithSkills( `[DeepChatAgent] Failed to load skills metadata for session ${sessionId}:`, error ) + skillMetadataLookupFailed = true skillsMetadataDegradations.push('skill_metadata_unavailable') } dependencies.logSlowStep(sessionId, 'system-prompt.skills-metadata-load', metadataStartedAt) @@ -198,10 +200,13 @@ export async function buildSystemPromptAssemblyWithSkills( const normalizedAvailableSkills = normalizeSkillMetadata(availableSkills) const availableSkillNames = new Set(normalizedAvailableSkills.map((skill) => skill.name)) const requestedActiveSkills = normalizeStringList(activeSkillNames) - const normalizedActiveSkills = requestedActiveSkills.filter((skillName) => - availableSkillNames.has(skillName) - ) - if (normalizedActiveSkills.length !== requestedActiveSkills.length) { + 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) diff --git a/test/main/agent/deepchat/resources/systemPromptBuilder.test.ts b/test/main/agent/deepchat/resources/systemPromptBuilder.test.ts index aee2277fc..72b936467 100644 --- a/test/main/agent/deepchat/resources/systemPromptBuilder.test.ts +++ b/test/main/agent/deepchat/resources/systemPromptBuilder.test.ts @@ -228,6 +228,68 @@ 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'], + 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' From 8660f9db3c0a71dc870d250a8cdd45e7b1c898c2 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 14:34:14 +0800 Subject: [PATCH 31/37] fix(main): fail closed on subagent policy --- src/main/app/composition.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index c2300d5b1..ce14323bc 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -1146,7 +1146,7 @@ export async function createMainProcessControl(dependencies: { const subagentCapability = resolveDeepChatSubagentCapability({ agentType, sessionKind: session.sessionKind, - agentPolicyEnabled: agentConfig.subagentEnabled !== false, + agentPolicyEnabled: agentConfig.subagentEnabled === true, slots: normalizeDeepChatSubagentSlots(agentConfig.subagents) }) From e615d0b3608bc4ef03b38895dddbad2a51b73a9a Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 17:44:08 +0800 Subject: [PATCH 32/37] refactor(tape): limit child contracts to handoff --- package.json | 3 +- pnpm-lock.yaml | 5 +- .../deepchat/runtime/deepChatLoopRunner.ts | 126 ++++---- .../agent/deepchat/runtime/turnCoordinator.ts | 12 +- .../orchestration/liveDelegationService.ts | 4 +- .../liveDelegationTaskContract.ts | 12 +- src/main/tape/domain/taskContract.ts | 228 +++----------- src/main/tape/domain/taskEvaluation.ts | 287 ++---------------- src/shared/types/task-contract.ts | 104 ++----- .../harness/deepChatAgentHarness.test.ts | 85 ++++-- .../liveDelegationRepository.test.ts | 18 +- .../liveDelegationService.test.ts | 32 +- test/main/tape/executionContract.test.ts | 2 +- test/main/tape/taskContract.test.ts | 247 ++++++--------- .../main/tape/taskContractPersistence.test.ts | 2 +- test/main/tape/taskEvaluation.test.ts | 199 +++--------- 16 files changed, 386 insertions(+), 980 deletions(-) diff --git a/package.json b/package.json index 9133c594f..e875b9fae 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,6 @@ "@parcel/watcher": "^2.5.6", "@zerob13/nativekit": "0.6.3", "ai": "^7.0.54", - "ajv": "8.20.0", "axios": "^1.18.1", "better-sqlite3-multiple-ciphers": "12.9.0", "compare-versions": "^6.1.1", @@ -154,7 +153,7 @@ "pdf-parse-new": "^1.4.1", "qrcode": "^1.5.4", "run-applescript": "^7.1.0", - "safe-regex2": "5.1.1", + "safe-regex2": "^5.1.1", "sharp": "^0.35.3", "tokenx": "0.4.1", "turndown": "^7.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9587fd904..8d7862992 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,9 +84,6 @@ importers: ai: specifier: ^7.0.54 version: 7.0.54(zod@4.4.3) - ajv: - specifier: 8.20.0 - version: 8.20.0 axios: specifier: ^1.18.1 version: 1.18.1 @@ -166,7 +163,7 @@ importers: specifier: ^7.1.0 version: 7.1.0 safe-regex2: - specifier: 5.1.1 + specifier: ^5.1.1 version: 5.1.1 sharp: specifier: ^0.35.3 diff --git a/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts b/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts index 0ad8eb404..5cac3410f 100644 --- a/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts +++ b/src/main/agent/deepchat/runtime/deepChatLoopRunner.ts @@ -258,7 +258,10 @@ export interface DeepChatLoopRunnerPorts { sessionSettings: Pick promptAssembly: Pick runLifecycle: LoopRunLifecyclePort - identity: Pick + identity: Pick< + SessionIdentityService, + 'getAgentId' | 'getSessionKind' | 'isAcpBackedSubagentSession' + > sessionPermissionPort: SessionPermissionPort reviewToolPermission: ToolPermissionReviewer hookSink: Pick @@ -403,7 +406,12 @@ export class DeepChatLoopRunner { throw new Error('Request was not sent because the prompt is empty.') } const sessionKind = this.ports.identity.getSessionKind(sessionId) - const strictViewContract = sessionKind === 'subagent' + 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 ?? @@ -722,63 +730,65 @@ export class DeepChatLoopRunner { expectedInstance: resourceInstance }) }, - executionContract: { - 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 + 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 + }) }, - assemblerVersion: contextBuilderVersion, - taskContractContext - }) - }, - onBuildError: (error) => - logger.warn( - `[DeepChatAgent] Failed to construct execution contract: ${ - error instanceof Error ? error.message : String(error) - }` - ) - }, + onBuildError: (error) => + logger.warn( + `[DeepChatAgent] Failed to construct execution contract: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } + : undefined, strictViewContract, manifest: { resolvePolicy: resolveTapeViewManifestPolicy, diff --git a/src/main/agent/deepchat/runtime/turnCoordinator.ts b/src/main/agent/deepchat/runtime/turnCoordinator.ts index c790f4dac..fda01d2d2 100644 --- a/src/main/agent/deepchat/runtime/turnCoordinator.ts +++ b/src/main/agent/deepchat/runtime/turnCoordinator.ts @@ -154,7 +154,7 @@ export interface TurnCoordinatorPorts { 'resolveProjectDir' | 'getEffectiveGenerationSettings' > promptAssembly: Pick - identity: Pick + identity: Pick taskContractContext: DeepChatTaskContractContextPort loopRunner: Pick messageProjection: Pick @@ -253,10 +253,12 @@ export class TurnCoordinator { signal ) ) - const taskContractContext = - this.ports.identity.getSessionKind(sessionId) === 'subagent' - ? this.ports.taskContractContext.prepare(sessionId) - : null + 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) diff --git a/src/main/orchestration/liveDelegationService.ts b/src/main/orchestration/liveDelegationService.ts index 28dd6cc38..d5eaf7610 100644 --- a/src/main/orchestration/liveDelegationService.ts +++ b/src/main/orchestration/liveDelegationService.ts @@ -63,7 +63,7 @@ import type { import { createLegacyLiveDelegationTaskContractInput, createLiveDelegationTaskContractInput, - LIVE_DELEGATION_REQUIRED_RESULT_SECTIONS + LIVE_DELEGATION_REQUIRED_HANDOFF_SECTIONS } from './liveDelegationTaskContract' import { extractMarkdownLevelTwoSection } from '@shared/orchestration/liveDelegationMarkdown' @@ -1655,7 +1655,7 @@ export class LiveDelegationService { } function buildTurnHandoff(delegation: LiveDelegation, turn: LiveDelegationTurn): string { - const [handoffSection, ...remainingSections] = LIVE_DELEGATION_REQUIRED_RESULT_SECTIONS + const [handoffSection, ...remainingSections] = LIVE_DELEGATION_REQUIRED_HANDOFF_SECTIONS return [ '# DeepChat Live Delegation', '', diff --git a/src/main/orchestration/liveDelegationTaskContract.ts b/src/main/orchestration/liveDelegationTaskContract.ts index fda4f0f30..db22c6d4b 100644 --- a/src/main/orchestration/liveDelegationTaskContract.ts +++ b/src/main/orchestration/liveDelegationTaskContract.ts @@ -1,10 +1,10 @@ import type { DeepChatEvaluationRef, - DeepChatTaskAcceptanceRequirement, + DeepChatHandoffFormatRequirement, DeepChatTaskWorkspaceCeiling } from '@shared/types/task-contract' -export const LIVE_DELEGATION_REQUIRED_RESULT_SECTIONS = [ +export const LIVE_DELEGATION_REQUIRED_HANDOFF_SECTIONS = [ 'Handoff', 'Result', 'Evidence', @@ -15,7 +15,7 @@ export const LIVE_DELEGATION_REQUIRED_RESULT_SECTIONS = [ export interface LiveDelegationTaskContractInput { workspace: DeepChatTaskWorkspaceCeiling - acceptance: readonly DeepChatTaskAcceptanceRequirement[] + handoffFormat: readonly DeepChatHandoffFormatRequirement[] predecessorEvaluationRef: DeepChatEvaluationRef | null maxToolEffect: 'read' | 'write' maxSubagentDepth: number @@ -31,12 +31,12 @@ export function createLiveDelegationTaskContractInput( ): LiveDelegationTaskContractInput { return { workspace: projectDir ? { kind: 'path', path: projectDir } : { kind: 'runtime_default' }, - acceptance: [ + handoffFormat: [ { id: 'live-delegation-required-sections', kind: 'required_sections', level: 2, - sections: LIVE_DELEGATION_REQUIRED_RESULT_SECTIONS + sections: LIVE_DELEGATION_REQUIRED_HANDOFF_SECTIONS } ], predecessorEvaluationRef, @@ -50,7 +50,7 @@ export function createLegacyLiveDelegationTaskContractInput( ): LegacyLiveDelegationTaskContractInput { return { ...createLiveDelegationTaskContractInput(projectDir), - acceptance: [], + handoffFormat: [], creationReason: 'legacy_recovery' } } diff --git a/src/main/tape/domain/taskContract.ts b/src/main/tape/domain/taskContract.ts index e02c4bd42..46764e395 100644 --- a/src/main/tape/domain/taskContract.ts +++ b/src/main/tape/domain/taskContract.ts @@ -4,14 +4,12 @@ import { DEEPCHAT_TASK_CONTRACT_SCHEMA_VERSION, MAX_TASK_CONTRACT_BYTES, MAX_TASK_CONTRACT_REQUIREMENTS, - MAX_TASK_CONTRACT_RESULT_SCHEMA_BYTES, type DeepChatEvaluationRef, - type DeepChatTaskAcceptanceRequirement, type DeepChatTaskContract, type DeepChatTaskContractRef, + type DeepChatHandoffFormatRequirement, type DeepChatTaskWorkspaceCeiling } from '@shared/types/task-contract' -import type { JsonValue } from '@shared/contracts/json' import { canonicalJsonStringifyData, hashJsonData } from './canonicalJson' import { normalizeAbsoluteWorkspacePath } from './workspacePath' @@ -22,33 +20,7 @@ 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 MAX_RESULT_SCHEMA_DEPTH = 64 -const MAX_RESULT_SCHEMA_NODES = 4_096 const SHA_256_PATTERN = /^[0-9a-f]{64}$/u -const FORBIDDEN_RESULT_SCHEMA_KEYS = new Set(['$ref', '$dynamicRef', '$recursiveRef', '$async']) -const SINGLE_RESULT_SCHEMA_KEYWORDS = new Set([ - 'additionalItems', - 'additionalProperties', - 'contains', - 'contentSchema', - 'else', - 'if', - 'not', - 'propertyNames', - 'then', - 'unevaluatedItems', - 'unevaluatedProperties' -]) -const ARRAY_RESULT_SCHEMA_KEYWORDS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']) -const MAP_RESULT_SCHEMA_KEYWORDS = new Set([ - '$defs', - 'definitions', - 'dependentSchemas', - 'patternProperties', - 'properties' -]) - -type ResultSchemaPosition = 'schema' | 'schema_array' | 'schema_map' | 'dependency_map' | 'data' const TASK_CONTRACT_KEYS = [ 'schemaVersion', @@ -79,7 +51,7 @@ export interface BuildTaskContractInput { title: string prompt: string workspace: DeepChatTaskWorkspaceCeiling - acceptance: readonly DeepChatTaskAcceptanceRequirement[] + handoffFormat: readonly DeepChatHandoffFormatRequirement[] creationReason?: 'delegation_created' | 'legacy_recovery' predecessorEvaluationRef?: DeepChatEvaluationRef | null maxToolEffect?: 'read' | 'write' @@ -185,186 +157,58 @@ function normalizeEvaluationRef(value: DeepChatEvaluationRef | null): DeepChatEv } } -function normalizeJsonValue(value: JsonValue, label: string): JsonValue { - assertBoundedJsonSchema(value, label, 0, { nodes: 0, ancestors: new Set() }) - let serialized: string - try { - serialized = canonicalJsonStringifyData(value) - } catch (error) { - throw new TaskContractError(`${label} must contain only JSON data.`, 'invalid_input', { - cause: error - }) - } - if (utf8Length(serialized) > MAX_TASK_CONTRACT_RESULT_SCHEMA_BYTES) { - throw new TaskContractError( - `${label} exceeds ${MAX_TASK_CONTRACT_RESULT_SCHEMA_BYTES} UTF-8 bytes.`, - 'limit_exceeded' - ) - } - const normalized = JSON.parse(serialized) as JsonValue - return normalized -} - -function assertBoundedJsonSchema( - value: unknown, - label: string, - depth: number, - state: { nodes: number; ancestors: Set }, - position: ResultSchemaPosition = 'schema' -): void { - state.nodes += 1 - if (depth > MAX_RESULT_SCHEMA_DEPTH || state.nodes > MAX_RESULT_SCHEMA_NODES) { - throw new TaskContractError( - `${label} exceeds the structural complexity limit.`, - 'limit_exceeded' - ) - } - if ( - value === null || - typeof value === 'string' || - typeof value === 'boolean' || - (typeof value === 'number' && Number.isFinite(value)) - ) { - return - } - if (!value || typeof value !== 'object') { - throw new TaskContractError(`${label} must contain only JSON data.`, 'invalid_input') - } - if (state.ancestors.has(value)) { - throw new TaskContractError(`${label} must not contain circular references.`, 'invalid_input') - } - if (Object.getOwnPropertySymbols(value).length > 0) { - throw new TaskContractError(`${label} must not contain symbol properties.`, 'invalid_input') - } - - state.ancestors.add(value) - try { - if (Array.isArray(value)) { - const keys = Object.getOwnPropertyNames(value).filter((key) => key !== 'length') - if (keys.length !== value.length) { - throw new TaskContractError(`${label} must not contain sparse arrays.`, 'invalid_input') - } - for (let index = 0; index < value.length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)) - if (!descriptor?.enumerable || !('value' in descriptor)) { - throw new TaskContractError( - `${label} must contain only data properties.`, - 'invalid_input' - ) - } - assertBoundedJsonSchema( - descriptor.value, - label, - depth + 1, - state, - position === 'schema_array' ? 'schema' : 'data' - ) - } - return - } - - const prototype = Object.getPrototypeOf(value) - if (prototype !== Object.prototype && prototype !== null) { - throw new TaskContractError(`${label} must contain only plain objects.`, 'invalid_input') - } - for (const key of Object.getOwnPropertyNames(value)) { - const descriptor = Object.getOwnPropertyDescriptor(value, key) - if (!descriptor?.enumerable || !('value' in descriptor)) { - throw new TaskContractError(`${label} must contain only data properties.`, 'invalid_input') - } - if (position === 'schema' && FORBIDDEN_RESULT_SCHEMA_KEYS.has(key)) { - throw new TaskContractError(`${label} must not contain ${key}.`, 'invalid_input') - } - assertBoundedJsonSchema( - descriptor.value, - label, - depth + 1, - state, - nestedResultSchemaPosition(position, key, descriptor.value) - ) - } - } finally { - state.ancestors.delete(value) - } -} - -function nestedResultSchemaPosition( - position: ResultSchemaPosition, - key: string, - value: unknown -): ResultSchemaPosition { - if (position === 'schema_map') return 'schema' - if (position === 'dependency_map') return Array.isArray(value) ? 'data' : 'schema' - if (position !== 'schema') return 'data' - if (key === 'items') return Array.isArray(value) ? 'schema_array' : 'schema' - if (key === 'dependencies') return 'dependency_map' - if (SINGLE_RESULT_SCHEMA_KEYWORDS.has(key)) return 'schema' - if (ARRAY_RESULT_SCHEMA_KEYWORDS.has(key)) return 'schema_array' - if (MAP_RESULT_SCHEMA_KEYWORDS.has(key)) return 'schema_map' - return 'data' -} - -function normalizeAcceptance( - requirements: readonly DeepChatTaskAcceptanceRequirement[] -): DeepChatTaskAcceptanceRequirement[] { +function normalizeHandoffFormat( + requirements: readonly DeepChatHandoffFormatRequirement[] +): DeepChatHandoffFormatRequirement[] { if (!Array.isArray(requirements)) { - throw new TaskContractError('acceptance must be an array.', 'invalid_input') + throw new TaskContractError('handoffFormat must be an array.', 'invalid_input') } if (requirements.length > MAX_TASK_CONTRACT_REQUIREMENTS) { throw new TaskContractError( - `acceptance exceeds ${MAX_TASK_CONTRACT_REQUIREMENTS} requirements.`, + `handoffFormat exceeds ${MAX_TASK_CONTRACT_REQUIREMENTS} requirements.`, 'limit_exceeded' ) } const ids = new Set() const normalized = requirements.map((requirement, index) => { - const label = `acceptance[${index}]` + const label = `handoffFormat[${index}]` const id = requireString(requirement?.id, `${label}.id`, MAX_IDENTITY_BYTES) if (ids.has(id)) { throw new TaskContractError( - `acceptance requirement ID is duplicated: ${id}.`, + `Handoff format requirement ID is duplicated: ${id}.`, 'invalid_input' ) } ids.add(id) - if (requirement.kind === 'required_sections') { - if (requirement.level !== 2 || !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 } + if (requirement.kind !== 'required_sections' || requirement.level !== 2) { + throw new TaskContractError(`${label}.kind is invalid.`, 'invalid_input') } - - if (requirement.kind === 'result_schema') { - return { - id, - kind: 'result_schema' as const, - section: requireString(requirement.section, `${label}.section`, MAX_SECTION_NAME_BYTES), - schema: normalizeJsonValue(requirement.schema, `${label}.schema`) + 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') } - throw new TaskContractError(`${label}.kind is invalid.`, '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)) } @@ -441,7 +285,7 @@ function buildTaskContractDraft( prompt: requireString(input.prompt, 'prompt', MAX_PROMPT_BYTES) }, taskHarness: { - acceptance: normalizeAcceptance(input.acceptance), + acceptance: normalizeHandoffFormat(input.handoffFormat), ceilings: { maxToolEffect, workspace: normalizeWorkspace(input.workspace), @@ -484,7 +328,7 @@ export function isDeepChatTaskContract(value: unknown): value is DeepChatTaskCon const normalized = buildTaskContract({ ...contract.taskDescription, workspace: contract.taskHarness.ceilings.workspace, - acceptance: contract.taskHarness.acceptance, + handoffFormat: contract.taskHarness.acceptance, creationReason: contract.taskConfig.creationReason, predecessorEvaluationRef: contract.taskConfig.predecessorEvaluationRef, maxToolEffect: contract.taskHarness.ceilings.maxToolEffect, diff --git a/src/main/tape/domain/taskEvaluation.ts b/src/main/tape/domain/taskEvaluation.ts index e0ed0b5ec..557cb67a7 100644 --- a/src/main/tape/domain/taskEvaluation.ts +++ b/src/main/tape/domain/taskEvaluation.ts @@ -1,7 +1,5 @@ import { Buffer } from 'node:buffer' import { createHash } from 'node:crypto' -import Ajv, { type AnySchema, type ErrorObject } from 'ajv' -import safeRegex from 'safe-regex2' import { DEEPCHAT_EVALUATION_REF_SCHEMA_VERSION, DEEPCHAT_TASK_EVALUATION_HASH_VERSION, @@ -20,56 +18,14 @@ import { type DeepChatTaskEvaluationRecord, type DeepChatTaskEvaluationSummary } from '@shared/types/task-contract' -import type { JsonValue } from '@shared/contracts/json' -import { - indexMarkdownLevelTwoSections, - removeEnclosingMarkdownFence -} from '@shared/orchestration/liveDelegationMarkdown' +import { indexMarkdownLevelTwoSections } from '@shared/orchestration/liveDelegationMarkdown' import { canonicalJsonStringifyData, hashJsonData } from './canonicalJson' import { isDeepChatTaskContract } from './taskContract' -const MAX_CANDIDATE_JSON_DEPTH = 64 -const MAX_CANDIDATE_JSON_NODES = 4_096 -const MAX_EVIDENCE_PATH_CHARACTERS = 1_024 -const MAX_EVIDENCE_KEYWORD_CHARACTERS = 128 const SHA_256_PATTERN = /^[0-9a-f]{64}$/u const SUCCESS_REASON_CODES = new Set([ - 'required_sections_present', - 'result_schema_valid' + 'required_sections_present' ]) -const SINGLE_SCHEMA_KEYWORDS = [ - 'additionalItems', - 'additionalProperties', - 'contains', - 'else', - 'if', - 'items', - 'not', - 'propertyNames', - 'then', - 'unevaluatedItems', - 'unevaluatedProperties' -] as const -const ARRAY_SCHEMA_KEYWORDS = ['allOf', 'anyOf', 'oneOf', 'prefixItems'] as const -const MAP_SCHEMA_KEYWORDS = [ - '$defs', - 'definitions', - 'dependencies', - 'dependentSchemas', - 'patternProperties', - 'properties' -] as const - -type ParsedResultSection = - | { state: 'missing' } - | { state: 'invalid' } - | { state: 'too_complex' } - | { state: 'available'; value: unknown } - -type CachedSchemaEvaluation = Pick< - DeepChatTaskEvaluationRecord, - 'outcome' | 'code' | 'instancePath' | 'keyword' -> export interface BuildTaskEvaluationInput { contract: DeepChatTaskContract @@ -131,25 +87,25 @@ export function buildTaskEvaluation(input: BuildTaskEvaluationInput): DeepChatTa records = evaluateRequirements(input.contract, candidateResult) } - const verdict = records.some((record) => record.outcome === 'failed') - ? 'failed' + const formatStatus = records.some((record) => record.outcome === 'invalid') + ? 'invalid' : records.some((record) => record.outcome === 'indeterminate') ? 'indeterminate' - : 'passed' + : 'valid' const reasonCodes = [ - ...new Set(records.filter((record) => record.outcome !== 'passed').map((record) => record.code)) + ...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, - verdict, - disposition: verdict === 'passed' ? 'accepted' : 'parked', + formatStatus, reasonCodes, records, omittedRecordCount: 0 @@ -229,11 +185,11 @@ export function projectTaskEvaluationSummary( throw new TaskEvaluationError('Task evaluation evidence limit is invalid.', 'invalid_input') } const evidenceLimit = Math.min(maxEvidenceRecords, MAX_TASK_EVALUATION_PARENT_EVIDENCE) - const relevant = canonicalEvaluation.records.filter((record) => record.outcome !== 'passed') + const relevant = canonicalEvaluation.records.filter((record) => record.outcome !== 'valid') const evidence = relevant.slice(0, evidenceLimit) return deepFreeze({ - verdict: canonicalEvaluation.verdict, - disposition: canonicalEvaluation.disposition, + evaluationKind: canonicalEvaluation.evaluationKind, + formatStatus: canonicalEvaluation.formatStatus, reasonCodes: [...canonicalEvaluation.reasonCodes], candidate: canonicalEvaluation.candidate, evidence, @@ -248,126 +204,22 @@ function evaluateRequirements( candidateResult: string ): DeepChatTaskEvaluationRecord[] { const sections = indexMarkdownLevelTwoSections(candidateResult) - const parsedSections = new Map() - const schemaEvaluations = new Map() - const ajv = new Ajv({ - allErrors: false, - strict: true, - validateFormats: false, - messages: false - }) return contract.taskHarness.acceptance.map((requirement) => { - if (requirement.kind === 'required_sections') { - const missing = requirement.sections.filter( - (section) => !(sections.get(section.toLowerCase())?.body.trim() ?? '') - ) - return evaluationRecord({ - requirementId: requirement.id, - requirementKind: requirement.kind, - outcome: missing.length === 0 ? 'passed' : 'failed', - code: missing.length === 0 ? 'required_sections_present' : 'required_sections_missing', - section: missing[0] ?? null, - additionalEvidenceCount: Math.max(0, missing.length - 1) - }) - } - - const sectionIdentity = requirement.section.toLowerCase() - let parsedSection = parsedSections.get(sectionIdentity) - if (!parsedSection) { - const section = sections.get(sectionIdentity) - if (!section?.body.trim()) { - parsedSection = { state: 'missing' } - } else { - try { - const value = JSON.parse(removeEnclosingMarkdownFence(section.body)) as unknown - parsedSection = isBoundedCandidateJson(value) - ? { state: 'available', value } - : { state: 'too_complex' } - } catch { - parsedSection = { state: 'invalid' } - } - } - parsedSections.set(sectionIdentity, parsedSection) - } - - if (parsedSection.state === 'missing') { - return evaluationRecord({ - requirementId: requirement.id, - requirementKind: requirement.kind, - outcome: 'failed', - code: 'result_section_missing', - section: requirement.section - }) - } - if (parsedSection.state === 'invalid') { - return evaluationRecord({ - requirementId: requirement.id, - requirementKind: requirement.kind, - outcome: 'failed', - code: 'result_json_invalid', - section: requirement.section - }) - } - if (parsedSection.state === 'too_complex') { - return evaluationRecord({ - requirementId: requirement.id, - requirementKind: requirement.kind, - outcome: 'indeterminate', - code: 'candidate_too_complex', - section: requirement.section - }) - } - - const schemaCacheKey = `${sectionIdentity}\0${hashJsonData(requirement.schema)}` - let schemaEvaluation = schemaEvaluations.get(schemaCacheKey) - if (!schemaEvaluation) { - try { - assertSafeSchemaRegexes(requirement.schema) - const validate = ajv.compile(requirement.schema as AnySchema) - if ('$async' in validate && validate.$async) { - throw new Error('Asynchronous result schema validators are not supported.') - } - const validationResult = validate(parsedSection.value) - if (typeof validationResult !== 'boolean') { - throw new Error('Result schema validator returned a non-boolean value.') - } - schemaEvaluation = validationResult - ? { - outcome: 'passed', - code: 'result_schema_valid', - instancePath: null, - keyword: null - } - : schemaMismatchEvidence(validate.errors?.[0]) - } catch { - schemaEvaluation = { - outcome: 'indeterminate', - code: 'evaluator_error', - instancePath: null, - keyword: null - } - } - schemaEvaluations.set(schemaCacheKey, schemaEvaluation) - } + const missing = requirement.sections.filter( + (section) => !(sections.get(section.toLowerCase())?.body.trim() ?? '') + ) return evaluationRecord({ requirementId: requirement.id, requirementKind: requirement.kind, - section: requirement.section, - ...schemaEvaluation + 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 schemaMismatchEvidence(error: ErrorObject | null | undefined): CachedSchemaEvaluation { - return { - outcome: 'failed', - code: 'result_schema_mismatch', - instancePath: normalizeEvidenceText(error?.instancePath, MAX_EVIDENCE_PATH_CHARACTERS), - keyword: normalizeEvidenceText(error?.keyword, MAX_EVIDENCE_KEYWORD_CHARACTERS) - } -} - function evaluationRecord( input: Partial & Pick @@ -378,8 +230,6 @@ function evaluationRecord( outcome: input.outcome, code: input.code, section: input.section ?? null, - instancePath: input.instancePath ?? null, - keyword: input.keyword ?? null, additionalEvidenceCount: input.additionalEvidenceCount ?? 0 } } @@ -421,7 +271,6 @@ function finalizeEvaluation( } function isCanonicalEvaluation(evaluation: DeepChatTaskEvaluation): boolean { - if ((evaluation.verdict === 'passed') !== (evaluation.disposition === 'accepted')) return false if (evaluation.reasonCodes.some((code) => SUCCESS_REASON_CODES.has(code))) return false if ( canonicalJsonStringifyData(evaluation.reasonCodes) !== @@ -431,9 +280,7 @@ function isCanonicalEvaluation(evaluation: DeepChatTaskEvaluation): boolean { } const recordedReasonCodes = [ ...new Set( - evaluation.records - .filter((record) => record.outcome !== 'passed') - .map((record) => record.code) + evaluation.records.filter((record) => record.outcome !== 'valid').map((record) => record.code) ) ].sort(compareCodePoints) if ( @@ -444,27 +291,23 @@ function isCanonicalEvaluation(evaluation: DeepChatTaskEvaluation): boolean { return false } const reasonOutcomes = evaluation.reasonCodes.map(reasonCodeOutcome) - const expectedVerdict = reasonOutcomes.includes('failed') - ? 'failed' + const expectedFormatStatus = reasonOutcomes.includes('invalid') + ? 'invalid' : reasonOutcomes.includes('indeterminate') ? 'indeterminate' - : 'passed' - if (evaluation.verdict !== expectedVerdict) return false + : 'valid' + if (evaluation.formatStatus !== expectedFormatStatus) return false return evaluation.records.every( (record) => recordMatchesReasonCode(record) && - (record.outcome === 'passed' || evaluation.reasonCodes.includes(record.code)) + (record.outcome === 'valid' || evaluation.reasonCodes.includes(record.code)) ) } function recordMatchesReasonCode(record: DeepChatTaskEvaluationRecord): boolean { const expectedOutcome = reasonCodeOutcome(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' + const requirementCode = record.code.startsWith('required_sections_') return requirementCode ? record.requirementId !== null && record.requirementKind !== null : record.requirementId === null && record.requirementKind === null @@ -473,87 +316,11 @@ function recordMatchesReasonCode(record: DeepChatTaskEvaluationRecord): boolean function reasonCodeOutcome( code: DeepChatTaskEvaluationReasonCode ): DeepChatTaskEvaluationRecord['outcome'] { - if (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' - } + if (SUCCESS_REASON_CODES.has(code)) return 'valid' + if (code === 'required_sections_missing') return 'invalid' return 'indeterminate' } -function isBoundedCandidateJson(value: unknown): boolean { - const state = { nodes: 0 } - const visit = (candidate: unknown, depth: number): boolean => { - state.nodes += 1 - if (depth > MAX_CANDIDATE_JSON_DEPTH || state.nodes > MAX_CANDIDATE_JSON_NODES) return false - if (candidate === null || typeof candidate !== 'object') return true - if (Array.isArray(candidate)) return candidate.every((entry) => visit(entry, depth + 1)) - return Object.values(candidate as Record).every((entry) => - visit(entry, depth + 1) - ) - } - return visit(value, 0) -} - -function assertSafeSchemaRegexes(value: JsonValue): void { - if (typeof value === 'boolean' || !value || typeof value !== 'object' || Array.isArray(value)) { - return - } - const schema = value as Record - if (typeof schema.pattern === 'string' && !safeRegex(schema.pattern)) { - throw new TaskEvaluationError('Result schema contains an unsafe pattern.', 'invalid_input') - } - if ( - schema.patternProperties && - typeof schema.patternProperties === 'object' && - !Array.isArray(schema.patternProperties) - ) { - for (const pattern of Object.keys(schema.patternProperties)) { - if (!safeRegex(pattern)) { - throw new TaskEvaluationError( - 'Result schema contains an unsafe pattern property.', - 'invalid_input' - ) - } - } - } - - for (const keyword of SINGLE_SCHEMA_KEYWORDS) { - visitNestedSchema(schema[keyword]) - } - for (const keyword of ARRAY_SCHEMA_KEYWORDS) { - const nested = schema[keyword] - if (Array.isArray(nested)) { - for (const child of nested) visitNestedSchema(child) - } - } - for (const keyword of MAP_SCHEMA_KEYWORDS) { - const nested = schema[keyword] - if (!nested || typeof nested !== 'object' || Array.isArray(nested)) continue - for (const child of Object.values(nested)) visitNestedSchema(child) - } -} - -function visitNestedSchema(value: JsonValue | undefined): void { - if (Array.isArray(value)) { - for (const child of value) visitNestedSchema(child) - return - } - if (typeof value === 'boolean' || (value && typeof value === 'object')) { - assertSafeSchemaRegexes(value) - } -} - -function normalizeEvidenceText(value: string | undefined, maxCharacters: number): string | null { - if (!value) return null - const sanitized = value.replaceAll('\0', '\uFFFD') - return sanitized.length <= maxCharacters ? sanitized : sanitized.slice(0, maxCharacters) -} - function compareCodePoints(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0 } diff --git a/src/shared/types/task-contract.ts b/src/shared/types/task-contract.ts index e7c38d5ab..63924f335 100644 --- a/src/shared/types/task-contract.ts +++ b/src/shared/types/task-contract.ts @@ -1,17 +1,15 @@ import { z } from 'zod' -import { JsonValueSchema, type JsonValue } from '../contracts/json' export const DEEPCHAT_TASK_CONTRACT_SCHEMA_VERSION = 1 as const export const DEEPCHAT_TASK_CONTRACT_HASH_VERSION = 1 as const export const DEEPCHAT_TASK_CONTRACT_REF_SCHEMA_VERSION = 1 as const export const DEEPCHAT_TASK_EVALUATION_SCHEMA_VERSION = 1 as const export const DEEPCHAT_TASK_EVALUATION_HASH_VERSION = 1 as const -export const DEEPCHAT_TASK_EVALUATOR_VERSION = 'task-contract-v1' as const +export const DEEPCHAT_TASK_EVALUATOR_VERSION = 'handoff-format-v1' as const export const DEEPCHAT_EVALUATION_REF_SCHEMA_VERSION = 1 as const export const MAX_TASK_CONTRACT_BYTES = 128 * 1024 export const MAX_TASK_CONTRACT_REQUIREMENTS = 64 -export const MAX_TASK_CONTRACT_RESULT_SCHEMA_BYTES = 32 * 1024 export const MAX_TASK_CONTRACT_REF_BYTES = 4 * 1024 export const MAX_TASK_EVALUATION_BYTES = 32 * 1024 export const MAX_TASK_EVALUATION_REF_BYTES = 4 * 1024 @@ -22,28 +20,21 @@ export const MAX_TASK_EVALUATION_CANDIDATE_BYTES = 1024 * 1024 export const DEEPCHAT_TASK_EVALUATION_REASON_CODES = [ 'candidate_missing', 'candidate_too_large', - 'candidate_too_complex', 'execution_cancelled', 'execution_interrupted', 'required_sections_present', - 'required_sections_missing', - 'result_schema_valid', - 'result_section_missing', - 'result_json_invalid', - 'result_schema_mismatch', - 'evaluator_error' + 'required_sections_missing' ] as const export type DeepChatTaskEvaluationReasonCode = (typeof DEEPCHAT_TASK_EVALUATION_REASON_CODES)[number] -export type DeepChatTaskEvaluationVerdict = 'passed' | 'failed' | 'indeterminate' -export type DeepChatTaskEvaluationDisposition = 'accepted' | 'parked' +export type DeepChatTaskEvaluationFormatStatus = 'valid' | 'invalid' | 'indeterminate' export type DeepChatTaskEvaluationExecutionStatus = | 'completed' | 'failed' | 'cancelled' | 'interrupted' -export type DeepChatTaskEvaluationOutcome = 'passed' | 'failed' | 'indeterminate' +export type DeepChatTaskEvaluationOutcome = 'valid' | 'invalid' | 'indeterminate' export interface DeepChatTaskContractRef { readonly schemaVersion: typeof DEEPCHAT_TASK_CONTRACT_REF_SCHEMA_VERSION @@ -73,12 +64,10 @@ export type DeepChatTaskEvaluationCandidate = export interface DeepChatTaskEvaluationRecord { readonly requirementId: string | null - readonly requirementKind: 'required_sections' | 'result_schema' | null + readonly requirementKind: 'required_sections' | null readonly outcome: DeepChatTaskEvaluationOutcome readonly code: DeepChatTaskEvaluationReasonCode readonly section: string | null - readonly instancePath: string | null - readonly keyword: string | null readonly additionalEvidenceCount: number } @@ -86,12 +75,12 @@ export interface DeepChatTaskEvaluation { readonly schemaVersion: typeof DEEPCHAT_TASK_EVALUATION_SCHEMA_VERSION readonly hashVersion: typeof DEEPCHAT_TASK_EVALUATION_HASH_VERSION readonly evaluatorVersion: typeof DEEPCHAT_TASK_EVALUATOR_VERSION + readonly evaluationKind: 'handoff_format' readonly turnId: string readonly taskContractHash: string readonly candidate: DeepChatTaskEvaluationCandidate readonly executionStatus: DeepChatTaskEvaluationExecutionStatus - readonly verdict: DeepChatTaskEvaluationVerdict - readonly disposition: DeepChatTaskEvaluationDisposition + readonly formatStatus: DeepChatTaskEvaluationFormatStatus readonly reasonCodes: readonly DeepChatTaskEvaluationReasonCode[] readonly records: readonly DeepChatTaskEvaluationRecord[] readonly omittedRecordCount: number @@ -99,8 +88,8 @@ export interface DeepChatTaskEvaluation { } export interface DeepChatTaskEvaluationSummary { - readonly verdict: DeepChatTaskEvaluationVerdict - readonly disposition: DeepChatTaskEvaluationDisposition + readonly evaluationKind: 'handoff_format' + readonly formatStatus: DeepChatTaskEvaluationFormatStatus readonly reasonCodes: readonly DeepChatTaskEvaluationReasonCode[] readonly candidate: DeepChatTaskEvaluationCandidate readonly evidence: readonly DeepChatTaskEvaluationRecord[] @@ -141,26 +130,17 @@ export type DeepChatTaskWorkspaceCeiling = | { readonly kind: 'path'; readonly path: string } | { readonly kind: 'runtime_default' } -export interface DeepChatRequiredSectionsAcceptance { +export interface DeepChatRequiredSectionsHandoffFormat { readonly id: string readonly kind: 'required_sections' readonly level: 2 readonly sections: readonly string[] } -export interface DeepChatResultSchemaAcceptance { - readonly id: string - readonly kind: 'result_schema' - readonly section: string - readonly schema: JsonValue -} - -export type DeepChatTaskAcceptanceRequirement = - | DeepChatRequiredSectionsAcceptance - | DeepChatResultSchemaAcceptance +export type DeepChatHandoffFormatRequirement = DeepChatRequiredSectionsHandoffFormat export interface DeepChatTaskHarness { - readonly acceptance: readonly DeepChatTaskAcceptanceRequirement[] + readonly acceptance: readonly DeepChatHandoffFormatRequirement[] readonly ceilings: { readonly maxToolEffect: 'read' | 'write' readonly workspace: DeepChatTaskWorkspaceCeiling @@ -222,12 +202,10 @@ export const DeepChatTaskEvaluationReasonCodeSchema = z.enum(DEEPCHAT_TASK_EVALU export const DeepChatTaskEvaluationRecordSchema = z .object({ requirementId: StoredIdSchema.nullable(), - requirementKind: z.enum(['required_sections', 'result_schema']).nullable(), - outcome: z.enum(['passed', 'failed', 'indeterminate']), + requirementKind: z.literal('required_sections').nullable(), + outcome: z.enum(['valid', 'invalid', 'indeterminate']), code: DeepChatTaskEvaluationReasonCodeSchema, 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() @@ -237,12 +215,12 @@ export const DeepChatTaskEvaluationProjectionSchema: z.ZodType { - if ((evaluation.verdict === 'passed') !== (evaluation.disposition === 'accepted')) { - context.addIssue({ - code: 'custom', - path: ['disposition'], - message: 'Only a passed evaluation may be accepted' - }) - } - }) export const DeepChatTaskEvaluationSummarySchema: z.ZodType = z .object({ - verdict: z.enum(['passed', 'failed', 'indeterminate']), - disposition: z.enum(['accepted', 'parked']), + evaluationKind: z.literal('handoff_format'), + formatStatus: z.enum(['valid', 'invalid', 'indeterminate']), reasonCodes: z .array(DeepChatTaskEvaluationReasonCodeSchema) .max(DEEPCHAT_TASK_EVALUATION_REASON_CODES.length), @@ -274,39 +243,20 @@ export const DeepChatTaskEvaluationSummarySchema: z.ZodType { - if ((evaluation.verdict === 'passed') !== (evaluation.disposition === 'accepted')) { - context.addIssue({ - code: 'custom', - path: ['disposition'], - message: 'Only a passed evaluation may be accepted' - }) - } - }) 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 DeepChatTaskAcceptanceRequirementSchema = z.discriminatedUnion('kind', [ - 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(), - z - .object({ - id: StoredIdSchema, - kind: z.literal('result_schema'), - section: z.string().trim().min(1).max(256), - schema: JsonValueSchema - }) - .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. @@ -350,7 +300,7 @@ export const DeepChatTaskContractProjectionSchema: z.ZodType { ) }) - 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 ) @@ -4042,8 +4042,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: 5, - hashVersion: 3, + schemaVersion: 4, taskType: 'chat', policy: 'cache_aware_context_v1', policyVersion: 1, @@ -4053,34 +4052,43 @@ describe('DeepChatAgentHarness', () => { } }) expect(manifests[1]).toMatchObject({ - schemaVersion: 5, - hashVersion: 3, + 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.map((manifest: any) => manifest.executionContract.request)).toEqual([ - expect.objectContaining({ - sessionId: 's1', - messageId: callArgs.run.messageId, - runId: callArgs.run.runId, - requestSeq: 1 - }), - expect.objectContaining({ - sessionId: 's1', - messageId: callArgs.run.messageId, - runId: callArgs.run.runId, - requestSeq: 2 - }) - ]) - expect(manifests[0].executionContract.provenance.promptHash).toBe( - manifests[0].hashes.promptHash - ) - expect(manifests[1].executionContract.provenance.providerVisibleToolDefinitionsHash).toBe( - manifests[1].hashes.toolDefinitionsHash + 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 () => { @@ -4095,7 +4103,7 @@ describe('DeepChatAgentHarness', () => { title: 'Review provider Views', prompt: 'Keep each View attached to the active task.', workspace: { kind: 'runtime_default' }, - acceptance: [], + handoffFormat: [], maxToolEffect: 'read', maxSubagentDepth: 0 }) @@ -4176,6 +4184,7 @@ describe('DeepChatAgentHarness', () => { .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([ @@ -4240,9 +4249,7 @@ describe('DeepChatAgentHarness', () => { .filter((row: any) => row.kind === 'event' && row.name === 'view/assembled') expect(viewManifests).toEqual([]) expect(loggerWarnMock).toHaveBeenCalledWith( - expect.stringContaining( - 'ExecutionContract disabled for request 1 because durable ViewManifest persistence could not be confirmed' - ) + expect.stringContaining('Failed to persist tape view manifest: manifest write failed') ) }) @@ -5827,6 +5834,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 () => { diff --git a/test/main/orchestration/liveDelegationRepository.test.ts b/test/main/orchestration/liveDelegationRepository.test.ts index e0f5c71e5..9debf5d22 100644 --- a/test/main/orchestration/liveDelegationRepository.test.ts +++ b/test/main/orchestration/liveDelegationRepository.test.ts @@ -113,7 +113,7 @@ describeIfSqlite('LiveDelegationRepository', () => { }) } - function completeAcceptedAnswer(): string { + function completeFormattedAnswer(): string { return [ '## Handoff', 'Use the reviewed conclusion.', @@ -953,7 +953,7 @@ describeIfSqlite('LiveDelegationRepository', () => { turnId: created.turn.id, status: 'completed', summary: 'Use the reviewed conclusion.', - candidateResult: completeAcceptedAnswer(), + candidateResult: completeFormattedAnswer(), now: 120 }) const event = repository.listEvents('parent')[0]! @@ -965,7 +965,11 @@ describeIfSqlite('LiveDelegationRepository', () => { expect(settled.delegation.status).toBe('idle') expect(settled.turn).toMatchObject({ status: 'completed', - evaluation: { verdict: 'passed', disposition: 'accepted', executionStatus: '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) @@ -1003,7 +1007,7 @@ describeIfSqlite('LiveDelegationRepository', () => { failingRepository.finishTurn({ turnId: created.turn.id, status: 'completed', - candidateResult: completeAcceptedAnswer(), + candidateResult: completeFormattedAnswer(), now: 120 }) ).toThrow('terminal projection failed') @@ -1026,7 +1030,7 @@ describeIfSqlite('LiveDelegationRepository', () => { const settled = repository.finishTurn({ turnId: created.turn.id, status: 'completed', - candidateResult: completeAcceptedAnswer(), + candidateResult: completeFormattedAnswer(), now: 120 }) const evaluationRef = settled.turn.evaluationRef! @@ -1061,7 +1065,7 @@ describeIfSqlite('LiveDelegationRepository', () => { repository.finishTurn({ turnId: created.turn.id, status: 'completed', - candidateResult: completeAcceptedAnswer(), + candidateResult: completeFormattedAnswer(), now: 130 }) ).toThrow('has no Task evaluation') @@ -1073,7 +1077,7 @@ describeIfSqlite('LiveDelegationRepository', () => { const settled = repository.finishTurn({ turnId: created.turn.id, status: 'completed', - candidateResult: completeAcceptedAnswer(), + candidateResult: completeFormattedAnswer(), now: 120 }) diff --git a/test/main/orchestration/liveDelegationService.test.ts b/test/main/orchestration/liveDelegationService.test.ts index 413e05841..a4321dab3 100644 --- a/test/main/orchestration/liveDelegationService.test.ts +++ b/test/main/orchestration/liveDelegationService.test.ts @@ -146,8 +146,8 @@ describeIfSqlite('LiveDelegationService', () => { contentPreview: '## Handoff\nThe boundary is sound.�', contentTruncated: false, evaluation: expect.objectContaining({ - verdict: 'failed', - disposition: 'parked', + evaluationKind: 'handoff_format', + formatStatus: 'invalid', reasonCodes: ['required_sections_missing'] }) }) @@ -156,7 +156,7 @@ describeIfSqlite('LiveDelegationService', () => { expect(repository.require(delegationId).status).toBe('idle') expect(repository.requireTurn(detail.turns[0]!.id)).toMatchObject({ status: 'completed', - evaluation: { verdict: 'failed', disposition: 'parked' } + evaluation: { evaluationKind: 'handoff_format', formatStatus: 'invalid' } }) expect(harness.sessions.linkSubagentTape).toHaveBeenCalledWith( expect.objectContaining({ @@ -168,23 +168,23 @@ describeIfSqlite('LiveDelegationService', () => { ) }) - it('surfaces one accepted evaluation through wait, inspect, and read_result', async () => { + it('surfaces one valid Handoff format evaluation through wait, inspect, and read_result', async () => { const detail = await service.spawn('parent', { slotId: 'reviewer', - title: 'Review accepted result', + 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 = completeAcceptedAnswer() + 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({ - verdict: 'passed', - disposition: 'accepted', + evaluationKind: 'handoff_format', + formatStatus: 'valid', reasonCodes: [], evidence: [] }) @@ -595,7 +595,7 @@ describeIfSqlite('LiveDelegationService', () => { expect(recovered.events[0]).toMatchObject({ relatedTurnId: turn.id, kind: 'turn_completed', - evaluation: { verdict: 'failed', disposition: 'parked' } + evaluation: { evaluationKind: 'handoff_format', formatStatus: 'invalid' } }) expect(repository.require(detail.delegation.id).status).toBe('idle') }) @@ -701,7 +701,7 @@ describeIfSqlite('LiveDelegationService', () => { expect(repository.listTurns(detail.delegation.id, 1)[0]).toMatchObject({ status: 'completed', resultSummary: '## Result\nUse this conclusion.', - evaluation: { verdict: 'failed', disposition: 'parked' } + evaluation: { evaluationKind: 'handoff_format', formatStatus: 'invalid' } }) }) @@ -1872,8 +1872,8 @@ describeIfSqlite('LiveDelegationService', () => { resultRef: null, error: 'Child session completed without a final answer.', evaluation: { - verdict: 'indeterminate', - disposition: 'parked', + evaluationKind: 'handoff_format', + formatStatus: 'indeterminate', reasonCodes: ['candidate_missing'] } }) @@ -1886,8 +1886,8 @@ describeIfSqlite('LiveDelegationService', () => { kind: 'turn_failed', contentPreview: 'Child session completed without a final answer.', evaluation: expect.objectContaining({ - verdict: 'indeterminate', - disposition: 'parked', + evaluationKind: 'handoff_format', + formatStatus: 'indeterminate', reasonCodes: ['candidate_missing'] }) }) @@ -1895,7 +1895,7 @@ describeIfSqlite('LiveDelegationService', () => { }) }) - 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', @@ -2411,7 +2411,7 @@ describeIfSqlite('LiveDelegationService', () => { }) }) -function completeAcceptedAnswer(): string { +function completeFormattedAnswer(): string { return [ '## Handoff', 'Use the reviewed conclusion.', diff --git a/test/main/tape/executionContract.test.ts b/test/main/tape/executionContract.test.ts index 5bd03dc45..b3cd3ec4c 100644 --- a/test/main/tape/executionContract.test.ts +++ b/test/main/tape/executionContract.test.ts @@ -160,7 +160,7 @@ function buildTaskContext( title: 'Review boundaries', prompt: 'Inspect the contract boundary.', workspace: { kind: 'path', path: overrides.workspace ?? path.resolve('task-workspace') }, - acceptance: [], + handoffFormat: [], maxToolEffect: overrides.maxToolEffect ?? 'write', maxSubagentDepth: overrides.maxSubagentDepth ?? 1 }) diff --git a/test/main/tape/taskContract.test.ts b/test/main/tape/taskContract.test.ts index 4f2359e64..ab63af70e 100644 --- a/test/main/tape/taskContract.test.ts +++ b/test/main/tape/taskContract.test.ts @@ -2,9 +2,8 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' import { MAX_TASK_CONTRACT_REQUIREMENTS, - type DeepChatTaskAcceptanceRequirement + type DeepChatHandoffFormatRequirement } from '@shared/types/task-contract' -import type { JsonValue } from '@shared/contracts/json' import { TaskContractError, buildTaskContract, @@ -30,7 +29,7 @@ function buildInput(overrides: Partial = {}): BuildTaskC title: 'Review boundaries', prompt: 'Inspect the contract boundary.', workspace: { kind: 'path', path: TEST_WORKSPACE_PATH }, - acceptance: [ + handoffFormat: [ { id: 'sections', kind: 'required_sections', @@ -38,14 +37,10 @@ function buildInput(overrides: Partial = {}): BuildTaskC sections: ['Validation', 'Handoff'] }, { - id: 'result', - kind: 'result_schema', - section: 'Result', - schema: { - required: ['decision'], - properties: { decision: { type: 'string' } }, - type: 'object' - } + id: 'details', + kind: 'required_sections', + level: 2, + sections: ['Evidence', 'Result'] } ], predecessorEvaluationRef: null, @@ -60,16 +55,12 @@ describe('TaskContract domain', () => { const first = buildTaskContract(buildInput()) const second = buildTaskContract( buildInput({ - acceptance: [ + handoffFormat: [ { - id: 'result', - kind: 'result_schema', - section: 'Result', - schema: { - type: 'object', - properties: { decision: { type: 'string' } }, - required: ['decision'] - } + id: 'details', + kind: 'required_sections', + level: 2, + sections: ['Result', 'Evidence'] }, { id: 'sections', @@ -84,7 +75,7 @@ describe('TaskContract domain', () => { expect(first).toEqual(second) expect(first.contractHash).toMatch(/^[0-9a-f]{64}$/u) expect(first.taskHarness.acceptance.map((requirement) => requirement.id)).toEqual([ - 'result', + 'details', 'sections' ]) expect(first.taskHarness.ceilings.workspace).toEqual({ @@ -96,6 +87,76 @@ describe('TaskContract domain', () => { 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 = { @@ -148,11 +209,11 @@ describe('TaskContract domain', () => { ).toThrow(/must belong to the parent Session/u) }) - it('rejects duplicate sections, asynchronous schemas, and bounded-input overflow', () => { + it('rejects duplicate sections and bounded-input overflow', () => { expect(() => buildTaskContract( buildInput({ - acceptance: [ + handoffFormat: [ { id: 'sections', kind: 'required_sections', @@ -166,89 +227,9 @@ describe('TaskContract domain', () => { expect(() => buildTaskContract( buildInput({ - acceptance: [ - { - id: 'schema', - kind: 'result_schema', - section: 'Result', - schema: { $ref: 'https://example.invalid/schema.json' } - } - ] - }) - ) - ).toThrow(/must not contain \$ref/u) - expect(() => - buildTaskContract( - buildInput({ - acceptance: [ - { - id: 'async-schema', - kind: 'result_schema', - section: 'Result', - schema: { $async: true, type: 'object' } - } - ] - }) - ) - ).toThrow(/must not contain \$async/u) - for (const key of ['$dynamicRef', '$recursiveRef'] as const) { - expect(() => - buildTaskContract( - buildInput({ - acceptance: [ - { - id: 'dynamic-schema', - kind: 'result_schema', - section: 'Result', - schema: { [key]: '#result' } - } - ] - }) - ) - ).toThrow(`must not contain ${key}`) - expect(() => - buildTaskContract( - buildInput({ - acceptance: [ - { - id: 'nested-dynamic-schema', - kind: 'result_schema', - section: 'Result', - schema: { properties: { result: { [key]: '#result' } } } - } - ] - }) - ) - ).toThrow(`must not contain ${key}`) - } - expect(() => - buildTaskContract( - buildInput({ - acceptance: [ - { - id: 'schema-property-names', - kind: 'result_schema', - section: 'Result', - schema: { - $id: 'https://example.invalid/result.schema.json', - $schema: 'http://json-schema.org/draft-07/schema#', - properties: { - $dynamicRef: { type: 'string' }, - $recursiveRef: { type: 'number' } - }, - type: 'object' - } - } - ] - }) - ) - ).not.toThrow() - expect(() => - buildTaskContract( - buildInput({ - acceptance: Array.from( + handoffFormat: Array.from( { length: MAX_TASK_CONTRACT_REQUIREMENTS + 1 }, - (_, index): DeepChatTaskAcceptanceRequirement => ({ + (_, index): DeepChatHandoffFormatRequirement => ({ id: `section-${index}`, kind: 'required_sections', level: 2, @@ -258,62 +239,6 @@ describe('TaskContract domain', () => { }) ) ).toThrow(/exceeds 64 requirements/u) - expect(() => - buildTaskContract( - buildInput({ - acceptance: [ - { - id: 'oversized-schema', - kind: 'result_schema', - section: 'Result', - schema: { const: 'x'.repeat(32 * 1024) } - } - ] - }) - ) - ).toThrow(/exceeds 32768 UTF-8 bytes/u) - - let deeplyNested: JsonValue = {} - for (let depth = 0; depth < 66; depth += 1) deeplyNested = { allOf: [deeplyNested] } - expect(() => - buildTaskContract( - buildInput({ - acceptance: [ - { - id: 'deep-schema', - kind: 'result_schema', - section: 'Result', - schema: deeplyNested - } - ] - }) - ) - ).toThrow(/structural complexity limit/u) - - let getterRead = false - const accessorSchema = Object.create(null) as Record - Object.defineProperty(accessorSchema, 'type', { - enumerable: true, - get: () => { - getterRead = true - return 'object' - } - }) - expect(() => - buildTaskContract( - buildInput({ - acceptance: [ - { - id: 'accessor-schema', - kind: 'result_schema', - section: 'Result', - schema: accessorSchema - } - ] - }) - ) - ).toThrow(/only data properties/u) - expect(getterRead).toBe(false) }) it('serializes only complete, normalized physical references', () => { diff --git a/test/main/tape/taskContractPersistence.test.ts b/test/main/tape/taskContractPersistence.test.ts index 7e174663f..9fc4737ce 100644 --- a/test/main/tape/taskContractPersistence.test.ts +++ b/test/main/tape/taskContractPersistence.test.ts @@ -41,7 +41,7 @@ function contract(title = 'Review boundaries') { title, prompt: 'Inspect the contract boundary.', workspace: { kind: 'runtime_default' }, - acceptance: [ + handoffFormat: [ { id: 'sections', kind: 'required_sections', diff --git a/test/main/tape/taskEvaluation.test.ts b/test/main/tape/taskEvaluation.test.ts index 10b4fdca9..cf7a9bf06 100644 --- a/test/main/tape/taskEvaluation.test.ts +++ b/test/main/tape/taskEvaluation.test.ts @@ -1,12 +1,11 @@ import path from 'node:path' -import Ajv from 'ajv' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { DEEPCHAT_TASK_EVALUATION_REASON_CODES, DeepChatTaskEvaluationProjectionSchema, DeepChatTaskEvaluationSummarySchema, MAX_TASK_EVALUATION_CANDIDATE_BYTES, - type DeepChatTaskAcceptanceRequirement, + type DeepChatHandoffFormatRequirement, type DeepChatTaskEvaluationExecutionStatus } from '@shared/types/task-contract' import { hashJsonData } from '@/tape/domain/canonicalJson' @@ -18,28 +17,17 @@ import { serializeTaskEvaluation } from '@/tape/domain/taskEvaluation' -const DEFAULT_ACCEPTANCE: readonly DeepChatTaskAcceptanceRequirement[] = [ +const DEFAULT_HANDOFF_FORMAT: readonly DeepChatHandoffFormatRequirement[] = [ { id: 'sections', kind: 'required_sections', level: 2, sections: ['Handoff', 'Validation'] - }, - { - id: 'result', - kind: 'result_schema', - section: 'Result', - schema: { - type: 'object', - properties: { decision: { type: 'string' } }, - required: ['decision'], - additionalProperties: false - } } ] function createContract( - acceptance: readonly DeepChatTaskAcceptanceRequirement[] = DEFAULT_ACCEPTANCE + handoffFormat: readonly DeepChatHandoffFormatRequirement[] = DEFAULT_HANDOFF_FORMAT ) { return buildTaskContract({ delegationId: 'delegation-1', @@ -52,7 +40,7 @@ function createContract( title: 'Review boundaries', prompt: 'Inspect the contract boundary.', workspace: { kind: 'path', path: path.resolve('project') }, - acceptance, + handoffFormat, predecessorEvaluationRef: null, maxToolEffect: 'write', maxSubagentDepth: 0 @@ -62,34 +50,26 @@ function createContract( function evaluate( candidateResult: string | null, executionStatus: DeepChatTaskEvaluationExecutionStatus = 'completed', - acceptance: readonly DeepChatTaskAcceptanceRequirement[] = DEFAULT_ACCEPTANCE + handoffFormat: readonly DeepChatHandoffFormatRequirement[] = DEFAULT_HANDOFF_FORMAT ) { return buildTaskEvaluation({ - contract: createContract(acceptance), + contract: createContract(handoffFormat), executionStatus, candidateResult }) } describe('Task evaluation domain', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - - it('evaluates required sections and one fenced JSON result as a canonical pass', () => { + 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', - 'Use the reviewed result.', - '## Result', - '```json', - '{"decision":"accept"}', - '```', + 'This claim is untrusted task evidence.', '## Validation', - 'Focused tests passed.' + 'The required section has a body.' ].join('\n') const first = evaluate(candidate) @@ -97,14 +77,14 @@ describe('Task evaluation domain', () => { expect(first).toEqual(second) expect(first).toMatchObject({ - verdict: 'passed', - disposition: 'accepted', + evaluatorVersion: 'handoff-format-v1', + evaluationKind: 'handoff_format', + formatStatus: 'valid', reasonCodes: [], - records: [ - { requirementId: 'result', code: 'result_schema_valid', outcome: 'passed' }, - { requirementId: 'sections', code: 'required_sections_present', outcome: 'passed' } - ] + 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) @@ -118,8 +98,8 @@ describe('Task evaluation domain', () => { } as const const summary = projectTaskEvaluationSummary(first, mutableRef) expect(summary).toMatchObject({ - verdict: 'passed', - disposition: 'accepted', + evaluationKind: 'handoff_format', + formatStatus: 'valid', evidence: [], omittedEvidenceCount: 0 }) @@ -127,33 +107,14 @@ describe('Task evaluation domain', () => { expect(Object.isFrozen(mutableRef)).toBe(false) }) - it('lets a definite requirement failure win over an evaluator failure', () => { - const result = evaluate( - ['## Handoff', 'Review complete.', '## Result', '{"value":"aaaa"}'].join('\n'), - 'completed', - [ - { - id: 'schema', - kind: 'result_schema', - section: 'Result', - schema: { type: 'object', properties: { value: { type: 'string', pattern: '(a+)+$' } } } - }, - { - id: 'sections', - kind: 'required_sections', - level: 2, - sections: ['Handoff', 'Validation'] - } - ] - ) + it('reports every missing section as bounded format evidence', () => { + const result = evaluate(['## Handoff', 'Review complete.'].join('\n'), 'completed') expect(result).toMatchObject({ - verdict: 'failed', - disposition: 'parked', - reasonCodes: ['evaluator_error', 'required_sections_missing'] + formatStatus: 'invalid', + reasonCodes: ['required_sections_missing'] }) expect(result.records).toEqual([ - expect.objectContaining({ requirementId: 'schema', code: 'evaluator_error' }), expect.objectContaining({ requirementId: 'sections', code: 'required_sections_missing', @@ -173,102 +134,37 @@ describe('Task evaluation domain', () => { 1 ) ).toMatchObject({ - evidence: [expect.objectContaining({ requirementId: 'schema' })], - omittedEvidenceCount: 1 - }) - }) - - it.each([ - { - name: 'an asynchronous validator', - validate: Object.assign( - vi.fn(async () => { - throw new Error('must not run') - }), - { $async: true as const } - ), - expectedCalls: 0 - }, - { - name: 'a validator returning a non-boolean value', - validate: vi.fn(() => Promise.resolve(true)), - expectedCalls: 1 - } - ])('records evaluator_error for $name', ({ validate, expectedCalls }) => { - vi.spyOn(Ajv.prototype, 'compile').mockReturnValue(validate as never) - - const result = evaluate('## Result\n{}', 'completed', [ - { id: 'result', kind: 'result_schema', section: 'Result', schema: {} } - ]) - - expect(result).toMatchObject({ - verdict: 'indeterminate', - disposition: 'parked', - reasonCodes: ['evaluator_error'], - records: [{ code: 'evaluator_error', outcome: 'indeterminate' }] + evidence: [expect.objectContaining({ requirementId: 'sections' })], + omittedEvidenceCount: 0 }) - expect(validate).toHaveBeenCalledTimes(expectedCalls) }) - it('keeps a valid contract verdict independent from execution failure', () => { - const result = evaluate( - '## Handoff\nDone.\n## Result\n{"decision":"accept"}\n## Validation\nChecked.', - 'failed' - ) + it('keeps format validity independent from execution failure', () => { + const result = evaluate('## Handoff\nDone.\n## Validation\nChecked.', 'failed') expect(result).toMatchObject({ executionStatus: 'failed', - verdict: 'passed', - disposition: 'accepted', - reasonCodes: [] - }) - }) - - it('does not interpret JSON Schema const data as executable pattern syntax', () => { - const result = evaluate('## Result\n{"metadata":{"pattern":"(a+)+$"}}', 'completed', [ - { - id: 'result', - kind: 'result_schema', - section: 'Result', - schema: { - type: 'object', - properties: { metadata: { const: { pattern: '(a+)+$' } } }, - required: ['metadata'] - } - } - ]) - - expect(result).toMatchObject({ - verdict: 'passed', - disposition: 'accepted', + formatStatus: 'valid', reasonCodes: [] }) }) it.each([ { - name: 'missing result section', - candidate: '## Handoff\nDone.\n## Validation\nChecked.', - code: 'result_section_missing', - keyword: null + name: 'a missing section', + candidate: '## Handoff\nDone.', + section: 'Validation' }, { - name: 'malformed result JSON', - candidate: '## Handoff\nDone.\n## Result\n{nope}\n## Validation\nChecked.', - code: 'result_json_invalid', - keyword: null - }, - { - name: 'schema mismatch', - candidate: '## Handoff\nDone.\n## Result\n{}\n## Validation\nChecked.', - code: 'result_schema_mismatch', - keyword: 'required' + name: 'an empty section', + candidate: '## Handoff\nDone.\n## Validation\n\n', + section: 'Validation' } - ])('parks a completed candidate with $name', ({ candidate, code, keyword }) => { + ])('marks a completed candidate invalid with $name', ({ candidate, section }) => { const result = evaluate(candidate) - expect(result).toMatchObject({ verdict: 'failed', disposition: 'parked' }) - expect(result.records[0]).toMatchObject({ code, keyword }) + expect(result).toMatchObject({ formatStatus: 'invalid' }) + expect(result.records[0]).toMatchObject({ code: 'required_sections_missing', section }) }) it.each([ @@ -282,35 +178,18 @@ describe('Task evaluation domain', () => { } ])('records $code as indeterminate', ({ status, candidate, code }) => { expect(evaluate(candidate, status)).toMatchObject({ - verdict: 'indeterminate', - disposition: 'parked', + formatStatus: 'indeterminate', reasonCodes: [code], records: [{ code, outcome: 'indeterminate' }] }) }) - it('bounds parsed candidate structure before schema validation', () => { - const nested = `${'['.repeat(66)}0${']'.repeat(66)}` - const result = evaluate(`## Result\n${nested}`, 'completed', [ - { id: 'result', kind: 'result_schema', section: 'Result', schema: {} } - ]) - - expect(result).toMatchObject({ - verdict: 'indeterminate', - disposition: 'parked', - reasonCodes: ['candidate_too_complex'] - }) - }) - it('rejects hash-valid projections that violate canonical reason evidence', () => { - const evaluation = evaluate( - '## Handoff\nDone.\n## Result\n{"decision":"accept"}\n## Validation\nChecked.' - ) + const evaluation = evaluate('## Handoff\nDone.\n## Validation\nChecked.') const { evaluationHash: _evaluationHash, ...draft } = evaluation const forgedDraft = { ...draft, - verdict: 'failed' as const, - disposition: 'parked' as const, + formatStatus: 'invalid' as const, reasonCodes: ['candidate_missing' as const] } const forged = { ...forgedDraft, evaluationHash: hashJsonData(forgedDraft) } From 8f5c7c2d199c6b03d5b780f20f0657c2f7f02da2 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 17:45:00 +0800 Subject: [PATCH 33/37] docs(tape): narrow contract lineage semantics --- .../spec.md | 57 ++++++------- .../tape-contract-lineage/plan.md | 23 +++-- .../tape-contract-lineage/spec.md | 84 +++++++++---------- .../tape-contract-lineage/tasks.md | 36 ++++---- docs/architecture/tape-system.md | 49 ++++++----- 5 files changed, 122 insertions(+), 127 deletions(-) diff --git a/docs/architecture/proactive-multi-agent-orchestration/spec.md b/docs/architecture/proactive-multi-agent-orchestration/spec.md index d893620f2..58d306743 100644 --- a/docs/architecture/proactive-multi-agent-orchestration/spec.md +++ b/docs/architecture/proactive-multi-agent-orchestration/spec.md @@ -83,10 +83,10 @@ identity, content hash, byte/token size, and explicit truncation state. `read_re 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`. Verdict, disposition, 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. +`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: @@ -107,7 +107,7 @@ sanitize or reinterpret valid child payload text, which remains untrusted eviden ## Task And Execution Contracts Every new live-delegation turn freezes one immutable `TaskContract` containing task schema, stable -task configuration, task description, and the harness acceptance/ceiling rules. The parent appends +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. @@ -126,26 +126,27 @@ groups: 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 child Views fail closed before provider dispatch when -the manifest or TaskContract binding cannot be persisted. Ordinary interactive chat preserves its -existing fail-open manifest behavior. +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 evaluates the persisted complete child answer against required level-two -Markdown sections and optional bounded local JSON Schema requirements. It keeps three independent -axes: +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 -verdict = passed | failed | indeterminate -disposition = accepted | parked +evaluationKind = handoff_format +formatStatus = valid | invalid | indeterminate ``` -Only `passed` is accepted. A generated answer that fails acceptance remains `completed`, is parked, -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. +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 a verdict. +without an evaluation. ## Consent And Permissions @@ -231,7 +232,7 @@ 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 automatically reinterpret parked output as accepted. Cross-Session +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 @@ -264,8 +265,8 @@ code must never lower the latest schema version below a version already observed 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 terminal verdict without -querying Tape. Existing rows remain valid with null contract/evaluation fields; historical terminal +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 @@ -296,9 +297,9 @@ Workflow panels, saved Workflow commands, launch approvals, and `/workflow` are - Existing released Sessions default to `explicit`; intent is never inferred from disabled tools. - Existing feature-branch databases migrate forward through version 66; pre-contract rows remain readable with nullable projections. -- ViewManifest schemas 1-4 remain readable. New schema-v5 manifests bind one ExecutionContract to - the exact request, and contract-bearing child dispatch fails closed on a missing or conflicting - binding. +- 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. @@ -334,11 +335,11 @@ Workflow panels, saved Workflow commands, launch approvals, and `/workflow` are 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 View carries one schema-v5 ExecutionContract and enforces the typed meet - of its frozen ceilings with current runtime authority. +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, verdict, and disposition remain - independent. + 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. diff --git a/docs/architecture/tape-contract-lineage/plan.md b/docs/architecture/tape-contract-lineage/plan.md index 746f6809c..b4845c9f8 100644 --- a/docs/architecture/tape-contract-lineage/plan.md +++ b/docs/architecture/tape-contract-lineage/plan.md @@ -3,10 +3,8 @@ ## 1. Establish Canonical Contract Domains - Add shared, bounded schemas for prompt-section provenance, TaskContract, TaskContract references, - ExecutionContract, evaluation, verdict, and disposition. + ExecutionContract, and Handoff format evaluation. - Add main-process canonical builders and versioned hashes using the existing canonical JSON helper. -- Add Ajv as a direct runtime dependency for bounded local result-schema validation; disable remote - loading, `$ref`, `$async`, custom executable formats, and unbounded error collection. - 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 @@ -29,12 +27,12 @@ - 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 normal DeepChat ViewManifest writes to schema 5 and the next hash version while preserving - v1-v4 readers plus ACP and explicit ordinary-interactive schema-v4 fallback writes. +- 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 interactive writes fail-open with explicit degradation and make contract-bearing child View - writes fail closed before provider request admission. +- 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 @@ -66,7 +64,7 @@ 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 - acceptance, and optional predecessor evaluation. + 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 @@ -90,13 +88,13 @@ - Parse the persisted complete child answer, not its bounded Handoff projection. - Implement required-section evaluation with the existing fence-aware Markdown rules. -- Validate bounded local JSON Schema without remote references or code execution. -- Create `passed`, `failed`, or `indeterminate` evaluation with bounded evidence and reason codes. +- 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`, `verdict=failed`, `disposition=parked`, and - `delegationStatus=idle` for contract-invalid but successfully generated results. +- Preserve `executionStatus=completed`, `formatStatus=invalid`, and `delegationStatus=idle` for + format-invalid but successfully generated results. ## 9. Surface Evaluation To The Parent @@ -115,4 +113,3 @@ - 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. -- Do not push the branch. diff --git a/docs/architecture/tape-contract-lineage/spec.md b/docs/architecture/tape-contract-lineage/spec.md index 7cec145cf..36ac9093a 100644 --- a/docs/architecture/tape-contract-lineage/spec.md +++ b/docs/architecture/tape-contract-lineage/spec.md @@ -28,18 +28,18 @@ persistence. Recovery may rebuild runtime projections from persisted facts. 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 acceptance contract applied to a delegated result. +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 valid child result from an execution that -completed but failed its task contract. It also makes historical provider requests difficult to -explain and compare. +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 DeepChat-owned provider View. + 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. @@ -47,7 +47,7 @@ explain and compare. 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 verdict and disposition through existing parent-facing orchestration operations. +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 @@ -70,38 +70,33 @@ 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`: acceptance requirements and host-enforced behavioral ceilings. +- `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 acceptance requirements, so a recovered contract remains +uses `legacy_recovery` with no retroactive Handoff format requirements, so a recovered contract remains distinguishable without adding a second runtime flag. -V1 supports two acceptance requirement kinds: - -- `required_sections`: required level-two Markdown section names; -- `result_schema`: a bounded JSON Schema applied to the body of a named Markdown section. - -`result_schema` accepts one JSON value after removing at most one enclosing Markdown code fence. -It uses synchronous Ajv strict validation with remote loading disabled, rejects every `$ref` and -nested `$async`, and stops after a bounded error set. It does not execute custom formats or -schema-provided code. -Ajv and regex-safety dependencies are pinned. Any semantic change to those validators, Markdown -section extraction, evidence normalization, or verdict reduction must bump `evaluatorVersion`. - -Requirements compose conjunctively. A missing required section or schema mismatch is `failed`. -Missing candidate data, cancellation, interruption, unavailable evidence, or evaluator failure is -`indeterminate`. +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 acceptance requirements; -- one embedded result schema: 32 KiB; +- 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. @@ -187,18 +182,18 @@ to their existing host contracts. ### Evaluation And Settlement -Execution status, contract verdict, and consumer disposition are independent axes: +Execution status and Handoff format status are independent axes: ```text executionStatus = completed | failed | cancelled | interrupted -verdict = passed | failed | indeterminate -disposition = accepted | parked +evaluationKind = handoff_format +formatStatus = valid | invalid | indeterminate ``` -`accepted` is valid only with `passed`. `failed` and `indeterminate` are `parked`. Parked is an -evaluation disposition, not a new persisted delegation or turn status. A successfully generated -but contract-invalid answer remains `executionStatus=completed` and leaves the delegation `idle`, -so the parent may explicitly start a new follow-up turn. +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: @@ -220,8 +215,8 @@ content is corruption, not a successful retry. The Tape fact is historical evidence, not a model-facing delivery mechanism. Existing `wait`, `inspect`, and `read_result` projections expose: -- `verdict`; -- `disposition`; +- `evaluationKind`; +- `formatStatus`; - bounded reason codes and evidence references; - `evaluationRef`. @@ -247,9 +242,9 @@ 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. -- Normal DeepChat contract-bearing writes use ViewManifest schema 5 and manifest hash version 3. - ACP compatibility and an explicitly degraded ordinary interactive request may still write schema - 4; contract-bearing child requests never take that fallback. +- 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. - A legacy active turn without a TaskContract must freeze a compatibility contract before it may @@ -272,16 +267,15 @@ This table describes write disciplines, not a count of all Tape event families. - 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 contract passes. -- JSON Schema evaluation is bounded by accepted schema size, candidate size, and evaluator work; - remote references and executable formats are forbidden. +- 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 normal DeepChat View has a schema-v5 manifest containing a - verifiable ExecutionContract built from the exact request inputs; ACP compatibility and bounded - ordinary-interactive degradation retain their documented schema-v4 behavior. +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. @@ -291,8 +285,8 @@ This table describes write disciplines, not a count of all Tape event families. 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. Contract failure does not rewrite successful provider execution as an execution failure. -9. Parent-facing orchestration results expose verdict, disposition, and evaluation identity. +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. diff --git a/docs/architecture/tape-contract-lineage/tasks.md b/docs/architecture/tape-contract-lineage/tasks.md index ae8134e66..9ef86ef2a 100644 --- a/docs/architecture/tape-contract-lineage/tasks.md +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -18,8 +18,10 @@ ## P0: ViewManifest V5 And Enforcement -- [x] Embed ExecutionContract in ViewManifest schema 5 and preserve v1-v4 reads. -- [x] Keep interactive manifest persistence fail-open and require contract-bearing child manifests. +- [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. @@ -46,13 +48,13 @@ ## P1: Evaluation And Parent Visibility -- [x] Implement bounded required-section and result-schema evaluation. +- [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] Preserve orthogonal execution status, verdict, and disposition semantics. -- [x] Cover no answer, malformed result, cancellation, interruption, evaluator failure, and - settlement retry/recovery. +- [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 @@ -61,7 +63,7 @@ - [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] Confirm the branch has not been pushed. +- [x] Keep the PR branch free of unrelated baseline test repairs. ## Validation Record @@ -69,13 +71,15 @@ Completed on 2026-08-09: | Gate | Result | | --- | --- | -| `pnpm run format` and `pnpm run format:check` | Passed | +| `pnpm run format:check` | Passed across 2,711 files | | `pnpm run i18n` | Passed with no missing or invalid translations | -| `pnpm run lint` | Passed | -| `pnpm run typecheck:node` and `pnpm run typecheck:web` | Passed | -| Focused prompt, View, Tape, dispatch, orchestration, and integration suites | Passed | -| `pnpm run test:main` | 570 files and 6,933 tests passed; 1 file and 5 tests skipped | - -The three previously recorded baseline failures were repaired before the final validation run. The -final severity-ordered review found no unresolved merge blockers. The branch has no upstream and -was not pushed. +| `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` | Three unrelated failures already present at the `dev` merge base | + +The `test:main` baseline consists of 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 14de86a00..956ee5a03 100644 --- a/docs/architecture/tape-system.md +++ b/docs/architecture/tape-system.md @@ -167,17 +167,16 @@ Tape entries + anchors + linked child head ``` `ViewManifest` 记录 policy、version、context builder、selection reason、included/excluded entry、 -synthetic contribution、anchor、token budget provenance 和该请求的 `ExecutionContract`。正常 chat、 -resume、tool loop 和 context pressure recovery 都必须记录自己的 view;不得依赖无法复现的隐式 context -builder 状态。summary、reconstruction 和 Memory 生成的 synthetic user contribution 只记录 source -entry ID 与 content hash,不在 manifest 中复制原文。 - -正常 DeepChat 新写入默认使用 `cache_aware_context_v1` / `cache-aware-v1`、schema version 5 和 -manifest hash version 3。schema version 1-4 与其历史 hash 语义继续兼容读取且不得原地重写;ACP -compatibility 与 ExecutionContract 构造失败后显式降级的普通 interactive request 仍可写 schema version -4,contract-bearing child 不得走该 fallback。`legacy_context_v1` 与 `legacy-v1` builder 同样保留兼容 -路径。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`,包含: @@ -219,11 +218,11 @@ physicalAttempt 最大的 trace,再按 createdAt 和 ID 稳定排序;attempt ## Contract lineage 与评价 每个 live-delegation turn 在 parent Tape 冻结一个 `TaskContract`,内容由 `taskSchema`、`taskConfig`、 -`taskDescription` 和 `taskHarness` 四部分组成。v1 harness 支持 required Markdown level-two sections 与 -指定 section 的 bounded synchronous local JSON Schema;所有层级的 `$ref` 与 `$async` 均被拒绝,不支持 -自动 repair、retry 或 override。follow-up 创建新 turn 和新 TaskContract,并引用同一 parent Session 的 -前一次 `evaluationRef`,不是复用旧 turn 或重放旧 Run;跨 Session predecessor ref 不能通过 canonical -contract 校验。 +`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,并以 @@ -232,20 +231,20 @@ child 收到的最小任务状态,不复制 parent transcript,也不要求 c child Tape reset 后,runtime 可用 row 中 hash-verified canonical value 在新 incarnation append `projection_recovery` fact 并替换 projection ref;完成前不得跨下一个 strict boundary。 -每个 contract-bearing terminal settlement 必须生成一个 `contract/evaluated`。执行状态、合同裁决和消费 -决策是三个正交维度: +每个 contract-bearing terminal settlement 必须生成一个 `contract/evaluated`。执行状态和 Handoff 格式 +状态相互独立: ```text executionStatus = completed | failed | cancelled | interrupted -verdict = passed | failed | indeterminate -disposition = accepted | parked +evaluationKind = handoff_format +formatStatus = valid | invalid | indeterminate ``` -只有 `passed` 可 `accepted`;`failed` 和 `indeterminate` 均 `parked`。`parked` 是 evaluation -disposition,不是 delegation/turn status。一个生成成功但验收失败的 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。 +`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,不得静默 From 9de387b4df91bc5e8b5c48658241667ea9543f5c Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 17:50:28 +0800 Subject: [PATCH 34/37] chore(main): restore unrelated dev baselines --- .../tape-contract-lineage/tasks.md | 11 +-- .../sessionDataMigrations.ts | 10 ++- .../sessionDataMigrations.sqlite.test.ts | 19 +---- .../sessionDataMigrations.test.ts | 13 ++-- test/main/data/mainDatabase.test.ts | 78 +++++++------------ test/main/scheduler/schedulerService.test.ts | 6 +- 6 files changed, 55 insertions(+), 82 deletions(-) diff --git a/docs/architecture/tape-contract-lineage/tasks.md b/docs/architecture/tape-contract-lineage/tasks.md index 9ef86ef2a..4a903cbc2 100644 --- a/docs/architecture/tape-contract-lineage/tasks.md +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -77,9 +77,10 @@ Completed on 2026-08-09: | `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` | Three unrelated failures already present at the `dev` merge base | +| `pnpm run test:main` | Eleven unrelated failures reproduced from the `dev` baseline files | -The `test:main` baseline consists of 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. +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/src/main/app/startupMigrations/sessionDataMigrations.ts b/src/main/app/startupMigrations/sessionDataMigrations.ts index 436c4ced0..d79f36d03 100644 --- a/src/main/app/startupMigrations/sessionDataMigrations.ts +++ b/src/main/app/startupMigrations/sessionDataMigrations.ts @@ -441,12 +441,16 @@ export async function runDisabledAgentToolCapabilityCleanupMigration( ORDER BY id ASC LIMIT ?` ) - const updateSessionDisabledTools = db.prepare<[string, string]>( - 'UPDATE new_sessions SET disabled_agent_tools = ?, revision = revision + 1 WHERE id = ?' + const updateSessionDisabledTools = db.prepare<[string, number, string]>( + 'UPDATE new_sessions SET disabled_agent_tools = ?, updated_at = ?, revision = revision + 1 WHERE id = ?' ) const persistSessionDisabledTools = db.transaction( (sessionId: string, disabledAgentTools: string[]): boolean => { - const result = updateSessionDisabledTools.run(JSON.stringify(disabledAgentTools), sessionId) + const result = updateSessionDisabledTools.run( + JSON.stringify(disabledAgentTools), + Date.now(), + sessionId + ) if (result.changes === 0) return false sqlitePresenter.newSessionDisabledAgentToolsTable.replaceForSession( sessionId, diff --git a/test/main/app/startupMigrations/sessionDataMigrations.sqlite.test.ts b/test/main/app/startupMigrations/sessionDataMigrations.sqlite.test.ts index 00cfd393f..a19c60ca0 100644 --- a/test/main/app/startupMigrations/sessionDataMigrations.sqlite.test.ts +++ b/test/main/app/startupMigrations/sessionDataMigrations.sqlite.test.ts @@ -9,9 +9,6 @@ const sqliteModule = await import('better-sqlite3-multiple-ciphers').catch(() => const sessionsModule = sqliteModule ? await import('@/session/data/tables/newSessions').catch(() => null) : null -const activeSkillsModule = sqliteModule - ? await import('@/session/data/tables/newSessionActiveSkills').catch(() => null) - : null const disabledToolsModule = sqliteModule ? await import('@/session/data/tables/newSessionDisabledAgentTools').catch(() => null) : null @@ -21,12 +18,10 @@ const environmentsModule = sqliteModule const Database = sqliteModule?.default const NewSessionsTable = sessionsModule?.NewSessionsTable -const NewSessionActiveSkillsTable = activeSkillsModule?.NewSessionActiveSkillsTable const NewSessionDisabledAgentToolsTable = disabledToolsModule?.NewSessionDisabledAgentToolsTable const NewEnvironmentsTable = environmentsModule?.NewEnvironmentsTable const DatabaseCtor = Database! const NewSessionsTableCtor = NewSessionsTable! -const NewSessionActiveSkillsTableCtor = NewSessionActiveSkillsTable! const NewSessionDisabledAgentToolsTableCtor = NewSessionDisabledAgentToolsTable! const NewEnvironmentsTableCtor = NewEnvironmentsTable! @@ -42,11 +37,7 @@ if (Database) { } const describeIfSqlite = - sqliteAvailable && - NewSessionsTable && - NewSessionActiveSkillsTable && - NewSessionDisabledAgentToolsTable && - NewEnvironmentsTable + sqliteAvailable && NewSessionsTable && NewSessionDisabledAgentToolsTable && NewEnvironmentsTable ? describe : describe.skip @@ -63,11 +54,9 @@ describeIfSqlite('disabled Agent tool capability cleanup SQLite integration', () `) const sessions = new NewSessionsTableCtor(db) - const activeSkills = new NewSessionActiveSkillsTableCtor(db) const disabledTools = new NewSessionDisabledAgentToolsTableCtor(db) const environments = new NewEnvironmentsTableCtor(db) sessions.createTable() - activeSkills.createTable() disabledTools.createTable() environments.createTable() @@ -83,7 +72,6 @@ describeIfSqlite('disabled Agent tool capability cleanup SQLite integration', () }) environments.rebuildFromSessions() const environmentBefore = environments.list() - const olderRevisionBefore = sessions.get('older')!.revision const settings = new Map() const sqlitePresenter = { @@ -112,8 +100,7 @@ describeIfSqlite('disabled Agent tool capability cleanup SQLite integration', () expect(sessions.list().map((row) => row.id)).toEqual(['newer', 'older']) expect(sessions.get('older')).toMatchObject({ disabled_agent_tools: JSON.stringify(['read']), - updated_at: 100, - revision: olderRevisionBefore + 1 + updated_at: 100 }) expect(disabledTools.listBySession('older')).toEqual([ { session_id: 'older', ordinal: 0, tool_name: 'read' } @@ -131,10 +118,8 @@ describeIfSqlite('disabled Agent tool capability cleanup SQLite integration', () const db = new DatabaseCtor(':memory:') try { const sessions = new NewSessionsTableCtor(db) - const activeSkills = new NewSessionActiveSkillsTableCtor(db) const disabledTools = new NewSessionDisabledAgentToolsTableCtor(db) sessions.createTable() - activeSkills.createTable() disabledTools.createTable() const originalDisabledTools = [TAPE_TOOL_NAMES.search, 'read'] diff --git a/test/main/app/startupMigrations/sessionDataMigrations.test.ts b/test/main/app/startupMigrations/sessionDataMigrations.test.ts index 7ef95585a..40f4a7564 100644 --- a/test/main/app/startupMigrations/sessionDataMigrations.test.ts +++ b/test/main/app/startupMigrations/sessionDataMigrations.test.ts @@ -16,10 +16,12 @@ function createFixture() { const sessionRows: Array<{ id: string }> = [] const sessionDisabledTools = new Map() const statements: string[] = [] - const updateSessionDisabledTools = vi.fn((serialized: string, sessionId: string) => ({ - changes: sessionRows.some((row) => row.id === sessionId) ? 1 : 0, - serialized - })) + const updateSessionDisabledTools = vi.fn( + (serialized: string, _updatedAt: number, sessionId: string) => ({ + changes: sessionRows.some((row) => row.id === sessionId) ? 1 : 0, + serialized + }) + ) const replaceSessionDisabledTools = vi.fn((sessionId: string, disabledAgentTools: string[]) => { sessionDisabledTools.set(sessionId, disabledAgentTools) }) @@ -232,6 +234,7 @@ describe('session data migrations', () => { ]) expect(fixture.updateSessionDisabledTools).toHaveBeenCalledWith( JSON.stringify(['cdp_send', 'custom_tool', 'exec']), + expect.any(Number), 'session-1' ) expect(fixture.providerSettings.updateDeepChatAgent).toHaveBeenCalledWith('deepchat', { @@ -257,7 +260,7 @@ describe('session data migrations', () => { sql.startsWith('UPDATE new_sessions SET disabled_agent_tools') ) expect(sessionUpdate).toBe( - 'UPDATE new_sessions SET disabled_agent_tools = ?, revision = revision + 1 WHERE id = ?' + 'UPDATE new_sessions SET disabled_agent_tools = ?, updated_at = ?, revision = revision + 1 WHERE id = ?' ) }) diff --git a/test/main/data/mainDatabase.test.ts b/test/main/data/mainDatabase.test.ts index 3cc263e2d..7bad88b46 100644 --- a/test/main/data/mainDatabase.test.ts +++ b/test/main/data/mainDatabase.test.ts @@ -15,17 +15,9 @@ const sqlitePresenterModule = sqliteModule const schemaCatalogModule = sqliteModule ? await import('../../../src/main/data/schemaCatalog').catch(() => null) : null -const sessionDatabaseModule = sqliteModule - ? await import('../../../src/main/session/data/database').catch(() => null) - : null -const agentDatabaseModule = sqliteModule - ? await import('../../../src/main/agent/data/database').catch(() => null) - : null const Database = sqliteModule?.default const MainDatabase = sqlitePresenterModule?.MainDatabase const getStartupSchemaCatalog = schemaCatalogModule?.getStartupSchemaCatalog -const SessionDatabase = sessionDatabaseModule?.SessionDatabase -const AgentDatabase = agentDatabaseModule?.AgentDatabase const sqliteSkipReason = 'skipped: better-sqlite3-multiple-ciphers is unavailable' const requireNativeSqlite = process.env.DEEPCHAT_REQUIRE_NATIVE_SQLITE === '1' let sqliteAvailable = false @@ -40,12 +32,9 @@ if (Database) { } const DatabaseCtor = Database! const MainDatabaseCtor = MainDatabase! -const SessionDatabaseCtor = SessionDatabase! -const AgentDatabaseCtor = AgentDatabase! -const sqliteHarnessAvailable = - sqliteAvailable && MainDatabase && getStartupSchemaCatalog && SessionDatabase && AgentDatabase +const sqliteHarnessAvailable = sqliteAvailable && MainDatabase && getStartupSchemaCatalog const sqliteHarnessSkipReason = sqliteAvailable - ? 'skipped: MainDatabase test dependencies are unavailable' + ? 'skipped: MainDatabase startup schema catalog is unavailable' : sqliteSkipReason const describeIfSqlite = sqliteHarnessAvailable ? describe @@ -85,7 +74,6 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { updated_at INTEGER NOT NULL ); `) - new SessionDatabaseCtor({ getDatabase: () => db }).deepchatSessionsTable.createTable() db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run( schemaVersion, Date.now() @@ -121,7 +109,7 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { const repairReport = await presenter.repairSchema() expect(repairReport.status).toBe('repaired') - const conversationList = await new SessionDatabaseCtor(presenter).getConversationList(1, 20) + const conversationList = await presenter.getConversationList(1, 20) expect(conversationList.total).toBe(0) expect(conversationList.list).toEqual([]) presenter.close() @@ -235,9 +223,8 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { bootstrapDb.close() const presenter = new MainDatabaseCtor(dbPath) - const sessions = new SessionDatabaseCtor(presenter) expect(presenter.getLatestSchemaVersion()).toBeGreaterThanOrEqual(44) - expect(sessions.newSessionsTable.get('session-1')).toMatchObject({ + expect(presenter.newSessionsTable.get('session-1')).toMatchObject({ title: 'Existing session', project_dir: '/work/app', is_pinned: 1, @@ -246,8 +233,8 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { revision: 0 }) - sessions.newSessionsTable.update('session-1', { title: 'Generated title' }) - expect(sessions.newSessionsTable.get('session-1')).toMatchObject({ + presenter.newSessionsTable.update('session-1', { title: 'Generated title' }) + expect(presenter.newSessionsTable.get('session-1')).toMatchObject({ title: 'Generated title', revision: 1 }) @@ -361,32 +348,30 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { const dbPath = path.join(tempDir, 'agent.db') const presenter = new MainDatabaseCtor(dbPath) - const sessions = new SessionDatabaseCtor(presenter) - const agents = new AgentDatabaseCtor(presenter) vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) - sessions.newSessionsTable.create('session-1', 'kimi-cli', 'Recovered session', null) - sessions.deepchatSessionsTable.create('session-1', 'acp', 'kimi-cli', 'full_access') - await agents.upsertAcpSession('conversation-1', 'kimi-cli', { + presenter.newSessionsTable.create('session-1', 'kimi-cli', 'Recovered session', null) + presenter.deepchatSessionsTable.create('session-1', 'acp', 'kimi-cli', 'full_access') + await presenter.upsertAcpSession('conversation-1', 'kimi-cli', { sessionId: 'acp-session-1', status: 'active' }) vi.setSystemTime(new Date('2026-01-01T00:00:01.000Z')) await expect( - agents.migrateAcpAgentReferences({ + presenter.migrateAcpAgentReferences({ 'kimi-cli': 'kimi' }) ).resolves.toBeUndefined() - expect(sessions.newSessionsTable.get('session-1')).toMatchObject({ + expect(presenter.newSessionsTable.get('session-1')).toMatchObject({ agent_id: 'kimi', revision: 1, updated_at: Date.parse('2026-01-01T00:00:01.000Z') }) - expect(sessions.deepchatSessionsTable.get('session-1')?.model_id).toBe('kimi') - expect(await agents.getAcpSession('conversation-1', 'kimi-cli')).toBeNull() - expect(await agents.getAcpSession('conversation-1', 'kimi')).toMatchObject({ + expect(presenter.deepchatSessionsTable.get('session-1')?.model_id).toBe('kimi') + expect(await presenter.getAcpSession('conversation-1', 'kimi-cli')).toBeNull() + expect(await presenter.getAcpSession('conversation-1', 'kimi')).toMatchObject({ conversationId: 'conversation-1', agentId: 'kimi', sessionId: 'acp-session-1' @@ -414,12 +399,7 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { bootstrapDb.close() const presenter = new MainDatabaseCtor(dbPath) - new SessionDatabaseCtor(presenter).newSessionsTable.create( - 'session-1', - 'agent-1', - 'Recovered session', - null - ) + presenter.newSessionsTable.create('session-1', 'agent-1', 'Recovered session', null) presenter.close() const checkDb = new DatabaseCtor(dbPath) @@ -713,12 +693,7 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { bootstrapDb.close() const presenter = new MainDatabaseCtor(dbPath) - new SessionDatabaseCtor(presenter).deepchatSessionsTable.create( - 'session-1', - 'openai', - 'gpt-4o', - 'full_access' - ) + presenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o', 'full_access') presenter.close() const checkDb = new DatabaseCtor(dbPath) @@ -812,7 +787,7 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { bootstrapDb.close() const presenter = new MainDatabaseCtor(dbPath) - new SessionDatabaseCtor(presenter).deepchatSessionsTable.updateGenerationSettings('session-1', { + presenter.deepchatSessionsTable.updateGenerationSettings('session-1', { forceInterleavedThinkingCompat: true }) presenter.close() @@ -1079,20 +1054,25 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { const dbPath = path.join(tempDir, 'agent.db') const presenter = new MainDatabaseCtor(dbPath) - const sessions = new SessionDatabaseCtor(presenter) - sessions.newSessionsTable.create('parent-session', 'deepchat', 'Parent session', '/workspace', { - sessionKind: 'regular' - }) - sessions.newSessionsTable.create('child-session', 'deepchat', 'Child session', '/workspace', { + presenter.newSessionsTable.create( + 'parent-session', + 'deepchat', + 'Parent session', + '/workspace', + { + sessionKind: 'regular' + } + ) + presenter.newSessionsTable.create('child-session', 'deepchat', 'Child session', '/workspace', { sessionKind: 'subagent', parentSessionId: 'parent-session' }) - const childRows = sessions.newSessionsTable.list({ + const childRows = presenter.newSessionsTable.list({ parentSessionId: 'parent-session' }) - const defaultRows = sessions.newSessionsTable.list() + const defaultRows = presenter.newSessionsTable.list() expect(childRows.map((row) => row.id)).toEqual(['child-session']) expect(defaultRows.map((row) => row.id)).toEqual(['parent-session']) diff --git a/test/main/scheduler/schedulerService.test.ts b/test/main/scheduler/schedulerService.test.ts index 794671713..438763671 100644 --- a/test/main/scheduler/schedulerService.test.ts +++ b/test/main/scheduler/schedulerService.test.ts @@ -876,7 +876,7 @@ describeIfSqlite('Cron Jobs persistence and service', () => { enabled: true } ] - const agentSettings = { + const providerSettings = { listAgents: vi.fn(async () => agents), resolveDeepChatAgentConfig: vi.fn(async () => ({ systemPrompt: 'system' })) } @@ -884,7 +884,7 @@ describeIfSqlite('Cron Jobs persistence and service', () => { ...createRequiredSchedulerDeps(), database: sqlitePresenter as never, schedulerManager: schedulerManager as never, - agentSettings: agentSettings as never + providerSettings: providerSettings as never }) const follow = await service.upsert({ @@ -897,7 +897,7 @@ describeIfSqlite('Cron Jobs persistence and service', () => { }) expect(follow.job.agentSnapshot).toBeNull() - expect(agentSettings.resolveDeepChatAgentConfig).toHaveBeenCalledWith('agent-1') + expect(providerSettings.resolveDeepChatAgentConfig).toHaveBeenCalledWith('agent-1') const { job } = await service.upsert({ name: 'Snapshot job', From bf46ac67622d7d015a2e4ea722e4d37d8113fac1 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 17:52:37 +0800 Subject: [PATCH 35/37] docs(tape): refresh validation record --- docs/architecture/tape-contract-lineage/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/tape-contract-lineage/tasks.md b/docs/architecture/tape-contract-lineage/tasks.md index 4a903cbc2..53e214dbe 100644 --- a/docs/architecture/tape-contract-lineage/tasks.md +++ b/docs/architecture/tape-contract-lineage/tasks.md @@ -71,7 +71,7 @@ Completed on 2026-08-09: | Gate | Result | | --- | --- | -| `pnpm run format:check` | Passed across 2,711 files | +| `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 | From c2cb5620a992ab44d3ca1795857e1ee951745d1d Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 9 Aug 2026 21:04:18 +0800 Subject: [PATCH 36/37] fix(tape): read legacy evaluation projections --- .../tape-contract-lineage/spec.md | 14 ++ .../orchestration/liveDelegationRepository.ts | 35 ++- src/main/tape/domain/taskEvaluation.ts | 224 +++++++++++++++++- src/shared/orchestration/liveDelegation.ts | 6 +- src/shared/types/task-contract.ts | 102 +++++++- .../liveDelegationRepository.test.ts | 179 ++++++++++++++ test/main/tape/taskEvaluation.test.ts | 165 +++++++++++++ 7 files changed, 712 insertions(+), 13 deletions(-) diff --git a/docs/architecture/tape-contract-lineage/spec.md b/docs/architecture/tape-contract-lineage/spec.md index 36ac9093a..c61b8b037 100644 --- a/docs/architecture/tape-contract-lineage/spec.md +++ b/docs/architecture/tape-contract-lineage/spec.md @@ -210,6 +210,17 @@ The evaluation idempotency identity includes the turn ID, TaskContract hash, can 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 @@ -247,6 +258,9 @@ This table describes write disciplines, not a count of all Tape event families. 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. diff --git a/src/main/orchestration/liveDelegationRepository.ts b/src/main/orchestration/liveDelegationRepository.ts index da5a246d4..0b253bf8b 100644 --- a/src/main/orchestration/liveDelegationRepository.ts +++ b/src/main/orchestration/liveDelegationRepository.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import type { SubagentTapeLinkReceipt } from '@shared/types/agent-interface' import type { DeepChatEvaluationRef, + DeepChatStoredTaskEvaluation, DeepChatTaskContractContext, DeepChatTaskEvaluation } from '@shared/types/task-contract' @@ -49,7 +50,7 @@ import { import { buildTaskEvaluation, restoreEvaluationRef, - restoreTaskEvaluation, + restoreStoredTaskEvaluation, serializeEvaluationRef, serializeTaskEvaluation } from '@/tape/domain/taskEvaluation' @@ -776,7 +777,7 @@ export class LiveDelegationRepository { if ( evaluation && turn.evaluation && - evaluation.evaluationHash !== turn.evaluation.evaluationHash + !matchesTerminalEvaluationRetry(turn.evaluation, evaluation) ) { throw new LiveDelegationTaskContractError( `Terminal evaluation retry conflicts with turn ${turn.id}.` @@ -1329,7 +1330,7 @@ function parseTaskContractRef(value: string | null) { function parseTaskEvaluation(value: string | null) { if (!value) return null - const evaluation = restoreTaskEvaluation( + const evaluation = restoreStoredTaskEvaluation( parseStoredContractProjectionJson(value, 'Task evaluation') ) if (!evaluation) { @@ -1340,6 +1341,34 @@ function parseTaskEvaluation(value: string | null) { 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( diff --git a/src/main/tape/domain/taskEvaluation.ts b/src/main/tape/domain/taskEvaluation.ts index 557cb67a7..4bba818ef 100644 --- a/src/main/tape/domain/taskEvaluation.ts +++ b/src/main/tape/domain/taskEvaluation.ts @@ -2,15 +2,21 @@ 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, @@ -26,6 +32,11 @@ 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 @@ -127,6 +138,26 @@ export function restoreTaskEvaluation(value: unknown): DeepChatTaskEvaluation | 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 } @@ -168,11 +199,11 @@ export function serializeEvaluationRef(ref: DeepChatEvaluationRef): string { } export function projectTaskEvaluationSummary( - evaluation: DeepChatTaskEvaluation, + evaluation: DeepChatStoredTaskEvaluation, evaluationRef: DeepChatEvaluationRef, maxEvidenceRecords = MAX_TASK_EVALUATION_PARENT_EVIDENCE ): DeepChatTaskEvaluationSummary { - const canonicalEvaluation = restoreTaskEvaluation(evaluation) + const canonicalEvaluation = restoreStoredTaskEvaluation(evaluation) const canonicalRef = restoreEvaluationRef(evaluationRef) if ( !canonicalEvaluation || @@ -185,12 +216,20 @@ export function projectTaskEvaluationSummary( throw new TaskEvaluationError('Task evaluation evidence limit is invalid.', 'invalid_input') } const evidenceLimit = Math.min(maxEvidenceRecords, MAX_TASK_EVALUATION_PARENT_EVIDENCE) - const relevant = canonicalEvaluation.records.filter((record) => record.outcome !== 'valid') + 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: canonicalEvaluation.evaluationKind, - formatStatus: canonicalEvaluation.formatStatus, - reasonCodes: [...canonicalEvaluation.reasonCodes], + evaluationKind: 'handoff_format', + formatStatus: projected.formatStatus, + reasonCodes: [...projected.reasonCodes], candidate: canonicalEvaluation.candidate, evidence, evaluationRef: canonicalRef, @@ -199,6 +238,38 @@ export function projectTaskEvaluationSummary( }) } +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 @@ -304,6 +375,147 @@ function isCanonicalEvaluation(evaluation: DeepChatTaskEvaluation): boolean { ) } +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 diff --git a/src/shared/orchestration/liveDelegation.ts b/src/shared/orchestration/liveDelegation.ts index abcc97609..5dd77c159 100644 --- a/src/shared/orchestration/liveDelegation.ts +++ b/src/shared/orchestration/liveDelegation.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import { OrchestrationEffectEvidenceSchema, OrchestrationEffectStateSchema } from './toolEffect' import { DeepChatEvaluationRefSchema, - DeepChatTaskEvaluationProjectionSchema, + DeepChatStoredTaskEvaluationProjectionSchema, DeepChatTaskEvaluationSummarySchema, DeepChatTaskContractProjectionSchema, DeepChatTaskContractRefSchema @@ -143,7 +143,7 @@ const LiveDelegationTurnBaseSchema = z taskContract: DeepChatTaskContractProjectionSchema.nullable().default(null), taskContractRef: DeepChatTaskContractRefSchema.nullable().default(null), inheritedTaskContractRef: DeepChatTaskContractRefSchema.nullable().default(null), - evaluation: DeepChatTaskEvaluationProjectionSchema.nullable().default(null), + evaluation: DeepChatStoredTaskEvaluationProjectionSchema.nullable().default(null), evaluationRef: DeepChatEvaluationRefSchema.nullable().default(null), effectState: OrchestrationEffectStateSchema, effectEvidence: OrchestrationEffectEvidenceSchema.nullable(), @@ -168,7 +168,7 @@ const LiveDelegationEventBaseSchema = z content: z.string(), relatedTurnId: LiveDelegationIdSchema.nullable(), consumedByTurnId: LiveDelegationIdSchema.nullable(), - evaluation: DeepChatTaskEvaluationProjectionSchema.nullable().default(null), + evaluation: DeepChatStoredTaskEvaluationProjectionSchema.nullable().default(null), evaluationRef: DeepChatEvaluationRefSchema.nullable().default(null), createdAt: z.number().int().nonnegative() }) diff --git a/src/shared/types/task-contract.ts b/src/shared/types/task-contract.ts index 63924f335..bb62c918e 100644 --- a/src/shared/types/task-contract.ts +++ b/src/shared/types/task-contract.ts @@ -3,7 +3,9 @@ import { z } from 'zod' export const DEEPCHAT_TASK_CONTRACT_SCHEMA_VERSION = 1 as const export const DEEPCHAT_TASK_CONTRACT_HASH_VERSION = 1 as const export const DEEPCHAT_TASK_CONTRACT_REF_SCHEMA_VERSION = 1 as const -export const DEEPCHAT_TASK_EVALUATION_SCHEMA_VERSION = 1 as const +export const DEEPCHAT_LEGACY_TASK_EVALUATION_SCHEMA_VERSION = 1 as const +export const DEEPCHAT_LEGACY_TASK_EVALUATOR_VERSION = 'task-contract-v1' as const +export const DEEPCHAT_TASK_EVALUATION_SCHEMA_VERSION = 2 as const export const DEEPCHAT_TASK_EVALUATION_HASH_VERSION = 1 as const export const DEEPCHAT_TASK_EVALUATOR_VERSION = 'handoff-format-v1' as const export const DEEPCHAT_EVALUATION_REF_SCHEMA_VERSION = 1 as const @@ -26,6 +28,21 @@ export const DEEPCHAT_TASK_EVALUATION_REASON_CODES = [ 'required_sections_missing' ] as const +export const DEEPCHAT_LEGACY_TASK_EVALUATION_REASON_CODES = [ + 'candidate_missing', + 'candidate_too_large', + 'candidate_too_complex', + 'execution_cancelled', + 'execution_interrupted', + 'required_sections_present', + 'required_sections_missing', + 'result_schema_valid', + 'result_section_missing', + 'result_json_invalid', + 'result_schema_mismatch', + 'evaluator_error' +] as const + export type DeepChatTaskEvaluationReasonCode = (typeof DEEPCHAT_TASK_EVALUATION_REASON_CODES)[number] export type DeepChatTaskEvaluationFormatStatus = 'valid' | 'invalid' | 'indeterminate' @@ -35,6 +52,9 @@ export type DeepChatTaskEvaluationExecutionStatus = | 'cancelled' | 'interrupted' export type DeepChatTaskEvaluationOutcome = 'valid' | 'invalid' | 'indeterminate' +export type DeepChatLegacyTaskEvaluationReasonCode = + (typeof DEEPCHAT_LEGACY_TASK_EVALUATION_REASON_CODES)[number] +export type DeepChatLegacyTaskEvaluationOutcome = 'passed' | 'failed' | 'indeterminate' export interface DeepChatTaskContractRef { readonly schemaVersion: typeof DEEPCHAT_TASK_CONTRACT_REF_SCHEMA_VERSION @@ -87,6 +107,36 @@ export interface DeepChatTaskEvaluation { readonly evaluationHash: string } +export interface DeepChatLegacyTaskEvaluationRecord { + readonly requirementId: string | null + readonly requirementKind: 'required_sections' | 'result_schema' | null + readonly outcome: DeepChatLegacyTaskEvaluationOutcome + readonly code: DeepChatLegacyTaskEvaluationReasonCode + readonly section: string | null + readonly instancePath: string | null + readonly keyword: string | null + readonly additionalEvidenceCount: number +} + +export interface DeepChatLegacyTaskEvaluation { + readonly schemaVersion: typeof DEEPCHAT_LEGACY_TASK_EVALUATION_SCHEMA_VERSION + readonly hashVersion: typeof DEEPCHAT_TASK_EVALUATION_HASH_VERSION + readonly evaluatorVersion: typeof DEEPCHAT_LEGACY_TASK_EVALUATOR_VERSION + readonly turnId: string + readonly taskContractHash: string + readonly candidate: DeepChatTaskEvaluationCandidate + readonly executionStatus: DeepChatTaskEvaluationExecutionStatus + readonly verdict: DeepChatLegacyTaskEvaluationOutcome + readonly disposition: 'accepted' | 'parked' + readonly reasonCodes: readonly DeepChatLegacyTaskEvaluationReasonCode[] + readonly records: readonly DeepChatLegacyTaskEvaluationRecord[] + readonly omittedRecordCount: number + readonly evaluationHash: string +} + +// Persistence readers accept the legacy arm; current writers use DeepChatTaskEvaluation only. +export type DeepChatStoredTaskEvaluation = DeepChatTaskEvaluation | DeepChatLegacyTaskEvaluation + export interface DeepChatTaskEvaluationSummary { readonly evaluationKind: 'handoff_format' readonly formatStatus: DeepChatTaskEvaluationFormatStatus @@ -199,6 +249,10 @@ export const DeepChatTaskEvaluationCandidateSchema = z.discriminatedUnion('kind' export const DeepChatTaskEvaluationReasonCodeSchema = z.enum(DEEPCHAT_TASK_EVALUATION_REASON_CODES) +const DeepChatLegacyTaskEvaluationReasonCodeSchema = z.enum( + DEEPCHAT_LEGACY_TASK_EVALUATION_REASON_CODES +) + export const DeepChatTaskEvaluationRecordSchema = z .object({ requirementId: StoredIdSchema.nullable(), @@ -230,6 +284,52 @@ export const DeepChatTaskEvaluationProjectionSchema: 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'), diff --git a/test/main/orchestration/liveDelegationRepository.test.ts b/test/main/orchestration/liveDelegationRepository.test.ts index 9debf5d22..fef255a7e 100644 --- a/test/main/orchestration/liveDelegationRepository.test.ts +++ b/test/main/orchestration/liveDelegationRepository.test.ts @@ -1,10 +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) @@ -130,6 +132,43 @@ describeIfSqlite('LiveDelegationRepository', () => { ].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() @@ -988,6 +1027,146 @@ describeIfSqlite('LiveDelegationRepository', () => { 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) diff --git a/test/main/tape/taskEvaluation.test.ts b/test/main/tape/taskEvaluation.test.ts index cf7a9bf06..ed756444e 100644 --- a/test/main/tape/taskEvaluation.test.ts +++ b/test/main/tape/taskEvaluation.test.ts @@ -5,6 +5,7 @@ import { DeepChatTaskEvaluationProjectionSchema, DeepChatTaskEvaluationSummarySchema, MAX_TASK_EVALUATION_CANDIDATE_BYTES, + type DeepChatLegacyTaskEvaluation, type DeepChatHandoffFormatRequirement, type DeepChatTaskEvaluationExecutionStatus } from '@shared/types/task-contract' @@ -13,10 +14,14 @@ 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', @@ -59,6 +64,12 @@ function evaluate( }) } +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 = [ @@ -77,6 +88,8 @@ describe('Task evaluation domain', () => { expect(first).toEqual(second) expect(first).toMatchObject({ + schemaVersion: 2, + hashVersion: 1, evaluatorVersion: 'handoff-format-v1', evaluationKind: 'handoff_format', formatStatus: 'valid', @@ -107,6 +120,158 @@ describe('Task evaluation domain', () => { 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') From cce6430244bcedf572c03bb67ed4d7570222d137 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Mon, 10 Aug 2026 10:31:44 +0800 Subject: [PATCH 37/37] test(agent): bind process runs to command shell --- test/main/agent/deepchat/runtime/process.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/main/agent/deepchat/runtime/process.test.ts b/test/main/agent/deepchat/runtime/process.test.ts index dcaa7fdb9..ab70563c2 100644 --- a/test/main/agent/deepchat/runtime/process.test.ts +++ b/test/main/agent/deepchat/runtime/process.test.ts @@ -935,7 +935,11 @@ describe('processStream', () => { abortController: new AbortController(), messages: [{ role: 'user', content: 'Hello' }], streamState: createState(), - resources: { toolDefinitions: tools, activeSkillNames: [] }, + resources: { + toolDefinitions: tools, + activeSkillNames: [], + commandShell: POSIX_COMMAND_SHELL + }, initialRequestSeq: 1 }) const executionContract = { @@ -979,7 +983,11 @@ describe('processStream', () => { abortController: new AbortController(), messages: [{ role: 'user', content: 'Hello' }], streamState: createState(), - resources: { toolDefinitions: tools, activeSkillNames: [] }, + resources: { + toolDefinitions: tools, + activeSkillNames: [], + commandShell: POSIX_COMMAND_SHELL + }, initialRequestSeq: 1 }) const promptAssembly = createOpaquePromptAssembly('System prompt')