From ecf90ff382344b706a123a5db417869a5084d9d6 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 10:51:38 +0800 Subject: [PATCH 001/207] feat(session-query): add SQLite full-text search --- docs/architecture.md | 1 + docs/capability-seams.md | 13 +- docs/config-catalog.md | 25 + docs/cordis-catalog/services.md | 16 +- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/session-query.md | 82 +- docs/module-graph.md | 5 + docs/rfc/INDEX.md | 2 +- .../2026-07-10-session-query-service.md | 12 +- ...026-07-10-sqlite-session-query-provider.md | 57 ++ ...026-07-10-sqlite-session-query-provider.md | 51 -- packages/README.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 57 ++ packages/session-query/README.md | 7 +- .../session-query-sqlite/README.md | 35 + .../session-query-sqlite/package.json | 46 ++ .../session-query-sqlite/src/index.ts | 765 ++++++++++++++++++ .../session-query-sqlite/src/query.ts | 312 +++++++ .../session-query-sqlite/src/schema.ts | 127 +++ .../tests/load-path.e2e.ts | 60 ++ .../session-query-sqlite/tests/query.spec.ts | 179 ++++ .../session-query-sqlite/tests/sqlite.spec.ts | 594 ++++++++++++++ .../session-query-sqlite/tsconfig.json | 30 + .../session-query/session-query/README.md | 19 +- .../session-query/session-query/src/config.ts | 11 +- .../session-query/session-query/src/corpus.ts | 21 +- .../session-query/src/documents.ts | 74 ++ .../session-query/src/extraction.ts | 93 +++ .../session-query/src/filters.ts | 132 +++ .../session-query/session-query/src/index.ts | 92 ++- .../session-query/src/sources.ts | 25 + .../session-query/session-query/src/types.ts | 96 +++ .../tests/search-helpers.spec.ts | 209 +++++ pnpm-lock.yaml | 25 + scripts/gen-doc-graphs.ts | 14 +- scripts/type-equiv.manifest.json | 8 + tsconfig.build.json | 1 + tsconfig.json | 1 + 38 files changed, 3181 insertions(+), 120 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md delete mode 100644 docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md create mode 100644 packages/session-query/session-query-sqlite/README.md create mode 100644 packages/session-query/session-query-sqlite/package.json create mode 100644 packages/session-query/session-query-sqlite/src/index.ts create mode 100644 packages/session-query/session-query-sqlite/src/query.ts create mode 100644 packages/session-query/session-query-sqlite/src/schema.ts create mode 100644 packages/session-query/session-query-sqlite/tests/load-path.e2e.ts create mode 100644 packages/session-query/session-query-sqlite/tests/query.spec.ts create mode 100644 packages/session-query/session-query-sqlite/tests/sqlite.spec.ts create mode 100644 packages/session-query/session-query-sqlite/tsconfig.json create mode 100644 packages/session-query/session-query/src/documents.ts create mode 100644 packages/session-query/session-query/src/extraction.ts create mode 100644 packages/session-query/session-query/src/filters.ts create mode 100644 packages/session-query/session-query/src/sources.ts create mode 100644 packages/session-query/session-query/tests/search-helpers.spec.ts diff --git a/docs/architecture.md b/docs/architecture.md index 8dc7f6f4b1..e7b6f25ac3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,6 +35,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus and exact-event reads | +| `ctx.sessionSearch` | [`session-query/`](../packages/session-query/README.md) | SQLite full-text search | ## Event diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 13af1cde1c..189b910149 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -19,6 +19,7 @@ flowchart LR pkg_agent["agent"] pkg_session_persistence["session-persistence"] pkg_session_query["session-query"] + pkg_session_query_sqlite["session-query-sqlite"] pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] @@ -26,6 +27,7 @@ flowchart LR pkg_session_persistence_sqlite["session-persistence-sqlite"] pkg_acp["acp"] svc_sessionQuery["ctx.sessionQuery
Exact session-history reads"] + svc_sessionSearch["ctx.sessionSearch
Full-text session search"] pkg_system_prompt["system-prompt"] svc_systemPrompt["ctx.systemPrompt
System prompt assembly registry"] pkg_tools["tools"] @@ -108,6 +110,8 @@ flowchart LR pkg_session_persistence_jsonl --> svc_sessionPersistence pkg_session_persistence_sqlite --> svc_sessionPersistence pkg_session_query --> svc_sessionQuery + pkg_session_query --> svc_sessionSearch + pkg_session_query_sqlite --> svc_sessionSearch pkg_skill --> svc_skills pkg_skill_local --> svc_skills pkg_stdio_agent --> svc_userInteraction @@ -146,11 +150,13 @@ flowchart LR svc_sessionPersistence --> pkg_acp svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_session_query + svc_sessionPersistence --> pkg_session_query_sqlite svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_invariants svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query + svc_sessions --> pkg_session_query_sqlite svc_sessions --> pkg_subagent_inprocess svc_skills --> pkg_tool_skill svc_subagents --> pkg_tool_subagent @@ -179,9 +185,10 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | -| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and semantic scans. | +| `ctx.sessionSearch` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | - | The concrete backend owns source reconciliation, ranking, snippets, and cursor generations as one lifecycle. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 24aee5adc2..3a802231d7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -590,6 +590,31 @@ export interface Config { Source: [`packages/session-query/session-query/src/config.ts:9`](../packages/session-query/session-query/src/config.ts) +## `@deepseek-ai/dsh-session-query-sqlite` + +Requires: `sessions` + +```ts config-catalog +/** SQLite session-search configuration. */ +export interface Config { + /** Dedicated derived-index path; `:memory:` is supported for tests. */ + path: string + /** SQLite journal mode. Defaults to `wal`. */ + journalMode?: JournalMode + /** Page size when a request omits `limit`. Defaults to 20. */ + defaultLimit?: number + /** Largest accepted page size. Defaults to 100. */ + maxLimit?: number + /** Maximum snippet length in Unicode code points. Defaults to 240. */ + snippetChars?: number +} + +/** Supported SQLite journal modes. */ +export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' +``` + +Source: [`packages/session-query/session-query-sqlite/src/index.ts:58`](../packages/session-query/session-query-sqlite/src/index.ts) + ## `@deepseek-ai/dsh-skill` ```ts config-catalog diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6f8508f5ee..fef691aae1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -208,10 +208,11 @@ Live-preferred logical-corpus and exact-event read service. ```ts cordis-catalog listSessions(): Promise async listEvents(sessionId: SessionId): Promise +async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFilter[], ): Promise async readEvent(request: SessionEventReadRequest): Promise ``` -Source: [`packages/session-query/session-query/src/index.ts:35`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:83`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessions` — `SessionStore` @@ -232,6 +233,19 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Source: [`packages/core/session/src/index.ts:590`](../../packages/core/session/src/index.ts) +## `ctx.sessionSearch` — `SessionSearchService` (abstract seam) + +Abstract full-text search service implemented by one concrete backend. + +The implementation owns source observation, reconciliation, cursor generations, ranking, and query execution as one lifecycle. + +```ts cordis-catalog +abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExecContext, ): Promise> +abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise> +``` + +Source: [`packages/session-query/session-query/src/index.ts:54`](../../packages/session-query/session-query/src/index.ts) + ## `ctx.skills` — `SkillService` Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 5536526aec..e632b36f4d 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -19,7 +19,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | -| [session-query.md](session-query.md) | logical session/event records and bounded exact-event reads | +| [session-query.md](session-query.md) | logical records, semantic filters/documents, exact reads, and full-text result pages | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, and the guarded execution pipeline | | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index ded8ca3f7e..27ef9959de 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -1,6 +1,6 @@ # Session Query -Exact reads over the live-preferred logical session corpus. The [package contract](../../packages/session-query/session-query) owns source precedence, dynamic optional persistence, cloning, surface classification, bounded windows, and typed failures. Full-text search is a separate proposed SQLite phase. +Query vocabulary over the live-preferred logical session corpus. The [interface package](../../packages/session-query/session-query) owns exact reads, source precedence, semantic extraction and provider-independent filters, while the [SQLite package](../../packages/session-query/session-query-sqlite) owns the concrete full-text index lifecycle. Source: [`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) @@ -30,6 +30,79 @@ export interface SessionEventRecord { } ``` +## Provider-independent filters and documents + +Session and event filter arrays are ANDed; values inside one list clause are ORed. Ranges are inclusive. The event `text` clause is a literal Unicode case-insensitive, whitespace-flexible regular-expression scan over extracted semantic text, independent of full-text providers. + +```ts type-equiv +export type SessionResultFilter = + | { kind: 'id'; values: readonly SessionId[] } + | { kind: 'cwd'; values: readonly (string | null)[] } + | ({ kind: 'created-at' } & SessionResultRange) + | { kind: 'parent'; values: readonly (SessionId | null)[] } + | { kind: 'availability'; values: readonly SessionAvailability[] } +``` + +```ts type-equiv +export type SessionEventResultFilter = + | ({ kind: 'seq' } & SessionResultRange) + | ({ kind: 'time' } & SessionResultRange) + | { kind: 'type'; values: readonly SessionEventType[] } + | { kind: 'surface'; values: readonly SessionEventSurface[] } + | { kind: 'text'; text: string } +``` + +```ts type-equiv +export interface SessionEventSearchDocument extends SessionEventRecord { + text: string +} +``` + +`ctx.sessionQuery.filterEvents(sessionId, filters)` returns these documents in ascending seq order. Messages, reasoning, tool calls/results, blocked prompts, todos, and failure/status detail contribute semantic text; structural events and stream chunks do not. + +## Full-text search pages + +The independent `ctx.sessionSearch` seam has two scopes. `searchSessions()` groups the corpus by strongest matching event; `searchEvents()` searches one session. Requests bind an opaque cursor to the normalized query, metadata filters, and limit. The event text scan is intentionally absent from provider metadata filters. + +```ts type-equiv +export interface SessionSearchRequest { + query: string + sessionFilters?: readonly SessionResultFilter[] + eventFilters?: readonly SessionEventMetadataFilter[] + limit?: number + cursor?: string +} +``` + +```ts type-equiv +export interface SessionEventSearchRequest { + sessionId: SessionId + query: string + filters?: readonly SessionEventMetadataFilter[] + limit?: number + cursor?: string +} +``` + +```ts type-equiv +export interface SessionSearchPage { + items: readonly T[] + nextCursor?: string +} +``` + +```ts type-equiv +export interface SessionEventSearchHit extends SessionEventRecord { + snippet: string +} +``` + +```ts type-equiv +export interface SessionSearchHit extends SessionRecord { + bestMatch: SessionEventSearchHit +} +``` + ## Bounded event reads The request addresses one raw seq and optional neighboring counts. The result carries a `SessionHeader` rather than availability flags so a known live target can remain independent of persistence health. @@ -59,11 +132,18 @@ The closed code union distinguishes request validation, missing targets, malform ```ts type-equiv export type SessionQueryErrorCode = + | 'SESSION_QUERY_ABORTED' | 'SESSION_QUERY_EVENT_NOT_FOUND' + | 'SESSION_QUERY_INDEX_FAILED' | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_CURSOR' + | 'SESSION_QUERY_INVALID_FILTER' + | 'SESSION_QUERY_INVALID_LIMIT' + | 'SESSION_QUERY_INVALID_QUERY' | 'SESSION_QUERY_INVALID_SURFACE' | 'SESSION_QUERY_INVALID_WINDOW' | 'SESSION_QUERY_PERSISTENCE_FAILED' | 'SESSION_QUERY_SESSION_NOT_FOUND' + | 'SESSION_QUERY_STALE_CURSOR' | 'SESSION_QUERY_SOURCE_CONFLICT' ``` diff --git a/docs/module-graph.md b/docs/module-graph.md index 8a1ad9239d..6fcec53dd4 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -84,6 +84,7 @@ flowchart TD end subgraph group_session_query["packages/session-query"] pkg_session_query["session-query"] + pkg_session_query_sqlite["session-query-sqlite"] end subgraph group_support["packages/support"] pkg_acp_snapshot["acp-snapshot"] @@ -195,6 +196,9 @@ flowchart TD pkg_bash_sandbox --> pkg_bash pkg_bash_sandbox --> pkg_bash_local pkg_bash_sandbox --> pkg_sandbox + pkg_session_query_sqlite --> pkg_session + pkg_session_query_sqlite --> pkg_session_persistence + pkg_session_query_sqlite --> pkg_session_query pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_scope @@ -372,6 +376,7 @@ flowchart TD | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | +| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 54e59f7aa1..a3eb41b7b0 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -11,7 +11,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 | | [Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)](proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md) | 2026-07-07 | | [Interactive side sessions and merge-back](proposed/feature/2026-07-08-interactive-side-sessions.md) | 2026-07-08 | -| [SQLite FTS5 session search](proposed/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 | | [Stream workflow progress through tool calls](proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md) | 2026-07-13 | ### Simplification @@ -77,6 +76,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 | | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | | [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | +| [SQLite FTS5 session search](implemented/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 | | [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md index 7e13256669..be496311cc 100644 --- a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md @@ -6,11 +6,11 @@ Status: implemented Session history exists in two places: current `SessionStore` objects and an optional persistence backend. Consumers that need exact inspection would otherwise duplicate live-versus-persisted precedence, persistence lifecycle handling, raw-event surface classification, and defensive cloning. Durable state can lag the live log between checkpoints, so persistence alone is not a truthful current source. -Full-text search is related but materially larger. Designing provider registration, extraction, synchronization, invalidation, ranking, and cursor contracts before a real backend exists creates two speculative state machines: one in the interface service and another in the eventual database package. +Full-text search is related but materially larger. Putting provider coordination, synchronization, invalidation, ranking, and cursor state into the exact-read service would create a second state machine beside the concrete database owner. ## Decision -`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-read service over one logical corpus. It exposes `listSessions()`, `listEvents(sessionId)`, and bounded `readEvent(request)`. It does not expose filters, lineage or provenance traversals, text extractors, search requests, provider registration, or derived-index synchronization. +`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-read service over one logical corpus. It exposes `listSessions()`, `listEvents(sessionId)`, provider-independent `filterEvents(sessionId, filters)`, and bounded `readEvent(request)`. The package also declares the separate abstract `ctx.sessionSearch` contract and shared semantic extraction used by the [SQLite search decision](2026-07-10-sqlite-session-query-provider.md), but `ctx.sessionQuery` does not coordinate providers or synchronize a derived index. The service observes the optional `ctx.sessionPersistence` binding dynamically but retains no persisted cache or invalidation listener. Each cross-corpus list asks the active backend for authoritative metadata, then overlays a fresh live-store list. Matching ids become one `SessionRecord`: the live header wins and `live`/`persisted` independently report source availability. Immutable header disagreement is `SESSION_QUERY_SOURCE_CONFLICT`. @@ -31,11 +31,11 @@ The service is context-wide trusted infrastructure, not an authorization layer. - **Put logical-corpus resolution directly in every consumer** — rejected because source precedence, conflicts, optional-service lifecycle, cloning, and surface classification are shared correctness rules. - **Query only persistence** — rejected because checkpoints can lag the current live log. - **Cache persisted metadata and listen for writes/removals** — rejected because exact reads can ask the authoritative sources directly, while cache invalidation adds lifecycle and concurrency state before scale requires it. -- **Define a provider-neutral search protocol now** — rejected because no provider consumes it. The first SQLite FTS package should own one reconciliation/transaction state machine; a smaller shared seam can be extracted later only when a second implementation proves the boundary. -- **Include lineage, provenance, and generic filters in phase one** — rejected because no current consumer requires them and canonical logs remain sufficient to add them with evidence later. +- **Put provider registration into the exact-read service** — rejected because the SQLite package owns one reconciliation/transaction lifecycle; a registry would split that state without a second provider to justify it. +- **Include lineage and provenance traversal** — rejected because canonical logs remain sufficient to add those higher-level views when a concrete consumer requires them. ## Consequences -Phase one has one source-resolution state variable: the currently mounted persistence service. There are no provider queues, fingerprints, extractor registries, observation generations, or derived index updates. Exact reads remain usable in live-only deployments and deterministic when persistence is present. +Exact reads have one source-resolution state variable: the currently mounted persistence service. There are no provider queues, fingerprints, extractor registries, observation generations, or derived index updates in `ctx.sessionQuery`. Exact reads and semantic scans remain usable in live-only deployments and deterministic when persistence is present. -Cross-corpus listing and persisted exact reads perform backend I/O on each call. That is deliberate: correctness comes from current authoritative state, and scale-oriented search belongs to the phase-two database. Full-text search is unavailable until that package defines and implements its complete contract. +Cross-corpus listing and persisted exact reads perform backend I/O on each call. That is deliberate: correctness comes from current authoritative state, while scale-oriented full-text search uses the separately owned SQLite derived index. diff --git a/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md new file mode 100644 index 0000000000..d620ece108 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md @@ -0,0 +1,57 @@ +# RFC: SQLite FTS5 session search + +Status: implemented + +## Problem + +The exact-read `ctx.sessionQuery` service deliberately has no derived index. Large persisted histories need full-text search without scanning every event on every query, while current live sessions need an overlay newer than the last durability checkpoint. Search also needs concrete ranking, snippets, filters, pagination, cancellation, and rebuild behavior. + +Splitting those concerns across a provider coordinator and a database implementation would create two coupled reconciliation state machines. The first implementation needs to own source observation, extraction, SQLite transactions, generations, and query execution as one lifecycle while still exposing a small provider-neutral call contract. + +## Decision + +`@deepseek-ai/dsh-session-query` declares an independent abstract `ctx.sessionSearch` service without changing the exact-read `ctx.sessionQuery` key. `searchSessions(request, exec?)` returns cursor-paginated `SessionSearchHit`s grouped by each session's strongest matching event; `searchEvents(request, exec?)` returns `SessionEventSearchHit`s within one logical session. Both requests require `query`, accept `limit` and an opaque `cursor`, and support an optional abort signal. Session search accepts `sessionFilters` plus event metadata filters; event search accepts event metadata filters. Results expose bounded plain-text snippets but no provider identifier or numeric relevance score. + +`@deepseek-ai/dsh-session-query-sqlite` is the sole concrete owner of `ctx.sessionSearch`. It depends on live `ctx.sessions`, observes optional `ctx.sessionPersistence` dynamically, and owns a dedicated derived SQLite database. There is no search-provider registry, coordinator, persistence event, or agent-loop integration. + +The interface package also owns shared first-party semantic extraction and provider-independent filtering. `SessionResultFilter` covers id, nullable cwd, created-at range, nullable parent, and availability. `SessionEventResultFilter` covers seq/time ranges, event type, surface, and literal semantic text. Arrays are ANDed and list values are ORed. The text clause escapes caller input into a Unicode case-insensitive regular expression whose whitespace runs match one or more whitespace characters; it is available through `ctx.sessionQuery.filterEvents()` and is not delegated to an FTS provider. + +## Search semantics + +Each semantic event is one FTS document carrying session metadata, event metadata, surface classification, and extracted text. All `current`, `shadowed`, and `log-only` documents participate unless a surface filter narrows them. Metadata filters compile to parameterized SQL before ranking. Session results partition matching documents by session and retain the strongest one. + +Ordering is deterministic: BM25 ascending, event time descending, session id ascending for the cross-session scope, and seq descending. Provider scores remain private. Snippets normalize whitespace and are bounded by Unicode code points. Opaque cursors bind to the service instance, scope, canonical normalized request, offset, and relevant generation. Any corpus change invalidates cross-session cursors; a within-session cursor changes only when its target source/generation changes, so unrelated sessions do not invalidate it. Reopening creates a new service instance and invalidates old cursors. + +Queries are trimmed, whitespace-normalized, and quoted as one literal FTS5 phrase. Embedded quotes are doubled before binding, so MATCH operators such as `OR`, `NEAR`, quotes, parentheses, and `*` remain data rather than executable query syntax. Phrase matching follows tokenizer tokens rather than arbitrary substrings. + +## Tokenizer choice + +Both persistent and live FTS5 tables use `unicode61`. The implementation experiment found that this tokenizer supports the two-character token `AI` and produces an index about 2.1× smaller than the trigram alternative. The accepted limitation is token/phrase recall: `AI` does not match the larger token `BRAID`, and arbitrary substring search uses the provider-independent text scan instead. + +## Extraction and reconciliation + +The shared extractor includes message text, reasoning, nested tool-call/result content, tool names and arguments, blocked-prompt reasons, todo status/content, and error or terminal status detail. Structural boundaries, stream chunks, request headers, successful completion markers, and unknown declaration-merged event/content variants produce no document. Surface classification reuses `foldSurface()` so search agrees with model-history derivation. + +One serialized operation observes complete persisted and live sources, computes stable fingerprints, reconciles rows in one transaction, and executes the query. Unchanged persisted sessions retain their rows and generation. New, changed, and deleted persisted sessions update on the next search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. + +Persisted documents survive restarts. Live sessions use connection-local TEMP tables, shadow the persisted base for the same id, and reveal that base on detach. Closing the database drops live rows. Unmounting persistence hides durable rows without treating absence as authoritative deletion; remounting observes and reconciles the backend again. Conflicting immutable live and durable headers fail rather than combining sources. + +The derived schema has its own application id and monotonic schema version. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused, which prevents an accidentally configured canonical session database from being reset. + +Cancellation rejects queued operations and caller waits around asynchronous source observation without committing an aborted observation. Node's synchronous `DatabaseSync` MATCH call cannot be interrupted once it is executing on the JavaScript thread, so the service checks the signal at serialized boundaries but does not promise mid-statement preemption. + +## Alternatives considered + +- **Add FTS tables to the canonical persistence database** — rejected because a rebuildable index must not share the authoritative log's schema, reset, or failure boundary. +- **Add a phase-one provider registry and coordinator** — rejected because one implementation provides no evidence for registration semantics and would split one reconciliation lifecycle across two owners. +- **Persist live overrides immediately** — rejected because live events are not canonical until the existing checkpoint commits. +- **Use the FTS5 trigram tokenizer** — rejected because it omits useful queries shorter than three characters and measured about 2.1× the index size of `unicode61`; literal substring filtering remains available through the scan path. +- **Return BM25 scores** — rejected because provider-specific numeric scales are unstable across corpus changes. + +## Consequences + +Search has a small provider-neutral API while its only backend owns every derived-index state transition. The separate database adds configuration and a reconciliation read before queries, but index corruption, reset, and tokenizer changes cannot endanger canonical logs. Persistent fingerprints avoid rewriting unchanged sessions; TEMP live overlays preserve current-session truth without making uncheckpointed events durable. + +The chosen tokenizer supports short tokens with a smaller index but does not promise substring recall. Literal phrases make query syntax safe and predictable at the cost of excluding boolean/full MATCH expressions. Cancellation is effective while queued or awaiting sources, but synchronous SQLite execution remains a non-preemptible section. + +Unit coverage pins extraction, filters, both search scopes, all default surfaces, metadata-before-ranking, snippets, literal escaping, deterministic ties, complete pagination, scoped cursor invalidation, dynamic persistence mount/unmount, restart reconciliation, live shadow/reveal/reopen, schema safety, rollback retry, and queued/in-flight source-wait cancellation. A keyless real-Loader-path test combines the package with the real SQLite persistence backend. diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md deleted file mode 100644 index acfdf23bee..0000000000 --- a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md +++ /dev/null @@ -1,51 +0,0 @@ -# RFC: SQLite FTS5 session search - -Status: proposed - -## Problem - -The exact-read `ctx.sessionQuery` service deliberately has no derived index. Large persisted histories need full-text search without scanning every event on every query, while current live sessions need an overlay newer than the last durability checkpoint. Search also needs concrete ranking, snippets, filters, pagination, cancellation, and rebuild behavior. - -Splitting those concerns across a speculative provider coordinator and a database implementation would create two coupled reconciliation state machines. The first real implementation should own the source observation, extraction, SQLite transaction, generation, and query as one lifecycle. - -## Proposal - -Add `@deepseek-ai/dsh-session-query-sqlite` beside the exact-read package. The package will expose a search service or extend the family with the smallest API required by its actual consumers; phase one does not pre-commit a provider-registration protocol. It will depend on `ctx.sessions` and optional `ctx.sessionPersistence`, own a separate derived SQLite database, and reuse the canonical `foldSurface()` classification. - -The implementation owns one serialized reconciliation/DB transaction state machine. A transaction observes authoritative persisted metadata and live snapshots, extracts semantic documents, updates derived tables, advances relevant cursor generations, and executes or enables the corresponding query. No second service maintains parallel fingerprints, dirty flags, live-id sets, or invalidation generations. - -Persisted documents survive restarts. Live overrides are connection-local and shadow the persisted rows for the same session, then disappear when the live owner or database closes. The derived database remains separate from canonical persistence so index reset, corruption, tokenizer changes, and schema churn cannot endanger durable conversation logs. - -## Search semantics to decide with implementation - -The implementation must define both cross-session and within-session scopes from executable use cases. Each searchable event is one document with session metadata, event metadata, surface classification, normalized semantic text, and a bounded plain-text snippet. Session results group by their strongest matching event; numeric backend scores remain private. - -Filters compile to parameterized SQL before ranking. Query syntax is treated as data. Ordering includes stable tie fields. Opaque cursors bind to normalized request shape and the smallest relevant generation; unrelated session changes should not invalidate a within-session cursor. Cancellation must stop caller waiting and interrupt SQLite work where the runtime permits. - -Tokenizer choice remains an implementation experiment. FTS5 trigram supports substring recall but rejects useful terms shorter than three characters and increases index size; the proposal must benchmark that tradeoff against the default Unicode tokenizer before making it contract. - -## Extraction and reconciliation - -The package starts with first-party semantic extraction for messages, reasoning, tool calls/results, blocked prompts, context, steering, todos, and error/status detail. Structural events and stream chunks contribute no document. Unknown declaration-merged event/content types remain non-searchable unless a real extension consumer demonstrates the need for a public extractor registry. - -Reconciliation may use stable fingerprints to avoid rewriting unchanged persisted sessions, but the database package owns their calculation and storage. It must never report a row current when source observation or extraction failed. Provider-schema mismatch may reset only the derived database; ordinary source changes use transactional upsert/delete. Mounted but unreadable persistence fails affected searches without affecting canonical writes or known live exact reads. - -## Alternatives considered - -- **Add FTS tables to the canonical persistence database** — rejected because a rebuildable index must not share the authoritative log's schema/reset/failure boundary. -- **Reintroduce phase-one provider coordination** — rejected because there is one planned implementation and no evidence for a stable multi-provider seam. -- **Persist live overrides immediately** — rejected because live events are not canonical until the existing checkpoint commits. -- **Return BM25 scores** — rejected because provider-specific numeric scales are unstable across corpus changes. - -## Acceptance criteria - -- Restart tests cover unchanged, new, changed, and deleted persisted sessions without rebuilding the whole index. -- Reopening preserves persisted rows and removes live rows; live rows shadow and then reveal their persisted base. -- Tests cover both search scopes, metadata filters, surface defaults, snippets, escaping, deterministic ties, pagination, scoped stale cursors, cancellation, dynamic persistence mount/unmount, and recovery after a failed transaction. -- A schema mismatch resets only the derived database. -- A keyless end-to-end test combines a real persistence backend with the real SQLite search package. -- The RFC is amended to the measured tokenizer and public API actually implemented before moving to `implemented/`. - -## Risks - -A single owner is simpler but initially less reusable than a provider-neutral seam. That is intentional: a second real backend can reveal what to extract. SQLite runtime differences can affect FTS ranking and snippets, so tests must pin only contract-controlled ordering and presentation. The separate database adds configuration and lifecycle work, but preserves the canonical store's safety boundary. diff --git a/packages/README.md b/packages/README.md index d3dd6818df..f494b12222 100644 --- a/packages/README.md +++ b/packages/README.md @@ -25,7 +25,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | -| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface | +| [`session-query/`](session-query/README.md) | Session retrieval family: exact reads, semantic filtering, and SQLite full-text search | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface | | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 06bf895e96..2859353cfa 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -157,6 +157,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ 'listSessions(): Promise', 'async listEvents(sessionId: SessionId): Promise', + 'async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFilter[], ): Promise', 'async readEvent(request: SessionEventReadRequest): Promise', ], }, @@ -174,6 +175,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session', ], }, + { + key: 'sessionSearch', + summary: 'Abstract full-text search service implemented by one concrete backend.', + methods: [ + 'abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExecContext, ): Promise>', + 'abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise>', + ], + }, { key: 'skills', summary: 'Registry of skill providers.', @@ -787,6 +796,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SendOptions', declaration: 'export interface SendOptions {\n source?: MessageSource;\n}', }, + { + name: 'SessionAvailability', + declaration: 'export type SessionAvailability = \'live\' | \'persisted\';', + }, { name: 'SessionEvent', declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', @@ -795,6 +808,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventMap', declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: E /* …truncated — full shape in source */', }, + { + name: 'SessionEventMetadataFilter', + declaration: 'export type SessionEventMetadataFilter = Exclude;', + }, { name: 'SessionEventReadRequest', declaration: 'export interface SessionEventReadRequest {\n sessionId: SessionId;\n seq: number;\n before?: number;\n after?: number;\n}', @@ -803,6 +820,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventRecord', declaration: 'export interface SessionEventRecord {\n sessionId: SessionId;\n seq: number;\n type: SessionEventType;\n time: number;\n surface: SessionEventSurface;\n}', }, + { + name: 'SessionEventResultFilter', + declaration: 'export type SessionEventResultFilter = ({\n kind: \'seq\';\n} & SessionResultRange) | ({\n kind: \'time\';\n} & SessionResultRange) | {\n kind: \'type\';\n values: readonly SessionEventType[];\n} | {\n kind: \'surface\';\n values: readonly SessionEventSurface[];\n} | {\n kind: \'text\';\n text: string;\n};', + }, + { + name: 'SessionEventSearchDocument', + declaration: 'export interface SessionEventSearchDocument extends SessionEventRecord {\n text: string;\n}', + }, + { + name: 'SessionEventSearchHit', + declaration: 'export interface SessionEventSearchHit extends SessionEventRecord {\n snippet: string;\n}', + }, + { + name: 'SessionEventSearchRequest', + declaration: 'export interface SessionEventSearchRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: string;\n}', + }, { name: 'SessionEventSurface', declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';', @@ -831,6 +864,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', }, + { + name: 'SessionResultFilter', + declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | ({\n kind: \'created-at\';\n} & SessionResultRange) | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly SessionAvailability[];\n};', + }, + { + name: 'SessionResultRange', + declaration: 'export interface SessionResultRange {\n from?: number;\n to?: number;\n}', + }, + { + name: 'SessionSearchExecContext', + declaration: 'export interface SessionSearchExecContext {\n signal?: AbortSignal;\n}', + }, + { + name: 'SessionSearchHit', + declaration: 'export interface SessionSearchHit extends SessionRecord {\n bestMatch: SessionEventSearchHit;\n}', + }, + { + name: 'SessionSearchPage', + declaration: 'export interface SessionSearchPage {\n items: readonly T[];\n nextCursor?: string;\n}', + }, + { + name: 'SessionSearchRequest', + declaration: 'export interface SessionSearchRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: string;\n}', + }, { name: 'SkillCandidate', declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly>;\n}', diff --git a/packages/session-query/README.md b/packages/session-query/README.md index 8b0c06a30c..0622858294 100644 --- a/packages/session-query/README.md +++ b/packages/session-query/README.md @@ -1,9 +1,10 @@ # session-query/ — session retrieval capability family -Trusted exact reads over live and durable session logs. Phase one contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, and bounded event reads. +Trusted exact reads, provider-independent semantic filtering, and SQLite full-text search over live and durable session logs. | Package | Role | ctx key | |---|---|---| -| [`session-query/`](session-query/README.md) | Logical-corpus and exact-event read service | `ctx.sessionQuery` | +| [`session-query/`](session-query/README.md) | Logical-corpus reads, semantic extraction/filtering, and the abstract search seam | `ctx.sessionQuery`, `ctx.sessionSearch` | +| [`session-query-sqlite/`](session-query-sqlite/README.md) | SQLite FTS5 search with persistent bases and live overlays | `ctx.sessionSearch` | -The family is independent of compaction: it reads the canonical session log but does not participate in compaction policy or execution. Full-text search remains proposed as a phase-two SQLite package rather than a speculative provider seam in this interface package. +The family is independent of compaction: it reads the canonical session log but does not participate in compaction policy or execution. Search uses one abstract service and one concrete owner, not a provider registry or coordinator. diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md new file mode 100644 index 0000000000..1f3344887f --- /dev/null +++ b/packages/session-query/session-query-sqlite/README.md @@ -0,0 +1,35 @@ +# @deepseek-ai/dsh-session-query-sqlite + +SQLite FTS5 implementation of `ctx.sessionSearch`. The service searches the live-preferred logical session corpus, groups cross-session results by their strongest event, and keeps provider-specific BM25 scores private. + +## Search contract + +`searchSessions(request, exec?)` returns `SessionSearchHit` pages across the corpus; `searchEvents(request, exec?)` returns `SessionEventSearchHit` pages within one session. Queries are required, trimmed, whitespace-normalized literal phrases. FTS5 syntax such as quotes, `OR`, `NEAR`, and `*` is treated as data rather than executable MATCH syntax. Metadata filters are parameterized SQL predicates applied before ranking. + +Ordering is deterministic: relevance first, then event time, session id where applicable, and seq. Cross-session results expose the selected event as `bestMatch`; both scopes return plain-text snippets bounded in Unicode code points. Cursors are opaque, bind to the normalized request and service instance, and fail when the relevant generation changes. A within-session cursor survives unrelated-session changes; a cross-session cursor does not. + +All three surfaces (`current`, `shadowed`, and `log-only`) are searchable by default. Pass a surface filter to narrow them. + +## Source and index lifecycle + +The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine observes complete sources, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Stable fingerprints preserve unchanged persisted rows and generations; new, changed, and deleted durable sessions reconcile on the next search. Source or transaction failure commits nothing, and the next search retries. + +Persisted FTS rows live in a dedicated derived database. Connection-local TEMP tables hold live rows, which shadow the durable base for the same session and reveal it when the live owner disappears. Unmounting persistence hides durable rows without discarding the cache; remounting reconciles it. Closing or reopening the database drops every live overlay while retaining persisted rows. + +The database is disposable but reset is guarded: a recognized incompatible search schema rebuilds in place, while an unrelated or canonical database is refused. Never point `path` at the session-persistence database. + +## Configuration + +| Key | Default | Contract | +|---|---:|---| +| `path` | required | Dedicated derived-index SQLite path; `:memory:` is supported. | +| `journalMode` | `wal` | `wal`, `delete`, `truncate`, or `persist`. | +| `defaultLimit` | `20` | Page size when a request omits `limit`. | +| `maxLimit` | `100` | Largest accepted request page size. | +| `snippetChars` | `240` | Maximum snippet length in Unicode code points. | + +## Tokenizer and limits + +The index uses FTS5 `unicode61`. In the implementation experiment it supported the two-character query `AI` and produced an index about 2.1× smaller than the trigram alternative. The trade-off is token/phrase recall rather than arbitrary substring recall: `AI` does not match the token `BRAID`. Use `ctx.sessionQuery.filterEvents()` with a `text` clause when a literal whitespace-flexible substring scan is required. + +Abort signals stop queued work and caller waits around asynchronous source observation. Node's synchronous `DatabaseSync` API cannot interrupt a MATCH statement already executing on the JavaScript thread; the signal is checked immediately before and after the serialized observation/reconciliation boundary. diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json new file mode 100644 index 0000000000..de5677fd68 --- /dev/null +++ b/packages/session-query/session-query-sqlite/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-session-query-sqlite", + "description": "SQLite FTS5 implementation of ctx.sessionSearch", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-session-query": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-session-persistence": { + "optional": true + } + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts new file mode 100644 index 0000000000..a6cba8d866 --- /dev/null +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -0,0 +1,765 @@ +/** + * SQLite FTS5 search over the live-preferred logical session corpus. + * + * @module @deepseek-ai/dsh-session-query-sqlite + */ + +import { createHash, randomUUID } from 'node:crypto' +import { DatabaseSync } from 'node:sqlite' +import { Context } from 'cordis' +import z from 'schemastery' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import { + SessionQueryError, + SessionSearchService, + assertSessionHeadersCompatible, + buildSessionEventSearchDocuments, +} from '@deepseek-ai/dsh-session-query' +import type { + SessionEventSearchDocument, + SessionEventSearchHit, + SessionEventSearchRequest, + SessionSearchExecContext, + SessionSearchHit, + SessionSearchPage, + SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' +import { + type JournalMode, + openSearchDatabase, +} from './schema.ts' +import { + type NormalizedEventRequest, + type NormalizedSessionRequest, + buildEventWhere, + buildSessionWhere, + makeSnippet, + normalizeEventRequest, + normalizeSessionRequest, + quoteFtsData, + requestFingerprint, +} from './query.ts' + +export { + SESSION_QUERY_SQLITE_APPLICATION_ID, + SESSION_QUERY_SQLITE_SCHEMA_VERSION, + type JournalMode, +} from './schema.ts' + +/** Default result page size. */ +export const SESSION_QUERY_SQLITE_DEFAULT_LIMIT = 20 +/** Maximum accepted result page size. */ +export const SESSION_QUERY_SQLITE_MAX_LIMIT = 100 +/** Default maximum snippet length in Unicode code points. */ +export const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240 + +/** SQLite session-search configuration. */ +export interface Config { + /** Dedicated derived-index path; `:memory:` is supported for tests. */ + path: string + /** SQLite journal mode. Defaults to `wal`. */ + journalMode?: JournalMode + /** Page size when a request omits `limit`. Defaults to 20. */ + defaultLimit?: number + /** Largest accepted page size. Defaults to 100. */ + maxLimit?: number + /** Maximum snippet length in Unicode code points. Defaults to 240. */ + snippetChars?: number +} + +interface ResolvedConfig { + path: string + journalMode: JournalMode + defaultLimit: number + maxLimit: number + snippetChars: number +} + +interface ObservedSession { + header: SessionHeader + events: SessionEvent[] + documents: SessionEventSearchDocument[] + fingerprint: string +} + +interface Observation { + persistence: SessionPersistence | undefined + persistenceRevision: number + persisted: Map + live: Map +} + +interface IndexedRow { + id: string + fingerprint: string + generation: number +} + +interface SearchRow { + session_id: string + version: number + created_at: number + cwd: string | null + parent_session: string | null + seed_length: number | null + live: number + persisted: number + seq: number + type: string + time: number + surface: string + text: string + score: number +} + +interface CursorPayload { + version: 1 + instance: string + scope: 'sessions' | 'events' + fingerprint: string + generation: string + offset: number +} + +/** Concrete SQLite owner of `ctx.sessionSearch`. */ +export class SessionSearchSqlite extends SessionSearchService { + static inject = ['sessions'] + + static Config: z = z.object({ + path: z.string().required(), + journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'), + defaultLimit: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_DEFAULT_LIMIT), + maxLimit: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_MAX_LIMIT), + snippetChars: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_SNIPPET_CHARS), + }) + + /** Validated and defaulted backend configuration. */ + readonly config: ResolvedConfig + + private readonly _instance = randomUUID() + private readonly _ready: Promise + private _db: DatabaseSync | undefined + private _persistence: SessionPersistence | undefined + private _persistenceBinding: object | undefined + private _persistenceRevision = 0 + private _lastPersistenceRevision: number | undefined + private _persistenceEpoch = 0 + private _globalGeneration = 0 + private _localGeneration = 0 + private _tail: Promise = Promise.resolve() + private _closed = false + + constructor(ctx: Context, config: Config) { + super(ctx) + this.config = resolveConfig(config) + this._ready = this._open() + ctx.effect(() => { + const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { + const service = childCtx.sessionPersistence + const binding = {} + this._persistenceBinding = binding + this._persistence = service + this._persistenceRevision += 1 + childCtx.effect(() => () => { + /* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */ + if (this._persistenceBinding !== binding) return + this._persistenceBinding = undefined + this._persistence = undefined + this._persistenceRevision += 1 + }, 'sessionSearchSqlite.persistenceBinding') + }) + return () => void fiber.dispose() + }, 'sessionSearchSqlite.optionalPersistence') + ctx.effect(() => async () => this.close(), 'sessionSearchSqlite.close') + } + + override async searchSessions( + request: SessionSearchRequest, + exec?: SessionSearchExecContext, + ): Promise> { + const normalized = normalizeSessionRequest(request, this.config) + return this._serialized(exec?.signal, async () => { + await this._ensureReady(exec?.signal) + await this._reconcile(exec?.signal) + assertNotAborted(exec?.signal) + const generation = String(this._globalGeneration) + const fingerprint = requestFingerprint(normalized) + const offset = normalized.cursor === undefined + ? 0 + : decodeCursor(normalized.cursor, this._instance, 'sessions', fingerprint, generation) + const rows = this._querySessions(normalized, offset) + return page(rows, normalized.limit, row => this._sessionHit(row, normalized.query), cursorOffset => encodeCursor({ + version: 1, + instance: this._instance, + scope: 'sessions', + fingerprint, + generation, + offset: cursorOffset, + }), offset) + }) + } + + override async searchEvents( + request: SessionEventSearchRequest, + exec?: SessionSearchExecContext, + ): Promise> { + const normalized = normalizeEventRequest(request, this.config) + return this._serialized(exec?.signal, async () => { + await this._ensureReady(exec?.signal) + await this._reconcile(exec?.signal) + assertNotAborted(exec?.signal) + const generation = this._targetGeneration(normalized.sessionId) + const fingerprint = requestFingerprint(normalized) + const offset = normalized.cursor === undefined + ? 0 + : decodeCursor(normalized.cursor, this._instance, 'events', fingerprint, generation) + const rows = this._queryEvents(normalized, offset) + return page(rows, normalized.limit, row => this._eventHit(row, normalized.query), cursorOffset => encodeCursor({ + version: 1, + instance: this._instance, + scope: 'events', + fingerprint, + generation, + offset: cursorOffset, + }), offset) + }) + } + + /** Close the database after every accepted operation reaches quiescence. */ + async close(): Promise { + if (this._closed) return + this._closed = true + await this._tail + try { + await this._ready + } catch { + // Opening already closed a partially-created handle; disposal only waits. + } + this._db?.close() + this._db = undefined + } + + private async _open(): Promise { + this._db = await openSearchDatabase(this.config.path, this.config.journalMode) + const state = this._db.prepare( + 'SELECT global_generation FROM search_state WHERE singleton = 1', + ).get() as { global_generation: number } + this._globalGeneration = state.global_generation + this._localGeneration = state.global_generation + } + + private async _ensureReady(signal: AbortSignal | undefined): Promise { + try { + await waitWithAbort(this._ready, signal) + } catch (error: unknown) { + if (isAbort(error)) throw error + throw new SessionQueryError( + `session-search SQLite index failed to open: ${errorMessage(error)}`, + 'SESSION_QUERY_INDEX_FAILED', + { cause: error }, + ) + } + } + + private async _serialized(signal: AbortSignal | undefined, operation: () => Promise): Promise { + if (this._isClosed()) throw indexClosed() + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const prior = this._tail + this._tail = prior.then(() => gate) + try { + await waitWithAbort(prior, signal) + } catch (error: unknown) { + release() + throw error + } + if (this._isClosed()) { + release() + throw indexClosed() + } + try { + assertNotAborted(signal) + return await operation() + } finally { + release() + } + } + + private async _reconcile(signal: AbortSignal | undefined): Promise { + const observation = await this._observeStable(signal) + assertNotAborted(signal) + const db = this._requireDb() + const persistedRows = db.prepare( + 'SELECT id, fingerprint, generation FROM persisted_sessions', + ).all() as unknown as IndexedRow[] + const liveRows = db.prepare( + 'SELECT id, fingerprint, generation FROM temp.live_sessions', + ).all() as unknown as IndexedRow[] + const persistedById = new Map(persistedRows.map(row => [row.id as SessionId, row])) + const liveById = new Map(liveRows.map(row => [row.id as SessionId, row])) + const persistentChanges = observation.persistence === undefined + ? [] + : [...observation.persisted.values()].filter(entry => persistedById.get(entry.header.id)?.fingerprint !== entry.fingerprint) + const persistentDeletes = observation.persistence === undefined + ? [] + : persistedRows.filter(row => !observation.persisted.has(row.id as SessionId)) + const liveChanges = [...observation.live.values()].filter(entry => liveById.get(entry.header.id)?.fingerprint !== entry.fingerprint) + const liveDeletes = liveRows.filter(row => !observation.live.has(row.id as SessionId)) + const pointerChanged = this._lastPersistenceRevision !== undefined + && this._lastPersistenceRevision !== observation.persistenceRevision + const hasWrites = persistentChanges.length > 0 + || persistentDeletes.length > 0 + || liveChanges.length > 0 + || liveDeletes.length > 0 + + let nextMainGeneration = this._mainGeneration() + let nextLocalGeneration = this._localGeneration + if (persistentChanges.length > 0 || persistentDeletes.length > 0) nextMainGeneration += 1 + const liveReplacements = liveChanges.map((entry) => { + nextLocalGeneration = Math.max(nextLocalGeneration, nextMainGeneration) + 1 + return { entry, generation: nextLocalGeneration } + }) + + if (hasWrites) { + let began = false + try { + db.exec('BEGIN IMMEDIATE') + began = true + for (const row of persistentDeletes) this._deleteSession('persisted', row.id as SessionId) + for (const entry of persistentChanges) this._replaceSession('persisted', entry, nextMainGeneration) + if (persistentChanges.length > 0 || persistentDeletes.length > 0) { + db.prepare('UPDATE search_state SET global_generation = ? WHERE singleton = 1').run(nextMainGeneration) + } + for (const row of liveDeletes) this._deleteSession('live', row.id as SessionId) + for (const { entry, generation } of liveReplacements) { + this._replaceSession('live', entry, generation) + } + db.exec('COMMIT') + } catch (error: unknown) { + /* v8 ignore next -- a BEGIN failure has no transaction to roll back; the common wrapper still reports it. */ + if (began) { + /* v8 ignore next 5 -- ROLLBACK failure requires a SQLite double fault; the original failure remains actionable. */ + try { + db.exec('ROLLBACK') + } catch { + // The original SQLite failure remains the actionable cause. + } + } + throw new SessionQueryError( + `session-search reconciliation failed: ${errorMessage(error)}`, + 'SESSION_QUERY_INDEX_FAILED', + { cause: error }, + ) + } + } + + if (hasWrites || pointerChanged) this._globalGeneration += 1 + if (pointerChanged) this._persistenceEpoch += 1 + this._localGeneration = nextLocalGeneration + this._lastPersistenceRevision = observation.persistenceRevision + } + + private async _observeStable(signal: AbortSignal | undefined): Promise { + for (;;) { + assertNotAborted(signal) + const persistence = this._persistence + const persistenceRevision = this._persistenceRevision + const persisted = new Map() + if (persistence !== undefined) { + try { + const headers = await waitWithAbort(persistence.list(), signal) + for (const listed of headers) { + const loaded = await waitWithAbort(persistence.load(listed.id), signal) + assertSessionHeadersCompatible(listed, loaded.meta) + persisted.set(listed.id, observeSession(loaded.meta, loaded.events)) + } + } catch (error: unknown) { + if (error instanceof SessionQueryError) throw error + throw new SessionQueryError( + `session-search persistence observation failed: ${errorMessage(error)}`, + 'SESSION_QUERY_PERSISTENCE_FAILED', + { cause: error }, + ) + } + } + const live = new Map() + for (const session of this.ctx.sessions.list()) { + const observed = observeLive(session) + const durable = persisted.get(session.id) + if (durable !== undefined) assertSessionHeadersCompatible(observed.header, durable.header) + live.set(session.id, observed) + } + if (this._persistenceRevision === persistenceRevision) { + return { persistence, persistenceRevision, persisted, live } + } + } + } + + private _mainGeneration(): number { + const row = this._requireDb().prepare( + 'SELECT global_generation FROM search_state WHERE singleton = 1', + ).get() as { global_generation: number } + return row.global_generation + } + + private _deleteSession(source: 'persisted' | 'live', id: SessionId): void { + const db = this._requireDb() + if (source === 'persisted') { + db.prepare('DELETE FROM persisted_docs WHERE session_id = ?').run(id) + db.prepare('DELETE FROM persisted_sessions WHERE id = ?').run(id) + } else { + db.prepare('DELETE FROM temp.live_docs WHERE session_id = ?').run(id) + db.prepare('DELETE FROM temp.live_sessions WHERE id = ?').run(id) + } + } + + private _replaceSession(source: 'persisted' | 'live', entry: ObservedSession, generation: number): void { + this._deleteSession(source, entry.header.id) + const db = this._requireDb() + const sessionTable = source === 'persisted' ? 'persisted_sessions' : 'temp.live_sessions' + const docsTable = source === 'persisted' ? 'persisted_docs' : 'temp.live_docs' + db.prepare(` + INSERT INTO ${sessionTable} + (id, version, created_at, cwd, parent_session, seed_length, fingerprint, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + entry.header.id, + entry.header.version, + entry.header.createdAt, + entry.header.cwd ?? null, + entry.header.parentSession ?? null, + entry.header.seedLength ?? null, + entry.fingerprint, + generation, + ) + const insert = db.prepare(` + INSERT INTO ${docsTable} (text, session_id, seq, type, time, surface) + VALUES (?, ?, ?, ?, ?, ?) + `) + for (const document of entry.documents) { + insert.run(document.text, document.sessionId, document.seq, document.type, document.time, document.surface) + } + } + + private _querySessions(request: NormalizedSessionRequest, offset: number): SearchRow[] { + const selected = selectedDocumentsSql() + const sessionWhere = buildSessionWhere(request.sessionFilters) + const eventWhere = buildEventWhere(request.eventFilters) + const where = [sessionWhere.sql, eventWhere.sql].filter(Boolean).join(' AND ') + return this._requireDb().prepare(` + ${selected.sql}, + filtered AS ( + SELECT * FROM matched ${where.length === 0 ? '' : `WHERE ${where}`} + ), + ranked AS ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY session_id + ORDER BY score ASC, time DESC, seq DESC + ) AS event_rank + FROM filtered + ) + SELECT * FROM ranked + WHERE event_rank = 1 + ORDER BY score ASC, time DESC, session_id ASC, seq DESC + LIMIT ? OFFSET ? + `).all( + quoteFtsData(request.query), + this._persistence === undefined ? 0 : 1, + this._persistence === undefined ? 0 : 1, + quoteFtsData(request.query), + ...sessionWhere.params, + ...eventWhere.params, + request.limit + 1, + offset, + ) as unknown as SearchRow[] + } + + private _queryEvents(request: NormalizedEventRequest, offset: number): SearchRow[] { + const selected = selectedDocumentsSql() + const eventWhere = buildEventWhere(request.filters) + const where = ['session_id = ?', eventWhere.sql].filter(Boolean).join(' AND ') + return this._requireDb().prepare(` + ${selected.sql} + SELECT * FROM matched + WHERE ${where} + ORDER BY score ASC, time DESC, seq DESC + LIMIT ? OFFSET ? + `).all( + quoteFtsData(request.query), + this._persistence === undefined ? 0 : 1, + this._persistence === undefined ? 0 : 1, + quoteFtsData(request.query), + request.sessionId, + ...eventWhere.params, + request.limit + 1, + offset, + ) as unknown as SearchRow[] + } + + private _targetGeneration(sessionId: SessionId): string { + const db = this._requireDb() + const live = db.prepare( + 'SELECT generation FROM temp.live_sessions WHERE id = ?', + ).get(sessionId) as { generation: number } | undefined + if (live !== undefined) return `live:${live.generation}` + if (this._persistence !== undefined) { + const persisted = db.prepare( + 'SELECT generation FROM persisted_sessions WHERE id = ?', + ).get(sessionId) as { generation: number } | undefined + if (persisted !== undefined) return `persisted:${this._persistenceEpoch}:${persisted.generation}` + } + throw new SessionQueryError( + `session "${sessionId}" not found`, + 'SESSION_QUERY_SESSION_NOT_FOUND', + ) + } + + private _sessionHit(row: SearchRow, query: string): SessionSearchHit { + return { + header: rowHeader(row), + live: row.live === 1, + persisted: row.persisted === 1, + bestMatch: this._eventHit(row, query), + } + } + + private _eventHit(row: SearchRow, query: string): SessionEventSearchHit { + return { + sessionId: row.session_id as SessionId, + seq: row.seq, + type: row.type as SessionEventSearchHit['type'], + time: row.time, + surface: row.surface as SessionEventSearchHit['surface'], + snippet: makeSnippet(row.text, query, this.config.snippetChars), + } + } + + private _requireDb(): DatabaseSync { + /* v8 ignore next -- callers await `_ready`; this guards lifecycle misuse */ + if (this._db === undefined) throw indexClosed() + return this._db + } + + private _isClosed(): boolean { + return this._closed + } +} + +function selectedDocumentsSql(): { sql: string } { + return { + sql: `WITH matched AS ( + SELECT + pd.session_id AS session_id, + ps.version AS version, + ps.created_at AS created_at, + ps.cwd AS cwd, + ps.parent_session AS parent_session, + ps.seed_length AS seed_length, + 0 AS live, + 1 AS persisted, + CAST(pd.seq AS INTEGER) AS seq, + pd.type AS type, + CAST(pd.time AS INTEGER) AS time, + pd.surface AS surface, + pd.text AS text, + bm25(persisted_docs) AS score + FROM persisted_docs AS pd + JOIN persisted_sessions AS ps ON ps.id = pd.session_id + WHERE persisted_docs MATCH ? + AND ? = 1 + AND NOT EXISTS (SELECT 1 FROM temp.live_sessions AS ls WHERE ls.id = pd.session_id) + UNION ALL + SELECT + ld.session_id AS session_id, + ls.version AS version, + ls.created_at AS created_at, + ls.cwd AS cwd, + ls.parent_session AS parent_session, + ls.seed_length AS seed_length, + 1 AS live, + CASE WHEN ? = 1 AND EXISTS ( + SELECT 1 FROM persisted_sessions AS ps WHERE ps.id = ld.session_id + ) THEN 1 ELSE 0 END AS persisted, + CAST(ld.seq AS INTEGER) AS seq, + ld.type AS type, + CAST(ld.time AS INTEGER) AS time, + ld.surface AS surface, + ld.text AS text, + bm25(live_docs) AS score + FROM temp.live_docs AS ld + JOIN temp.live_sessions AS ls ON ls.id = ld.session_id + WHERE live_docs MATCH ? + )`, + } +} + +function observeLive(session: Session): ObservedSession { + return observeSession( + structuredClone(session.header), + session.events.map(event => structuredClone(event)), + ) +} + +function observeSession(header: SessionHeader, events: readonly SessionEvent[]): ObservedSession { + const detachedHeader = structuredClone(header) + const detachedEvents = events.map(event => structuredClone(event)) + return { + header: detachedHeader, + events: detachedEvents, + documents: buildSessionEventSearchDocuments(detachedHeader.id, detachedEvents), + fingerprint: createHash('sha256') + .update(JSON.stringify({ header: detachedHeader, events: detachedEvents })) + .digest('base64url'), + } +} + +function rowHeader(row: SearchRow): SessionHeader { + return { + version: row.version, + id: row.session_id as SessionId, + createdAt: row.created_at, + ...row.cwd === null ? {} : { cwd: row.cwd }, + ...row.parent_session === null ? {} : { parentSession: row.parent_session as SessionId }, + ...row.seed_length === null ? {} : { seedLength: row.seed_length }, + } +} + +function page( + rows: readonly Row[], + limit: number, + convert: (row: Row) => Item, + nextCursor: (offset: number) => string, + offset: number, +): SessionSearchPage { + const hasMore = rows.length > limit + return { + items: rows.slice(0, limit).map(convert), + ...hasMore ? { nextCursor: nextCursor(offset + limit) } : {}, + } +} + +function encodeCursor(payload: CursorPayload): string { + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') +} + +function decodeCursor( + cursor: string, + instance: string, + scope: CursorPayload['scope'], + fingerprint: string, + generation: string, +): number { + let decoded: Partial + try { + decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as Partial + } catch (error: unknown) { + throw invalidCursor(error) + } + if ( + decoded.version !== 1 + || decoded.instance !== instance + || decoded.scope !== scope + || decoded.fingerprint !== fingerprint + || !Number.isInteger(decoded.offset) + || decoded.offset === undefined + || decoded.offset < 0 + ) { + throw invalidCursor(new Error('cursor does not belong to this normalized request')) + } + if (decoded.generation !== generation) { + throw new SessionQueryError( + 'session-search cursor is stale because its relevant corpus changed', + 'SESSION_QUERY_STALE_CURSOR', + ) + } + return decoded.offset +} + +function invalidCursor(cause: unknown): SessionQueryError { + return new SessionQueryError( + 'session-search cursor is invalid', + 'SESSION_QUERY_INVALID_CURSOR', + { cause }, + ) +} + +function resolveConfig(config: Config): ResolvedConfig { + const resolved: ResolvedConfig = { + path: config.path, + journalMode: config.journalMode ?? 'wal', + defaultLimit: config.defaultLimit ?? SESSION_QUERY_SQLITE_DEFAULT_LIMIT, + maxLimit: config.maxLimit ?? SESSION_QUERY_SQLITE_MAX_LIMIT, + snippetChars: config.snippetChars ?? SESSION_QUERY_SQLITE_SNIPPET_CHARS, + } + if (typeof resolved.path !== 'string' || resolved.path.trim().length === 0) { + throw invalidConfig('path must not be blank') + } + assertPositiveInteger('defaultLimit', resolved.defaultLimit) + assertPositiveInteger('maxLimit', resolved.maxLimit) + assertPositiveInteger('snippetChars', resolved.snippetChars) + if (resolved.defaultLimit > resolved.maxLimit) { + throw invalidConfig('defaultLimit must be less than or equal to maxLimit') + } + const journalModes: readonly string[] = ['wal', 'delete', 'truncate', 'persist'] + if (!journalModes.includes(resolved.journalMode)) throw invalidConfig('journalMode is not supported') + return resolved +} + +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) throw invalidConfig(`${name} must be a positive integer`) +} + +function invalidConfig(detail: string): SessionQueryError { + return new SessionQueryError( + `session-search SQLite config: ${detail}`, + 'SESSION_QUERY_INVALID_CONFIG', + ) +} + +function indexClosed(): SessionQueryError { + return new SessionQueryError('session-search SQLite index is closed', 'SESSION_QUERY_INDEX_FAILED') +} + +function assertNotAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED') + } +} + +function waitWithAbort(promise: Promise, signal: AbortSignal | undefined): Promise { + if (signal === undefined) return promise + if (signal.aborted) return Promise.reject(new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED')) + return new Promise((resolve, reject) => { + const onAbort = () => { + reject(new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED')) + } + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(asError(error)) + }, + ) + }) +} + +function isAbort(error: unknown): boolean { + return error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED' +} + +function asError(error: unknown): Error { + return error instanceof Error + ? error + : new Error('session-search dependency rejected with a non-Error value', { cause: error }) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'unknown error' +} + +export default SessionSearchSqlite diff --git a/packages/session-query/session-query-sqlite/src/query.ts b/packages/session-query/session-query-sqlite/src/query.ts new file mode 100644 index 0000000000..fd2b5156e6 --- /dev/null +++ b/packages/session-query/session-query-sqlite/src/query.ts @@ -0,0 +1,312 @@ +/** Request normalization, parameterized predicates, and result presentation. */ + +import { + SessionQueryError, + filterSessionEventDocuments, + filterSessionResults, +} from '@deepseek-ai/dsh-session-query' +import type { + SessionEventMetadataFilter, + SessionEventSearchRequest, + SessionResultFilter, + SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' + +/** Limit defaults needed to normalize a search request. */ +export interface QueryLimits { + /** Page size used when the request omits one. */ + defaultLimit: number + /** Largest accepted page size. */ + maxLimit: number +} + +/** Normalized cross-session request. */ +export interface NormalizedSessionRequest { + query: string + sessionFilters: readonly SessionResultFilter[] + eventFilters: readonly SessionEventMetadataFilter[] + limit: number + cursor?: string +} + +/** Normalized within-session request. */ +export interface NormalizedEventRequest { + sessionId: SessionEventSearchRequest['sessionId'] + query: string + filters: readonly SessionEventMetadataFilter[] + limit: number + cursor?: string +} + +/** Parameterized SQL predicate fragment. */ +export interface SqlWhere { + /** SQL without the leading `WHERE`. */ + sql: string + /** Bindings in placeholder order. */ + params: Array +} + +/** + * Validate and canonicalize a cross-session request. + * @param request - caller-provided query, filters, limit, and cursor. + * @param limits - configured default and maximum page sizes. + * @returns normalized request with explicit arrays and limit. + */ +export function normalizeSessionRequest( + request: SessionSearchRequest, + limits: QueryLimits, +): NormalizedSessionRequest { + const sessionFilters = request.sessionFilters ?? [] + const eventFilters = request.eventFilters ?? [] + filterSessionResults([], sessionFilters) + filterSessionEventDocuments([], eventFilters) + return { + query: normalizeQuery(request.query), + sessionFilters, + eventFilters, + limit: normalizeLimit(request.limit, limits), + ...request.cursor === undefined ? {} : { cursor: request.cursor }, + } +} + +/** + * Validate and canonicalize a within-session request. + * @param request - caller-provided target, query, filters, limit, and cursor. + * @param limits - configured default and maximum page sizes. + * @returns normalized request with an explicit filter array and limit. + */ +export function normalizeEventRequest( + request: SessionEventSearchRequest, + limits: QueryLimits, +): NormalizedEventRequest { + const filters = request.filters ?? [] + filterSessionEventDocuments([], filters) + return { + sessionId: request.sessionId, + query: normalizeQuery(request.query), + filters, + limit: normalizeLimit(request.limit, limits), + ...request.cursor === undefined ? {} : { cursor: request.cursor }, + } +} + +/** + * Compile logical-session predicates against selected-document columns. + * @param filters - validated ANDed logical-session clauses. + * @returns parameterized SQL fragment and ordered bindings. + */ +export function buildSessionWhere(filters: readonly SessionResultFilter[]): SqlWhere { + const clauses: string[] = [] + const params: Array = [] + for (const filter of filters) { + switch (filter.kind) { + case 'id': + addList(clauses, params, 'session_id', filter.values) + break + case 'cwd': + addNullableList(clauses, params, 'cwd', filter.values) + break + case 'created-at': + addRange(clauses, params, 'created_at', filter) + break + case 'parent': + addNullableList(clauses, params, 'parent_session', filter.values) + break + case 'availability': { + const availability = [...new Set(filter.values)] + if (availability.length === 0) clauses.push('0') + else if (availability.length === 1) clauses.push(`${availability[0]} = 1`) + break + } + } + } + return { sql: clauses.join(' AND '), params } +} + +/** + * Compile event metadata predicates against selected-document columns. + * @param filters - validated ANDed event metadata clauses. + * @returns parameterized SQL fragment and ordered bindings. + */ +export function buildEventWhere(filters: readonly SessionEventMetadataFilter[]): SqlWhere { + const clauses: string[] = [] + const params: Array = [] + for (const filter of filters) { + switch (filter.kind) { + case 'seq': + addRange(clauses, params, 'seq', filter) + break + case 'time': + addRange(clauses, params, 'time', filter) + break + case 'type': + addList(clauses, params, 'type', filter.values) + break + case 'surface': + addList(clauses, params, 'surface', filter.values) + break + } + } + return { sql: clauses.join(' AND '), params } +} + +/** + * Quote caller text as one FTS5 phrase so query syntax remains inert data. + * @param query - normalized caller query. + * @returns FTS5 expression containing one escaped literal phrase. + */ +export function quoteFtsData(query: string): string { + return `"${query.replaceAll('"', '""')}"` +} + +/** + * Build the stable normalized request identity stored in opaque cursors. + * @param request - normalized request whose filter ordering is canonicalized. + * @returns deterministic JSON identity for cursor binding. + */ +export function requestFingerprint(request: NormalizedSessionRequest | NormalizedEventRequest): string { + if ('sessionId' in request) { + return JSON.stringify({ + scope: 'events', + sessionId: request.sessionId, + query: request.query, + filters: canonicalFilters(request.filters), + limit: request.limit, + }) + } + return JSON.stringify({ + scope: 'sessions', + query: request.query, + sessionFilters: canonicalFilters(request.sessionFilters), + eventFilters: canonicalFilters(request.eventFilters), + limit: request.limit, + }) +} + +/** + * Build a whitespace-normalized excerpt no longer than `maxChars`. + * @param text - complete extracted semantic document. + * @param query - normalized literal query used to position the excerpt. + * @param maxChars - maximum result length in Unicode code points. + * @returns bounded plain-text snippet. + */ +export function makeSnippet(text: string, query: string, maxChars: number): string { + const clean = text.replace(/\s+/gu, ' ').trim() + const characters = Array.from(clean) + if (characters.length <= maxChars) return clean + if (maxChars === 1) return '…' + const foundUnits = clean.toLowerCase().indexOf(query.toLowerCase()) + const found = foundUnits < 0 ? -1 : Array.from(clean.slice(0, foundUnits)).length + let start = found < 0 ? 0 : Math.max(0, found - Math.floor(maxChars / 3)) + let prefix = start > 0 ? '…' : '' + let suffix = '…' + let contentLength = maxChars - prefix.length - suffix.length + if (contentLength < 1) { + start = 0 + prefix = '' + contentLength = maxChars - 1 + } + let end = Math.min(characters.length, start + contentLength) + if (end === characters.length) { + suffix = '' + contentLength = maxChars - prefix.length + start = Math.max(0, end - contentLength) + } + end = Math.min(characters.length, start + contentLength) + return `${prefix}${characters.slice(start, end).join('')}${suffix}` +} + +function normalizeQuery(value: string): string { + if (typeof value !== 'string') { + throw new SessionQueryError('session-search query must be text', 'SESSION_QUERY_INVALID_QUERY') + } + const query = value.trim().replace(/\s+/gu, ' ') + if (query.length === 0) { + throw new SessionQueryError( + 'session-search query must contain non-whitespace text', + 'SESSION_QUERY_INVALID_QUERY', + ) + } + return query +} + +function normalizeLimit(value: number | undefined, limits: QueryLimits): number { + const limit = value ?? limits.defaultLimit + if (!Number.isInteger(limit) || limit < 1 || limit > limits.maxLimit) { + throw new SessionQueryError( + `session-search limit must be an integer between 1 and ${limits.maxLimit}`, + 'SESSION_QUERY_INVALID_LIMIT', + ) + } + return limit +} + +function addList( + clauses: string[], + params: Array, + column: string, + values: readonly (string | number)[], +): void { + if (values.length === 0) { + clauses.push('0') + return + } + clauses.push(`${column} IN (${values.map(() => '?').join(', ')})`) + params.push(...values) +} + +function addNullableList( + clauses: string[], + params: Array, + column: string, + values: readonly (string | null)[], +): void { + if (values.length === 0) { + clauses.push('0') + return + } + const concrete = values.filter((value): value is string => value !== null) + const parts: string[] = [] + if (concrete.length > 0) { + parts.push(`${column} IN (${concrete.map(() => '?').join(', ')})`) + params.push(...concrete) + } + if (values.includes(null)) parts.push(`${column} IS NULL`) + clauses.push(`(${parts.join(' OR ')})`) +} + +function addRange( + clauses: string[], + params: Array, + column: string, + range: { from?: number; to?: number }, +): void { + if (range.from !== undefined) { + clauses.push(`CAST(${column} AS INTEGER) >= ?`) + params.push(range.from) + } + if (range.to !== undefined) { + clauses.push(`CAST(${column} AS INTEGER) <= ?`) + params.push(range.to) + } +} + +function canonicalFilters(filters: readonly (SessionResultFilter | SessionEventMetadataFilter)[]): unknown[] { + return filters.map((filter) => { + if ('values' in filter) { + return { ...filter, values: [...filter.values].sort(compareNullable) } + } + return { + kind: filter.kind, + from: filter.from ?? null, + to: filter.to ?? null, + } + }).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) +} + +function compareNullable(a: string | null, b: string | null): number { + if (a === b) return 0 + if (a === null) return -1 + if (b === null) return 1 + return a.localeCompare(b) +} diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts new file mode 100644 index 0000000000..1c9bd98791 --- /dev/null +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -0,0 +1,127 @@ +/** SQLite schema for the disposable session full-text read model. */ + +import { DatabaseSync } from 'node:sqlite' +import { mkdir } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' + +/** Current derived-index schema version. Incompatible versions reset in place. */ +export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 1 + +/** SQLite application id protecting unrelated databases from derived resets. */ +export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 + +/** Supported SQLite journal modes. */ +export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' + +/** + * Open, validate, and initialize persistent and connection-local schemas. + * @param path - dedicated derived-index path or `:memory:`. + * @param journalMode - validated SQLite journal mode. + * @returns initialized database handle owned by the search service. + */ +export async function openSearchDatabase(path: string, journalMode: JournalMode): Promise { + const actual = path === ':memory:' ? path : resolve(path) + if (actual !== ':memory:') await mkdir(dirname(actual), { recursive: true, mode: 0o700 }) + const db = new DatabaseSync(actual) + try { + // journalMode is a validated closed union, not caller-controlled SQL. + db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`) + const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number } + const { user_version: version } = db.prepare('PRAGMA user_version').get() as { user_version: number } + const userTables = listUserTables(db) + if (applicationId !== 0 && applicationId !== SESSION_QUERY_SQLITE_APPLICATION_ID) { + throw new Error(`session-search database at "${actual}" belongs to another application`) + } + if (applicationId === 0 && userTables.length > 0) { + throw new Error(`session-search database at "${actual}" is not an empty or recognized derived index`) + } + if (applicationId === SESSION_QUERY_SQLITE_APPLICATION_ID && version !== SESSION_QUERY_SQLITE_SCHEMA_VERSION) { + resetDerivedSchema(db) + } + ensurePersistentSchema(db) + ensureTemporarySchema(db) + return db + } catch (error: unknown) { + db.close() + throw error + } +} + +function listUserTables(db: DatabaseSync): string[] { + const rows = db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ).all() as Array<{ name: string }> + return rows.map(row => row.name) +} + +function resetDerivedSchema(db: DatabaseSync): void { + for (const name of listUserTables(db)) { + db.exec(`DROP TABLE IF EXISTS ${quoteIdentifier(name)}`) + } + db.exec('PRAGMA user_version = 0') +} + +function ensurePersistentSchema(db: DatabaseSync): void { + db.exec(`PRAGMA application_id = ${SESSION_QUERY_SQLITE_APPLICATION_ID}`) + db.exec(` + CREATE TABLE IF NOT EXISTS search_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + global_generation INTEGER NOT NULL + ) STRICT + `) + db.exec('INSERT OR IGNORE INTO search_state (singleton, global_generation) VALUES (1, 0)') + db.exec(` + CREATE TABLE IF NOT EXISTS persisted_sessions ( + id TEXT PRIMARY KEY, + version INTEGER NOT NULL, + created_at INTEGER NOT NULL, + cwd TEXT, + parent_session TEXT, + seed_length INTEGER, + fingerprint TEXT NOT NULL, + generation INTEGER NOT NULL + ) STRICT + `) + db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS persisted_docs USING fts5( + text, + session_id UNINDEXED, + seq UNINDEXED, + type UNINDEXED, + time UNINDEXED, + surface UNINDEXED, + tokenize = 'unicode61' + ) + `) + db.exec(`PRAGMA user_version = ${SESSION_QUERY_SQLITE_SCHEMA_VERSION}`) +} + +function ensureTemporarySchema(db: DatabaseSync): void { + db.exec(` + CREATE TEMP TABLE IF NOT EXISTS live_sessions ( + id TEXT PRIMARY KEY, + version INTEGER NOT NULL, + created_at INTEGER NOT NULL, + cwd TEXT, + parent_session TEXT, + seed_length INTEGER, + fingerprint TEXT NOT NULL, + generation INTEGER NOT NULL + ) STRICT + `) + db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS temp.live_docs USING fts5( + text, + session_id UNINDEXED, + seq UNINDEXED, + type UNINDEXED, + time UNINDEXED, + surface UNINDEXED, + tokenize = 'unicode61' + ) + `) +} + +function quoteIdentifier(value: string): string { + return `"${value.replaceAll('"', '""')}"` +} diff --git a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts new file mode 100644 index 0000000000..c20a501964 --- /dev/null +++ b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts @@ -0,0 +1,60 @@ +/** + * Keyless real-Loader-path smoke for the SQLite session-search service. + * + * @module @deepseek-ai/dsh-session-query-sqlite/tests/load-path + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore from '@deepseek-ai/dsh-session' +import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' +import SessionSearchSqlite, * as searchModule from '@deepseek-ai/dsh-session-query-sqlite' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await rm(directory, { recursive: true, force: true }) + } +}) + +async function temporaryPath(name: string): Promise { + const directory = await mkdtemp(join(tmpdir(), 'dsh-session-search-loader-')) + temporaryDirectories.push(directory) + return join(directory, name) +} + +describe('dsh-session-query-sqlite real Loader path', () => { + it('unwraps, mounts, and searches the real persistence backend', async () => { + const persistencePath = await temporaryPath('canonical.db') + const searchPath = await temporaryPath('derived.db') + const ctx = new Context() + await ctx.plugin(SessionStore) + const persistence = await ctx.plugin(SessionPersistenceSqlite, { path: persistencePath }) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(searchModule) as Parameters[0] + expect(unwrapped).toBe(SessionSearchSqlite) + const search = await ctx.plugin(unwrapped, { path: searchPath }) + + const id = SessionId('loader-path') + await ctx.sessionPersistence.create({ version: SESSION_FORMAT_VERSION, id, createdAt: 10 }) + await ctx.sessionPersistence.append(id, [{ + type: 'user/message', + seq: 0, + time: 10, + data: { content: [{ type: 'text', text: 'real Loader needle' }], source: { kind: 'user' } }, + surfaceOp: 'append', + }]) + + await expect(ctx.sessionSearch.searchSessions({ query: 'Loader needle' })) + .resolves.toMatchObject({ items: [{ header: { id }, persisted: true, live: false }] }) + await search.dispose() + await persistence.dispose() + }) +}) diff --git a/packages/session-query/session-query-sqlite/tests/query.spec.ts b/packages/session-query/session-query-sqlite/tests/query.spec.ts new file mode 100644 index 0000000000..0faced72e4 --- /dev/null +++ b/packages/session-query/session-query-sqlite/tests/query.spec.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' +import { + buildEventWhere, + buildSessionWhere, + makeSnippet, + normalizeEventRequest, + normalizeSessionRequest, + quoteFtsData, + requestFingerprint, + type NormalizedEventRequest, + type NormalizedSessionRequest, +} from '../src/query.ts' + +const limits = { defaultLimit: 2, maxLimit: 3 } + +function expectCode(code: SessionQueryErrorCode): Error { + return expect.objectContaining({ code }) as Error +} + +describe('SQLite search request normalization', () => { + it('normalizes both scopes, defaults arrays and limits, and preserves cursors', () => { + expect(normalizeSessionRequest({ query: ' alpha\n beta ' }, limits)).toEqual({ + query: 'alpha beta', + sessionFilters: [], + eventFilters: [], + limit: 2, + }) + expect(normalizeSessionRequest({ + query: 'needle', + sessionFilters: [{ kind: 'availability', values: ['live'] }], + eventFilters: [{ kind: 'surface', values: ['current'] }], + limit: 3, + cursor: 'next', + }, limits)).toEqual({ + query: 'needle', + sessionFilters: [{ kind: 'availability', values: ['live'] }], + eventFilters: [{ kind: 'surface', values: ['current'] }], + limit: 3, + cursor: 'next', + }) + expect(normalizeEventRequest({ sessionId: SessionId('s'), query: 'needle' }, limits)).toEqual({ + sessionId: SessionId('s'), + query: 'needle', + filters: [], + limit: 2, + }) + expect(normalizeEventRequest({ + sessionId: SessionId('s'), + query: 'needle', + filters: [{ kind: 'seq', from: 1 }], + cursor: 'next', + }, limits)).toEqual({ + sessionId: SessionId('s'), + query: 'needle', + filters: [{ kind: 'seq', from: 1 }], + limit: 2, + cursor: 'next', + }) + }) + + it('rejects non-text, blank, non-integer, non-positive, and oversized requests', () => { + expect(() => normalizeSessionRequest({ query: 1 as never }, limits)) + .toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) + expect(() => normalizeSessionRequest({ query: ' \n ' }, limits)) + .toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) + for (const limit of [1.5, 0, 4]) { + expect(() => normalizeEventRequest({ sessionId: SessionId('s'), query: 'x', limit }, limits)) + .toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT')) + } + }) +}) + +describe('SQLite search predicate compilation', () => { + it('compiles all logical-session clauses including empty and nullable values', () => { + expect(buildSessionWhere([])).toEqual({ sql: '', params: [] }) + expect(buildSessionWhere([{ kind: 'id', values: [] }])).toEqual({ sql: '0', params: [] }) + expect(buildSessionWhere([{ kind: 'id', values: [SessionId('a'), SessionId('b')] }])).toEqual({ + sql: 'session_id IN (?, ?)', + params: [SessionId('a'), SessionId('b')], + }) + expect(buildSessionWhere([{ kind: 'cwd', values: [] }])).toEqual({ sql: '0', params: [] }) + expect(buildSessionWhere([{ kind: 'cwd', values: [null] }])).toEqual({ + sql: '(cwd IS NULL)', + params: [], + }) + expect(buildSessionWhere([{ kind: 'cwd', values: ['/a'] }])).toEqual({ + sql: '(cwd IN (?))', + params: ['/a'], + }) + expect(buildSessionWhere([{ kind: 'parent', values: [SessionId('p'), null] }])).toEqual({ + sql: '(parent_session IN (?) OR parent_session IS NULL)', + params: [SessionId('p')], + }) + expect(buildSessionWhere([ + { kind: 'created-at', from: 1, to: 2 }, + { kind: 'availability', values: [] }, + { kind: 'availability', values: ['live', 'live'] }, + { kind: 'availability', values: ['live', 'persisted'] }, + ])).toEqual({ + sql: 'CAST(created_at AS INTEGER) >= ? AND CAST(created_at AS INTEGER) <= ? AND 0 AND live = 1', + params: [1, 2], + }) + expect(buildSessionWhere([{ kind: 'created-at' }])).toEqual({ sql: '', params: [] }) + }) + + it('compiles every event clause and empty lists', () => { + expect(buildEventWhere([ + { kind: 'seq', from: 1 }, + { kind: 'time', to: 9 }, + { kind: 'type', values: ['user/message'] }, + { kind: 'surface', values: ['current', 'log-only'] }, + ])).toEqual({ + sql: 'CAST(seq AS INTEGER) >= ? AND CAST(time AS INTEGER) <= ? AND type IN (?) AND surface IN (?, ?)', + params: [1, 9, 'user/message', 'current', 'log-only'], + }) + expect(buildEventWhere([ + { kind: 'type', values: [] }, + { kind: 'surface', values: [] }, + ])).toEqual({ sql: '0 AND 0', params: [] }) + }) +}) + +describe('SQLite query identity and presentation', () => { + it('quotes all caller MATCH syntax as data', () => { + expect(quoteFtsData('say "needle" OR *')).toBe('"say ""needle"" OR *"') + }) + + it('canonicalizes request and filter ordering in both scopes', () => { + const sessionA: NormalizedSessionRequest = { + query: 'needle', + limit: 2, + sessionFilters: [ + { kind: 'cwd', values: ['/b', '/a'] }, + { kind: 'parent', values: [null, SessionId('p')] }, + { kind: 'id', values: [SessionId('same'), SessionId('same')] }, + { kind: 'created-at', from: 1 }, + ], + eventFilters: [{ kind: 'time', to: 9 }], + } + const sessionB: NormalizedSessionRequest = { + query: 'needle', + limit: 2, + sessionFilters: [ + { kind: 'created-at', from: 1 }, + { kind: 'id', values: [SessionId('same'), SessionId('same')] }, + { kind: 'parent', values: [SessionId('p'), null] }, + { kind: 'cwd', values: ['/a', '/b'] }, + ], + eventFilters: [{ kind: 'time', to: 9 }], + } + expect(requestFingerprint(sessionA)).toBe(requestFingerprint(sessionB)) + + const eventA: NormalizedEventRequest = { + sessionId: SessionId('s'), + query: 'needle', + limit: 2, + filters: [{ kind: 'seq' }, { kind: 'surface', values: ['shadowed', 'current'] }], + } + const eventB: NormalizedEventRequest = { + sessionId: SessionId('s'), + query: 'needle', + limit: 2, + filters: [{ kind: 'surface', values: ['current', 'shadowed'] }, { kind: 'seq' }], + } + expect(requestFingerprint(eventA)).toBe(requestFingerprint(eventB)) + expect(requestFingerprint(eventA)).not.toBe(requestFingerprint({ ...eventB, sessionId: SessionId('other') })) + }) + + it('normalizes, bounds, and positions snippets by Unicode code point', () => { + expect(makeSnippet(' short\ntext ', 'absent', 20)).toBe('short text') + expect(makeSnippet('abcdef', 'f', 1)).toBe('…') + expect(makeSnippet('abcdefghij', 'absent', 5)).toBe('abcd…') + expect(makeSnippet('abcdefghij', 'c', 5)).toBe('…bcd…') + expect(makeSnippet('abcdef', 'f', 2)).toBe('a…') + expect(makeSnippet('abcdef', 'f', 5)).toBe('…cdef') + }) +}) diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts new file mode 100644 index 0000000000..77d1e0125d --- /dev/null +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -0,0 +1,594 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { DatabaseSync } from 'node:sqlite' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' +import SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' +import SessionSearchSqlite, { + SESSION_QUERY_SQLITE_APPLICATION_ID, + SESSION_QUERY_SQLITE_SCHEMA_VERSION, +} from '@deepseek-ai/dsh-session-query-sqlite' +import type { SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await rm(directory, { recursive: true, force: true }) + } +}) + +async function temporaryPath(name = 'search.db'): Promise { + const directory = await mkdtemp(join(tmpdir(), 'dsh-session-search-')) + temporaryDirectories.push(directory) + return join(directory, name) +} + +function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader { + return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra } +} + +function messageEvents(text: string, time = 1): SessionEvent[] { + return [{ + type: 'user/message', + seq: 0, + time, + data: { content: [{ type: 'text', text }], source: { kind: 'user' } }, + surfaceOp: 'append', + }] +} + +function expectCode(code: SessionQueryErrorCode): Error { + return expect.objectContaining({ code }) as Error +} + +class TestPersistence extends SessionPersistence { + static entries = new Map() + static listGate: Promise | undefined + static listStarted: (() => void) | undefined + static failure: unknown + + static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { + this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) + this.listGate = undefined + this.listStarted = undefined + this.failure = undefined + } + + create(meta: SessionHeader): Promise { + TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) + return Promise.resolve() + } + + append(id: SessionIdType, events: readonly SessionEvent[]): Promise { + const entry = TestPersistence.entries.get(id) + if (entry === undefined) return Promise.reject(new Error('missing test session')) + entry.events.push(...structuredClone(events)) + return Promise.resolve() + } + + async load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + if (TestPersistence.failure !== undefined) throw TestPersistence.failure + const entry = TestPersistence.entries.get(id) + if (entry === undefined) throw new Error('missing test session') + return structuredClone(entry) + } + + async list(): Promise { + TestPersistence.listStarted?.() + await TestPersistence.listGate + if (TestPersistence.failure !== undefined) throw TestPersistence.failure + return [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)) + } +} + +async function liveContext(config: ConstructorParameters[1] = { path: ':memory:' }): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionSearchSqlite, config) + return ctx +} + +describe('SQLite session search', () => { + it('searches two-character Unicode61 tokens in live-only sessions', async () => { + const ctx = await liveContext({ path: ':memory:', snippetChars: 20 }) + const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: '/work', createdAt: 10, seedLength: 1 } }) + session.append( + 'user/message', + { content: [{ type: 'text', text: 'An AI helper' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + + await expect(ctx.sessionSearch.searchEvents({ sessionId: session.id, query: 'AI' })) + .resolves.toMatchObject({ items: [{ sessionId: session.id, seq: 0, snippet: 'An AI helper' }] }) + await expect(ctx.sessionSearch.searchSessions({ query: 'AI' })) + .resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] }) + }) + + it('searches all surfaces by default and applies metadata before ranking', async () => { + const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 20 }) + const parent = SessionId('parent') + const events: SessionEvent[] = [ + { type: 'user/message', seq: 0, time: 10, data: { content: [{ type: 'text', text: 'needle original' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'needle raw' } } }, + { type: 'user/message', seq: 2, time: 12, data: { content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: { op: 'replace', start: 0, end: 0 } }, + { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'error', step: 1, message: 'needle failure' } } }, + ] + ctx.sessions.create(SessionId('a'), { seed: events, meta: { cwd: '/a', parentSession: parent, createdAt: 20 } }) + ctx.sessions.create(SessionId('b'), { seed: messageEvents('needle peer', 12), meta: { createdAt: 20 } }) + + const all = await ctx.sessionSearch.searchEvents({ sessionId: SessionId('a'), query: 'needle' }) + expect(new Set(all.items.map(item => item.surface))).toEqual(new Set(['current', 'shadowed', 'log-only'])) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: SessionId('a'), + query: 'needle', + filters: [ + { kind: 'seq', from: 2, to: 2 }, + { kind: 'time', from: 12, to: 12 }, + { kind: 'type', values: ['user/message'] }, + { kind: 'surface', values: ['current'] }, + ], + })).resolves.toMatchObject({ items: [{ seq: 2, surface: 'current' }] }) + + const grouped = await ctx.sessionSearch.searchSessions({ + query: 'needle', + sessionFilters: [ + { kind: 'id', values: [SessionId('a')] }, + { kind: 'cwd', values: ['/a'] }, + { kind: 'created-at', from: 20, to: 20 }, + { kind: 'parent', values: [parent] }, + { kind: 'availability', values: ['live'] }, + ], + eventFilters: [{ kind: 'surface', values: ['shadowed'] }], + }) + expect(grouped.items).toHaveLength(1) + expect(grouped.items[0]).toMatchObject({ + header: { id: SessionId('a'), cwd: '/a', parentSession: parent }, + live: true, + persisted: false, + bestMatch: { seq: 0, surface: 'shadowed' }, + }) + }) + + it('uses literal phrase tokens, stable ties, and bounded Unicode snippets', async () => { + const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 10, snippetChars: 5 }) + ctx.sessions.create(SessionId('a'), { seed: messageEvents('😀😀 alpha beta BRAID 😀😀', 10), meta: { createdAt: 1 } }) + ctx.sessions.create(SessionId('b'), { seed: messageEvents('alpha beta', 10), meta: { createdAt: 1 } }) + ctx.sessions.create(SessionId('c'), { seed: messageEvents('alpha middle beta', 10), meta: { createdAt: 1 } }) + ctx.sessions.create(SessionId('d'), { seed: messageEvents('alpha beta', 10), meta: { createdAt: 1 } }) + ctx.sessions.create(SessionId('operator'), { seed: messageEvents('needle OR absent', 10), meta: { createdAt: 1 } }) + ctx.sessions.create(SessionId('only'), { seed: messageEvents('needle only', 10), meta: { createdAt: 1 } }) + ctx.sessions.create(SessionId('quote'), { seed: messageEvents('say "needle" exactly', 10), meta: { createdAt: 1 } }) + + const phrase = await ctx.sessionSearch.searchSessions({ query: 'alpha beta' }) + expect(phrase.items.map(item => item.header.id)).toEqual([SessionId('b'), SessionId('d'), SessionId('a')]) + expect(phrase.items.every(item => Array.from(item.bestMatch.snippet).length <= 5)).toBe(true) + await expect(ctx.sessionSearch.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionSearch.searchSessions({ query: 'needle OR absent' })) + .resolves.toMatchObject({ items: [{ header: { id: SessionId('operator') } }] }) + await expect(ctx.sessionSearch.searchSessions({ query: 'say "needle"' })) + .resolves.toMatchObject({ items: [{ header: { id: SessionId('quote') } }] }) + await expect(ctx.sessionSearch.searchSessions({ query: '*' })).resolves.toEqual({ items: [] }) + }) + + it('binds cursors to requests and only invalidates within-session pages for target changes', async () => { + const ctx = await liveContext({ path: ':memory:', defaultLimit: 1, maxLimit: 5 }) + const target = ctx.sessions.create(SessionId('target'), { + seed: [ + ...messageEvents('needle one', 10), + { ...messageEvents('needle two', 11)[0]!, seq: 1 }, + { ...messageEvents('needle three', 12)[0]!, seq: 2 }, + ], + }) + ctx.sessions.create(SessionId('other'), { seed: messageEvents('needle other', 10) }) + + const eventPage = await ctx.sessionSearch.searchEvents({ sessionId: target.id, query: 'needle', limit: 1 }) + const sessionPage = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1 }) + expect(eventPage.nextCursor).toEqual(expect.any(String)) + expect(sessionPage.nextCursor).toEqual(expect.any(String)) + if (eventPage.nextCursor === undefined || sessionPage.nextCursor === undefined) throw new Error('expected cursors') + + const eventKeys = eventPage.items.map(item => `${item.sessionId}:${item.seq}`) + let eventCursor: string | undefined = eventPage.nextCursor + while (eventCursor !== undefined) { + const next = await ctx.sessionSearch.searchEvents({ + sessionId: target.id, + query: 'needle', + limit: 1, + cursor: eventCursor, + }) + eventKeys.push(...next.items.map(item => `${item.sessionId}:${item.seq}`)) + eventCursor = next.nextCursor + } + expect(eventKeys).toHaveLength(3) + expect(new Set(eventKeys).size).toBe(eventKeys.length) + + const sessionIds = sessionPage.items.map(item => item.header.id) + let sessionCursor: string | undefined = sessionPage.nextCursor + while (sessionCursor !== undefined) { + const next = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1, cursor: sessionCursor }) + sessionIds.push(...next.items.map(item => item.header.id)) + sessionCursor = next.nextCursor + } + expect(sessionIds).toHaveLength(2) + expect(new Set(sessionIds).size).toBe(sessionIds.length) + + ctx.sessions.create(SessionId('unrelated'), { seed: messageEvents('needle unrelated', 20) }) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: target.id, + query: 'needle', + limit: 1, + cursor: eventPage.nextCursor, + })).resolves.toMatchObject({ items: [{ sessionId: target.id }] }) + await expect(ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1, cursor: sessionPage.nextCursor })) + .rejects.toThrow(expectCode('SESSION_QUERY_STALE_CURSOR')) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: target.id, + query: 'different', + limit: 1, + cursor: eventPage.nextCursor, + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR')) + + target.append('user/message', { content: [{ type: 'text', text: 'needle four' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: target.id, + query: 'needle', + limit: 1, + cursor: eventPage.nextCursor, + })).rejects.toThrow(expectCode('SESSION_QUERY_STALE_CURSOR')) + }) + + it('rejects invalid requests, filters, cursors, and direct config', async () => { + const ctx = await liveContext({ path: ':memory:', defaultLimit: 2, maxLimit: 3 }) + const session = ctx.sessions.create(SessionId('valid'), { seed: messageEvents('needle') }) + for (const request of [ + { sessionId: session.id, query: '' }, + { sessionId: session.id, query: 'needle', limit: 0 }, + { sessionId: session.id, query: 'needle', limit: 4 }, + { sessionId: session.id, query: 'needle', filters: [{ kind: 'seq', from: 2, to: 1 }] }, + { sessionId: session.id, query: 'needle', filters: [{ kind: 'surface', values: ['future'] }] }, + ] as const) { + await expect(ctx.sessionSearch.searchEvents(request as never)).rejects.toBeInstanceOf(Error) + } + await expect(ctx.sessionSearch.searchSessions({ + query: 'needle', + sessionFilters: [{ kind: 'availability', values: ['remote' as never] }], + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchEvents({ sessionId: session.id, query: 'needle', cursor: 'not-json' })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR')) + await expect(ctx.sessionSearch.searchEvents({ sessionId: SessionId('absent'), query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + + for (const config of [ + { path: '' }, + { path: ':memory:', defaultLimit: 0 }, + { path: ':memory:', maxLimit: 0 }, + { path: ':memory:', snippetChars: 0 }, + { path: ':memory:', defaultLimit: 3, maxLimit: 2 }, + { path: ':memory:', journalMode: 'memory' }, + ]) { + const direct = new Context() + await direct.plugin(SessionStore) + expect(() => new SessionSearchSqlite(direct, config as never)) + .toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) + } + }) +}) + +describe('SQLite reconciliation and source lifecycle', () => { + it('mounts persistence dynamically, shadows with TEMP live rows, reveals, and hides on unmount', async () => { + const shared = header('shared', 10, { cwd: '/work' }) + const durable = header('durable', 5) + TestPersistence.reset([ + { meta: shared, events: messageEvents('persisted needle') }, + { meta: durable, events: messageEvents('durable needle') }, + ]) + const ctx = await liveContext() + await expect(ctx.sessionSearch.searchSessions({ query: 'durable' })).resolves.toEqual({ items: [] }) + const persistenceFiber = await ctx.plugin(TestPersistence) + + await expect(ctx.sessionSearch.searchSessions({ query: 'durable' })) + .resolves.toMatchObject({ items: [{ header: durable, live: false, persisted: true }] }) + const live = ctx.sessions.prepare(shared.id, { meta: { createdAt: 10, cwd: '/work' } }) + live.append('user/message', { content: [{ type: 'text', text: 'live needle' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const detach = ctx.sessions.enter(live) + ctx.sessions.announce(live) + + await expect(ctx.sessionSearch.searchSessions({ query: 'persisted' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionSearch.searchSessions({ query: 'live' })) + .resolves.toMatchObject({ items: [{ header: shared, live: true, persisted: true }] }) + detach() + await expect(ctx.sessionSearch.searchSessions({ query: 'persisted' })) + .resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] }) + + await persistenceFiber.dispose() + await expect(ctx.sessionSearch.searchSessions({ query: 'durable' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionSearch.searchEvents({ sessionId: durable.id, query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + }) + + it('restarts observation when persistence unmounts during an asynchronous list', async () => { + const durable = header('racing') + TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) + const ctx = await liveContext() + const persistenceFiber = await ctx.plugin(TestPersistence) + let release!: () => void + TestPersistence.listGate = new Promise((resolve) => { release = resolve }) + let markStarted!: () => void + const started = new Promise((resolve) => { markStarted = resolve }) + TestPersistence.listStarted = () => { + TestPersistence.listStarted = undefined + markStarted() + } + + const search = ctx.sessionSearch.searchSessions({ query: 'needle' }) + await started + await persistenceFiber.dispose() + release() + await expect(search).resolves.toEqual({ items: [] }) + }) + + it('rejects immutable header conflicts between live and persisted sources', async () => { + const shared = header('conflict', 10) + TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + ctx.sessions.create(shared.id, { seed: messageEvents('live needle'), meta: { createdAt: 11 } }) + + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) + }) + + it('preserves unchanged persisted generations while reconciling new, changed, and deleted rows', async () => { + const path = await temporaryPath() + const unchanged = header('unchanged') + const changed = header('changed') + const deleted = header('deleted') + TestPersistence.reset([ + { meta: unchanged, events: messageEvents('unchanged needle') }, + { meta: changed, events: messageEvents('old needle') }, + { meta: deleted, events: messageEvents('deleted needle') }, + ]) + const first = new Context() + await first.plugin(SessionStore) + const firstPersistence = await first.plugin(TestPersistence) + const firstSearch = await first.plugin(SessionSearchSqlite, { path }) + await first.sessionSearch.searchSessions({ query: 'needle' }) + await firstSearch.dispose() + await firstPersistence.dispose() + + const beforeDb = new DatabaseSync(path) + const beforeRows = beforeDb.prepare('SELECT id, generation FROM persisted_sessions ORDER BY id').all() as Array<{ id: string; generation: number }> + beforeDb.close() + const before = new Map(beforeRows.map(row => [row.id, row.generation])) + + const added = header('added') + TestPersistence.entries.delete(deleted.id) + TestPersistence.entries.set(changed.id, { meta: changed, events: messageEvents('changed needle') }) + TestPersistence.entries.set(added.id, { meta: added, events: messageEvents('added needle') }) + const second = new Context() + await second.plugin(SessionStore) + const secondPersistence = await second.plugin(TestPersistence) + const secondSearch = await second.plugin(SessionSearchSqlite, { path }) + const result = await second.sessionSearch.searchSessions({ query: 'needle' }) + expect(result.items.map(item => item.header.id).sort()).toEqual([added.id, changed.id, unchanged.id].sort()) + await secondSearch.dispose() + await secondPersistence.dispose() + + const afterDb = new DatabaseSync(path) + const afterRows = afterDb.prepare('SELECT id, generation FROM persisted_sessions ORDER BY id').all() as Array<{ id: string; generation: number }> + afterDb.close() + const after = new Map(afterRows.map(row => [row.id, row.generation])) + expect(after.get(unchanged.id)).toBe(before.get(unchanged.id)) + expect(after.get(changed.id)).toBeGreaterThan(before.get(changed.id)!) + expect(after.has(deleted.id)).toBe(false) + expect(after.has(added.id)).toBe(true) + }) + + it('drops connection-local live overlays on reopen and retains persistent bases', async () => { + const path = await temporaryPath() + const shared = header('shared', 10) + TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }]) + const first = new Context() + await first.plugin(SessionStore) + const persistence = await first.plugin(TestPersistence) + const live = first.sessions.create(shared.id, { seed: messageEvents('live needle'), meta: { createdAt: 10 } }) + const search = await first.plugin(SessionSearchSqlite, { path }) + await expect(first.sessionSearch.searchEvents({ sessionId: live.id, query: 'live' })).resolves.toMatchObject({ items: [{}] }) + await search.dispose() + await persistence.dispose() + + const second = new Context() + await second.plugin(SessionStore) + const persistenceAgain = await second.plugin(TestPersistence) + const searchAgain = await second.plugin(SessionSearchSqlite, { path }) + await expect(second.sessionSearch.searchSessions({ query: 'live' })).resolves.toEqual({ items: [] }) + await expect(second.sessionSearch.searchSessions({ query: 'persisted' })) + .resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] }) + await searchAgain.dispose() + await persistenceAgain.dispose() + }) + + it('recovers on the next search after source and SQLite transaction failures', async () => { + TestPersistence.reset([{ meta: header('durable'), events: messageEvents('durable needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + TestPersistence.failure = 'offline' + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + const signal = new AbortController().signal + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TestPersistence.failure = new Error('still offline') + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TestPersistence.failure = undefined + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).resolves.toMatchObject({ items: [{}] }) + + const live = ctx.sessions.create(SessionId('live'), { seed: messageEvents('base') }) + await ctx.sessionSearch.searchEvents({ sessionId: live.id, query: 'base' }) + const db = (ctx.sessionSearch as unknown as { _db: DatabaseSync })._db + db.exec('PRAGMA query_only = ON') + live.append('user/message', { content: [{ type: 'text', text: 'retry needle' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + await expect(ctx.sessionSearch.searchEvents({ sessionId: live.id, query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + db.exec('PRAGMA query_only = OFF') + await expect(ctx.sessionSearch.searchEvents({ sessionId: live.id, query: 'needle' })) + .resolves.toMatchObject({ items: [{ seq: 1 }] }) + }) +}) + +describe('SQLite schema, cancellation, and real persistence integration', () => { + it('resets a recognized incompatible derived schema but refuses a foreign database', async () => { + const stalePath = await temporaryPath('stale.db') + const stale = new DatabaseSync(stalePath) + stale.exec(`PRAGMA application_id = ${SESSION_QUERY_SQLITE_APPLICATION_ID}`) + stale.exec('PRAGMA user_version = 999') + stale.exec('CREATE TABLE stale(value TEXT)') + stale.close() + const staleCtx = await liveContext({ path: stalePath }) + staleCtx.sessions.create(SessionId('live'), { seed: messageEvents('needle') }) + await staleCtx.sessionSearch.searchSessions({ query: 'needle' }) + await (staleCtx.sessionSearch as SessionSearchSqlite).close() + const rebuilt = new DatabaseSync(stalePath) + expect((rebuilt.prepare('PRAGMA user_version').get() as { user_version: number }).user_version) + .toBe(SESSION_QUERY_SQLITE_SCHEMA_VERSION) + expect(rebuilt.prepare("SELECT name FROM sqlite_master WHERE name = 'stale'").get()).toBeUndefined() + rebuilt.close() + + const foreignPath = await temporaryPath('foreign.db') + const foreign = new DatabaseSync(foreignPath) + foreign.exec('CREATE TABLE canonical(value TEXT)') + foreign.exec("INSERT INTO canonical VALUES ('safe')") + foreign.close() + const foreignCtx = await liveContext({ path: foreignPath }) + await expect(foreignCtx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + const stillForeign = new DatabaseSync(foreignPath) + expect(stillForeign.prepare('SELECT value FROM canonical').get()).toEqual({ value: 'safe' }) + stillForeign.close() + + const otherAppPath = await temporaryPath('other-app.db') + const otherApp = new DatabaseSync(otherAppPath) + otherApp.exec('PRAGMA application_id = 123') + otherApp.close() + const otherAppCtx = await liveContext({ path: otherAppPath }) + await expect(otherAppCtx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + }) + + it('cancels both queued and in-flight source waits without committing them', async () => { + TestPersistence.reset() + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + + const boundaryController = new AbortController() + const boundary = ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal: boundaryController.signal }) + queueMicrotask(() => { boundaryController.abort() }) + await expect(boundary).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + + const readyController = new AbortController() + readyController.abort() + const internals = ctx.sessionSearch as unknown as { + _ensureReady(signal: AbortSignal): Promise + } + await expect(internals._ensureReady(readyController.signal)) + .rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + + let releaseBlocking!: () => void + TestPersistence.listGate = new Promise((resolve) => { releaseBlocking = resolve }) + let markBlockingStarted!: () => void + const blockingStarted = new Promise((resolve) => { markBlockingStarted = resolve }) + TestPersistence.listStarted = () => { + TestPersistence.listStarted = undefined + markBlockingStarted() + } + const blocking = ctx.sessionSearch.searchSessions({ query: 'needle' }) + await blockingStarted + + const queuedController = new AbortController() + const queued = ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal: queuedController.signal }) + queuedController.abort() + await expect(queued).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + + releaseBlocking() + await expect(blocking).resolves.toEqual({ items: [] }) + + TestPersistence.entries.set(SessionId('uncommitted'), { + meta: header('uncommitted'), + events: messageEvents('durable needle'), + }) + let releaseActive!: () => void + TestPersistence.listGate = new Promise((resolve) => { releaseActive = resolve }) + let markActiveStarted!: () => void + const activeStarted = new Promise((resolve) => { markActiveStarted = resolve }) + TestPersistence.listStarted = () => { + TestPersistence.listStarted = undefined + markActiveStarted() + } + const activeController = new AbortController() + const active = ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal: activeController.signal }) + await activeStarted + activeController.abort() + await expect(active).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + releaseActive() + + const db = (ctx.sessionSearch as unknown as { _db: DatabaseSync })._db + expect(db.prepare('SELECT COUNT(*) AS count FROM persisted_sessions').get()).toEqual({ count: 0 }) + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .resolves.toMatchObject({ items: [{ header: { id: SessionId('uncommitted') } }] }) + }) + + it('rejects queued and future work when close waits for an accepted operation', async () => { + TestPersistence.reset() + let release!: () => void + TestPersistence.listGate = new Promise((resolve) => { release = resolve }) + let markStarted!: () => void + const started = new Promise((resolve) => { markStarted = resolve }) + TestPersistence.listStarted = () => { + TestPersistence.listStarted = undefined + markStarted() + } + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const search = ctx.sessionSearch as SessionSearchSqlite + const accepted = search.searchSessions({ query: 'needle' }) + await started + const queued = search.searchSessions({ query: 'needle' }) + const closing = search.close() + release() + + await expect(accepted).resolves.toEqual({ items: [] }) + await expect(queued).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + await closing + await expect(search.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + await search.close() + }) + + it('combines the real SQLite persistence backend with the real search service keylessly', async () => { + const persistencePath = await temporaryPath('canonical.db') + const searchPath = await temporaryPath('derived.db') + const ctx = new Context() + await ctx.plugin(SessionStore) + const persistence = await ctx.plugin(SessionPersistenceSqlite, { path: persistencePath }) + const search = await ctx.plugin(SessionSearchSqlite, { path: searchPath }) + const meta = header('real', 10, { cwd: '/work' }) + await ctx.sessionPersistence.create(meta) + await ctx.sessionPersistence.append(meta.id, messageEvents('real SQLite needle')) + + await expect(ctx.sessionSearch.searchSessions({ query: 'SQLite needle' })) + .resolves.toMatchObject({ items: [{ header: meta, persisted: true, live: false }] }) + await expect(ctx.sessionSearch.searchEvents({ sessionId: meta.id, query: 'SQLite needle' })) + .resolves.toMatchObject({ items: [{ sessionId: meta.id, seq: 0 }] }) + await expect(ctx.sessionSearch.searchEvents({ sessionId: SessionId('absent'), query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + await search.dispose() + await expect(ctx.sessionPersistence.load(meta.id)).resolves.toMatchObject({ meta, events: [{ seq: 0 }] }) + await persistence.dispose() + }) +}) diff --git a/packages/session-query/session-query-sqlite/tsconfig.json b/packages/session-query/session-query-sqlite/tsconfig.json new file mode 100644 index 0000000000..ea16cdbe96 --- /dev/null +++ b/packages/session-query/session-query-sqlite/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../session-query" + } + ] +} diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 55f9b32fcd..6f9e9bbd7a 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-session-query -Exact session-history retrieval through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`. +Session-history query contracts and provider-independent helpers. The concrete `ctx.sessionQuery` service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus for exact reads and semantic scans. The abstract `ctx.sessionSearch` service defines full-text search without introducing a provider registry. This is trusted context-wide infrastructure. It performs no caller authorization; a future model tool or UI must constrain which sessions its caller may inspect. @@ -8,11 +8,24 @@ This is trusted context-wide infrastructure. It performs no caller authorization - `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. +- `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. - `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. Persistence is optional and may mount or unmount dynamically. A cross-corpus list fails with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted exact reads list before loading, and reject a metadata mismatch rather than combining inconsistent observations. -`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. +## Filtering and extraction + +`SessionResultFilter` covers id, nullable cwd, created-at range, nullable parent, and source availability. `SessionEventResultFilter` covers seq/time ranges, event type, surface, and semantic text. Filter arrays are ANDed; values within one list clause are ORed. Empty list values match nothing, ranges are inclusive, and malformed ranges or closed-union values fail with `SESSION_QUERY_INVALID_FILTER`. + +The text clause is deliberately independent of FTS providers: caller text is escaped into a Unicode, case-insensitive regular expression, and each whitespace run matches one or more whitespace characters. It is a literal semantic-text scan, not a full-text query. `extractSessionEventText()` and `buildSessionEventSearchDocuments()` define the shared first-party document projection; structural boundaries, stream chunks, request headers, and unknown declaration-merged variants produce no document. + +## Full-text seam + +`SessionSearchService` owns the independent `ctx.sessionSearch` key. `searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. Both return opaque cursor pages, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above. + +The package has no provider coordinator or registration protocol. A concrete backend owns observation, reconciliation, ranking, cursor generations, and query execution as one lifecycle; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md). + +`SessionQueryError.code` is a closed union covering request validation, missing targets, malformed surfaces, source conflicts, persistence/index failures, cancellation, and invalid or stale cursors; the exact literals are defined in [`src/config.ts`](src/config.ts). ## Configuration @@ -20,4 +33,4 @@ Persistence is optional and may mount or unmount dynamically. A cross-corpus lis |---|---:|---| | `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. | -This phase deliberately has no filters, lineage/provenance traversal, extraction registry, search-provider protocol, index synchronization, or model-facing tool. Full-text search belongs beside its first real implementation; the proposed SQLite package and its single transaction/reconciliation owner are described in the [phase-two RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). +The package deliberately has no lineage/provenance traversal, extractor registry, search-provider registry, index synchronization, caller authorization, or model-facing tool. The SQLite ownership and tokenizer decisions are recorded in the [implemented search RFC](../../../docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md). diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index 2736f68cbd..296d3830c6 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -1,4 +1,4 @@ -/** Public configuration and typed failures for session-query. */ +/** Public configuration and typed failures for session-query and search. */ import { HarnessError } from '@deepseek-ai/dsh-llm' @@ -11,14 +11,21 @@ export interface Config { readWindowMax?: number } -/** Stable machine-routable failure taxonomy for exact session reads. */ +/** Stable machine-routable failure taxonomy for session reads and search. */ export type SessionQueryErrorCode = + | 'SESSION_QUERY_ABORTED' | 'SESSION_QUERY_EVENT_NOT_FOUND' + | 'SESSION_QUERY_INDEX_FAILED' | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_CURSOR' + | 'SESSION_QUERY_INVALID_FILTER' + | 'SESSION_QUERY_INVALID_LIMIT' + | 'SESSION_QUERY_INVALID_QUERY' | 'SESSION_QUERY_INVALID_SURFACE' | 'SESSION_QUERY_INVALID_WINDOW' | 'SESSION_QUERY_PERSISTENCE_FAILED' | 'SESSION_QUERY_SESSION_NOT_FOUND' + | 'SESSION_QUERY_STALE_CURSOR' | 'SESSION_QUERY_SOURCE_CONFLICT' /** Typed session-query failure whose `code` is one closed taxonomy member. */ diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index ebc3d92577..c8ddca3caa 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -5,6 +5,7 @@ import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek- import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' import type { SessionRecord } from './types.ts' import { SessionQueryError } from './config.ts' +import { assertSessionHeadersCompatible } from './sources.ts' /** Detached source selected for one exact read. */ export interface LogicalSession { @@ -45,7 +46,7 @@ export class SessionCorpus { } for (const session of this._ctx.sessions.list()) { const durable = records.get(session.id) - if (durable !== undefined) assertCompatibleHeaders(session.header, durable.header) + if (durable !== undefined) assertSessionHeadersCompatible(session.header, durable.header) records.set(session.id, { header: structuredClone(session.header), live: true, @@ -80,7 +81,7 @@ export class SessionCorpus { { cause: error }, ) } - assertCompatibleHeaders(loaded.meta, listed) + assertSessionHeadersCompatible(loaded.meta, listed) return { header: structuredClone(loaded.meta), events: loaded.events.map(event => structuredClone(event)), @@ -107,22 +108,6 @@ function snapshotLive(session: Session): LogicalSession { } } -function assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void { - if ( - a.version !== b.version - || a.id !== b.id - || a.createdAt !== b.createdAt - || a.cwd !== b.cwd - || a.parentSession !== b.parentSession - || a.seedLength !== b.seedLength - ) { - throw new SessionQueryError( - `live and persisted headers conflict for session "${a.id}"`, - 'SESSION_QUERY_SOURCE_CONFLICT', - ) - } -} - function compareSessions(a: SessionRecord, b: SessionRecord): number { return b.header.createdAt - a.header.createdAt || a.header.id.localeCompare(b.header.id) } diff --git a/packages/session-query/session-query/src/documents.ts b/packages/session-query/session-query/src/documents.ts new file mode 100644 index 0000000000..f58029ae67 --- /dev/null +++ b/packages/session-query/session-query/src/documents.ts @@ -0,0 +1,74 @@ +/** Shared event metadata and semantic-document projection. */ + +import { foldSurface } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEventRecord, SessionEventSearchDocument, SessionEventSurface } from './types.ts' +import { SessionQueryError } from './config.ts' +import { extractSessionEventText } from './extraction.ts' + +/** + * Project a raw log into lightweight surface-aware event records. + * @param sessionId - session that owns the log. + * @param events - complete contiguous raw event log. + * @returns one record per event in ascending seq order. + */ +export function buildSessionEventRecords( + sessionId: SessionId, + events: readonly SessionEvent[], +): SessionEventRecord[] { + const surfaceBySeq = classifySurface(events) + return events.map(event => ({ + sessionId, + seq: event.seq, + type: event.type, + time: event.time, + surface: surfaceBySeq.get(event.seq) ?? 'log-only', + })) +} + +/** + * Build first-party semantic documents for one complete raw event log. + * @param sessionId - session that owns the log. + * @param events - complete contiguous raw event log. + * @returns searchable documents in ascending seq order; structural events are omitted. + */ +export function buildSessionEventSearchDocuments( + sessionId: SessionId, + events: readonly SessionEvent[], +): SessionEventSearchDocument[] { + const surfaceBySeq = classifySurface(events) + const documents: SessionEventSearchDocument[] = [] + for (const event of events) { + const text = extractSessionEventText(event) + if (text.length === 0) continue + documents.push({ + sessionId, + seq: event.seq, + type: event.type, + time: event.time, + surface: surfaceBySeq.get(event.seq) ?? 'log-only', + text, + }) + } + return documents +} + +function classifySurface(events: readonly SessionEvent[]): Map { + let folded: ReturnType + try { + folded = foldSurface(events) + } catch (error: unknown) { + throw new SessionQueryError( + /* v8 ignore next -- foldSurface throws Error instances */ + `invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`, + 'SESSION_QUERY_INVALID_SURFACE', + { cause: error }, + ) + } + const result = new Map() + for (const node of folded.nodes) result.set(node.seq, 'current') + for (const replacement of folded.replacements) { + for (const seq of replacement.shadowedSeqs) result.set(seq, 'shadowed') + } + return result +} diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts new file mode 100644 index 0000000000..96a0af247b --- /dev/null +++ b/packages/session-query/session-query/src/extraction.ts @@ -0,0 +1,93 @@ +/** First-party semantic text extraction for session-query consumers. */ + +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +/** + * Extract searchable semantic text from one first-party session event. + * + * Structural boundaries, raw stream chunks, request envelopes, and unknown + * declaration-merged events contribute no text. + * @param event - event to inspect. + * @returns newline-joined semantic text, or an empty string when non-searchable. + */ +export function extractSessionEventText(event: SessionEvent): string { + switch (event.type) { + case 'user/message': + case 'assistant/message': + case 'context/message': + case 'steering/message': + return contentText(event.data.content) + case 'prompt/blocked': + return joinText([contentText(event.data.content), event.data.reason]) + case 'tool/call': + return joinText([event.data.name, event.data.arguments]) + case 'tool/result': + return joinText([ + contentText(event.data.content), + event.data.error?.name ?? '', + event.data.error?.code ?? '', + ]) + case 'todo/write': + return joinText(event.data.todos.flatMap(todo => [todo.status, todo.content])) + case 'turn/end': + return turnEndText(event.data.reason) + case 'turn/start': + case 'step/start': + case 'step/end': + case 'assistant/chunk': + case 'request/header': + case 'request/header-delta': + return '' + // SessionEventMap is merge-extensible. Unknown events remain + // non-searchable until a concrete first-party consumer defines semantics. + default: + return '' + } +} + +function turnEndText(reason: SessionEvent<'turn/end'>['data']['reason']): string { + switch (reason.kind) { + case 'error': + return joinText(['error', reason.message, reason.code ?? '']) + case 'aborted': + return joinText(['aborted', reason.reason ?? '']) + case 'rejected': + return joinText(['rejected', reason.reason]) + case 'disposed': + case 'max-tokens': + case 'interrupted': + return reason.kind + case 'completed': + return '' + // TurnEndReasonMap is merge-extensible. Unknown outcomes stay out until + // their owner defines which detail is semantic rather than structural. + default: + return '' + } +} + +type SessionContentBlock = SessionEvent<'user/message'>['data']['content'][number] + +function contentText(content: readonly SessionContentBlock[]): string { + return joinText(content.flatMap(blockText)) +} + +function blockText(block: SessionContentBlock): string[] { + switch (block.type) { + case 'text': + case 'reasoning': + return [block.text] + case 'tool-call': + return [block.name, block.arguments] + case 'tool-result': + return block.content.flatMap(blockText) + // ContentBlockMap is merge-extensible. Unknown blocks do not become + // searchable merely because their payload happens to contain strings. + default: + return [] + } +} + +function joinText(parts: readonly string[]): string { + return parts.map(part => part.trim()).filter(Boolean).join('\n') +} diff --git a/packages/session-query/session-query/src/filters.ts b/packages/session-query/session-query/src/filters.ts new file mode 100644 index 0000000000..c7a7b40dd4 --- /dev/null +++ b/packages/session-query/session-query/src/filters.ts @@ -0,0 +1,132 @@ +/** Pure provider-independent predicates for logical sessions and event text. */ + +import type { SessionRecord, SessionEventSearchDocument, SessionEventResultFilter, SessionResultFilter, SessionResultRange } from './types.ts' +import { SessionQueryError } from './config.ts' + +/** + * Apply ANDed logical-session filters while preserving input order. + * @param records - detached logical-session records to inspect. + * @param filters - clauses whose list values are ORed within each clause. + * @returns records accepted by every clause. + */ +export function filterSessionResults( + records: readonly T[], + filters: readonly SessionResultFilter[] = [], +): T[] { + const predicates = filters.map(sessionPredicate) + return records.filter(record => predicates.every(predicate => predicate(record))) +} + +/** + * Apply ANDed event filters to extracted semantic documents. + * @param documents - semantic documents produced by {@link buildSessionEventSearchDocuments}. + * @param filters - metadata and literal-text predicates. + * @returns documents accepted by every clause, in input order. + */ +export function filterSessionEventDocuments( + documents: readonly T[], + filters: readonly SessionEventResultFilter[] = [], +): T[] { + const predicates = filters.map(eventPredicate) + return documents.filter(document => predicates.every(predicate => predicate(document))) +} + +/** + * Compile a literal case-insensitive, whitespace-flexible semantic-text match. + * @param text - caller-provided literal text. + * @returns Unicode-aware regular expression safe from regex injection. + */ +export function compileSessionTextFilter(text: string): RegExp { + const trimmed = text.trim() + if (trimmed.length === 0) { + throw new SessionQueryError( + 'session text filter must contain non-whitespace text', + 'SESSION_QUERY_INVALID_FILTER', + ) + } + const pattern = trimmed + .split(/\s+/u) + .map(part => part.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')) + .join('\\s+') + return new RegExp(pattern, 'iu') +} + +function sessionPredicate(filter: SessionResultFilter): (record: SessionRecord) => boolean { + switch (filter.kind) { + case 'id': + return record => filter.values.includes(record.header.id) + case 'cwd': + return record => filter.values.includes(record.header.cwd ?? null) + case 'created-at': { + const range = validateRange(filter.kind, filter) + return record => matchesRange(record.header.createdAt, range) + } + case 'parent': + return record => filter.values.includes(record.header.parentSession ?? null) + case 'availability': + assertAllowedValues(filter.kind, filter.values, ['live', 'persisted']) + return record => filter.values.some(value => value === 'live' ? record.live : record.persisted) + } +} + +function eventPredicate(filter: SessionEventResultFilter): (document: SessionEventSearchDocument) => boolean { + switch (filter.kind) { + case 'seq': { + const range = validateRange(filter.kind, filter) + return document => matchesRange(document.seq, range) + } + case 'time': { + const range = validateRange(filter.kind, filter) + return document => matchesRange(document.time, range) + } + case 'type': + return document => filter.values.includes(document.type) + case 'surface': + assertAllowedValues(filter.kind, filter.values, ['current', 'shadowed', 'log-only']) + return document => filter.values.includes(document.surface) + case 'text': { + const pattern = compileSessionTextFilter(filter.text) + return document => pattern.test(document.text) + } + } +} + +function assertAllowedValues( + name: string, + values: readonly string[], + allowed: readonly string[], +): void { + for (const value of values) { + if (!allowed.includes(value)) { + throw new SessionQueryError( + `session ${name} filter contains unknown value "${value}"`, + 'SESSION_QUERY_INVALID_FILTER', + ) + } + } +} + +function validateRange(name: string, range: SessionResultRange): SessionResultRange { + if (range.from !== undefined && !Number.isFinite(range.from)) { + throw invalidRange(name, 'from must be finite') + } + if (range.to !== undefined && !Number.isFinite(range.to)) { + throw invalidRange(name, 'to must be finite') + } + if (range.from !== undefined && range.to !== undefined && range.from > range.to) { + throw invalidRange(name, 'from must be less than or equal to to') + } + return range +} + +function matchesRange(value: number, range: SessionResultRange): boolean { + return (range.from === undefined || value >= range.from) + && (range.to === undefined || value <= range.to) +} + +function invalidRange(name: string, detail: string): SessionQueryError { + return new SessionQueryError( + `session ${name} filter ${detail}`, + 'SESSION_QUERY_INVALID_FILTER', + ) +} diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 828fe2ec88..243c86746b 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -6,13 +6,20 @@ import { Context, Service } from 'cordis' import z from 'schemastery' -import { foldSurface } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { + SessionEventResultFilter, SessionEventReadRequest, SessionEventRecord, + SessionEventSearchHit, + SessionEventSearchDocument, + SessionEventSearchRequest, SessionEventWindow, SessionRecord, + SessionSearchExecContext, + SessionSearchHit, + SessionSearchPage, + SessionSearchRequest, } from './types.ts' import { SESSION_QUERY_READ_WINDOW_MAX, @@ -20,17 +27,58 @@ import { type Config, } from './config.ts' import { SessionCorpus } from './corpus.ts' +import { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts' +import { filterSessionEventDocuments } from './filters.ts' export type * from './types.ts' export type { Config, SessionQueryErrorCode } from './config.ts' export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts' +export { extractSessionEventText } from './extraction.ts' +export { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts' +export { compileSessionTextFilter, filterSessionEventDocuments, filterSessionResults } from './filters.ts' +export { assertSessionHeadersCompatible } from './sources.ts' declare module 'cordis' { interface Context { sessionQuery: SessionQueryService + sessionSearch: SessionSearchService } } +/** + * Abstract full-text search service implemented by one concrete backend. + * + * The implementation owns source observation, reconciliation, cursor + * generations, ranking, and query execution as one lifecycle. + */ +export abstract class SessionSearchService extends Service { + constructor(ctx: Context) { + super(ctx, 'sessionSearch') + } + + /** + * Search the live-preferred logical corpus and group by session. + * @param request - query text, metadata filters, page size, and cursor. + * @param exec - optional cancellation control. + * @returns session hits ranked by their strongest matching event. + */ + abstract searchSessions( + request: SessionSearchRequest, + exec?: SessionSearchExecContext, + ): Promise> + + /** + * Search events within one live-preferred logical session. + * @param request - target session, query text, filters, page size, and cursor. + * @param exec - optional cancellation control. + * @returns matching event hits in deterministic relevance order. + */ + abstract searchEvents( + request: SessionEventSearchRequest, + exec?: SessionSearchExecContext, + ): Promise> +} + /** Live-preferred logical-corpus and exact-event read service. */ export class SessionQueryService extends Service { static inject = ['sessions'] @@ -68,7 +116,22 @@ export class SessionQueryService extends Service { */ async listEvents(sessionId: SessionId): Promise { const loaded = await this._corpus.load(sessionId) - return eventRecords(sessionId, loaded.events) + return buildSessionEventRecords(sessionId, loaded.events) + } + + /** + * Scan first-party semantic event documents with provider-independent filters. + * @param sessionId - live-preferred session id to scan. + * @param filters - ANDed metadata and literal-text predicates. + * @returns matching semantic documents in ascending seq order. + */ + async filterEvents( + sessionId: SessionId, + filters: readonly SessionEventResultFilter[], + ): Promise { + const loaded = await this._corpus.load(sessionId) + const documents = buildSessionEventSearchDocuments(sessionId, loaded.events) + return filterSessionEventDocuments(documents, filters) } /** @@ -110,27 +173,4 @@ export class SessionQueryService extends Service { } } -function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] { - let folded: ReturnType - try { - folded = foldSurface(events) - } catch (error: unknown) { - throw new SessionQueryError( - /* v8 ignore next -- foldSurface throws Error instances */ - `invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`, - 'SESSION_QUERY_INVALID_SURFACE', - { cause: error }, - ) - } - const current = new Set(folded.nodes.map(node => node.seq)) - const shadowed = new Set(folded.replacements.flatMap(replacement => replacement.shadowedSeqs)) - return events.map(event => ({ - sessionId, - seq: event.seq, - type: event.type, - time: event.time, - surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only', - })) -} - export default SessionQueryService diff --git a/packages/session-query/session-query/src/sources.ts b/packages/session-query/session-query/src/sources.ts new file mode 100644 index 0000000000..00b08eae4e --- /dev/null +++ b/packages/session-query/session-query/src/sources.ts @@ -0,0 +1,25 @@ +/** Shared immutable-header checks for logical session source observers. */ + +import type { SessionHeader } from '@deepseek-ai/dsh-session' +import { SessionQueryError } from './config.ts' + +/** + * Reject incompatible observations of one logical session source. + * @param a - first live, listed, or loaded header observation. + * @param b - second header observation expected to identify the same source. + */ +export function assertSessionHeadersCompatible(a: SessionHeader, b: SessionHeader): void { + if ( + a.version !== b.version + || a.id !== b.id + || a.createdAt !== b.createdAt + || a.cwd !== b.cwd + || a.parentSession !== b.parentSession + || a.seedLength !== b.seedLength + ) { + throw new SessionQueryError( + `session source headers conflict for session "${a.id}"`, + 'SESSION_QUERY_SOURCE_CONFLICT', + ) + } +} diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index 5c49695dda..d0de0dd48a 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -58,3 +58,99 @@ export interface SessionEventWindow { /** Last seq included in `events`. */ endSeq: number } + +/** Inclusive numeric interval used by time and sequence filters. */ +export interface SessionResultRange { + /** Inclusive lower bound. */ + from?: number + /** Inclusive upper bound. */ + to?: number +} + +/** Source availability predicates understood by logical-session filters. */ +export type SessionAvailability = 'live' | 'persisted' + +/** + * One logical-session predicate. A filter array is ANDed; `values` within a + * clause are ORed. + */ +export type SessionResultFilter = + | { kind: 'id'; values: readonly SessionId[] } + | { kind: 'cwd'; values: readonly (string | null)[] } + | ({ kind: 'created-at' } & SessionResultRange) + | { kind: 'parent'; values: readonly (SessionId | null)[] } + | { kind: 'availability'; values: readonly SessionAvailability[] } + +/** + * One event predicate. A filter array is ANDed; list-valued clauses are ORed. + * Text is a literal, case-insensitive, whitespace-flexible semantic-text scan. + */ +export type SessionEventResultFilter = + | ({ kind: 'seq' } & SessionResultRange) + | ({ kind: 'time' } & SessionResultRange) + | { kind: 'type'; values: readonly SessionEventType[] } + | { kind: 'surface'; values: readonly SessionEventSurface[] } + | { kind: 'text'; text: string } + +/** Event predicates a full-text provider can apply before relevance ranking. */ +export type SessionEventMetadataFilter = Exclude + +/** Searchable semantic document derived from one session event. */ +export interface SessionEventSearchDocument extends SessionEventRecord { + /** First-party semantic text used by scan filters and full-text indexes. */ + text: string +} + +/** One cursor-paginated result page. */ +export interface SessionSearchPage { + /** Results for this page in contract-defined order. */ + items: readonly T[] + /** Opaque continuation cursor, absent on the final page. */ + nextCursor?: string +} + +/** Controls shared by cross-session and within-session search calls. */ +export interface SessionSearchExecContext { + /** Abort caller waiting and interrupt provider work where supported. */ + signal?: AbortSignal +} + +/** Cross-session full-text search request. */ +export interface SessionSearchRequest { + /** Full-text query interpreted as data, never executable FTS syntax. */ + query: string + /** Logical-session predicates applied before event ranking. */ + sessionFilters?: readonly SessionResultFilter[] + /** Event predicates applied before event ranking. */ + eventFilters?: readonly SessionEventMetadataFilter[] + /** Maximum sessions in this page. */ + limit?: number + /** Opaque cursor returned for the identical normalized request. */ + cursor?: string +} + +/** Within-session full-text search request. */ +export interface SessionEventSearchRequest { + /** Session whose live-preferred logical log is searched. */ + sessionId: SessionId + /** Full-text query interpreted as data, never executable FTS syntax. */ + query: string + /** Event predicates applied before ranking. */ + filters?: readonly SessionEventMetadataFilter[] + /** Maximum events in this page. */ + limit?: number + /** Opaque cursor returned for the identical normalized request. */ + cursor?: string +} + +/** One event full-text search hit with a bounded plain-text excerpt. */ +export interface SessionEventSearchHit extends SessionEventRecord { + /** Plain text excerpt selected around the match. */ + snippet: string +} + +/** One grouped cross-session hit, ranked by its strongest matching event. */ +export interface SessionSearchHit extends SessionRecord { + /** Strongest matching event for this session. */ + bestMatch: SessionEventSearchHit +} diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts new file mode 100644 index 0000000000..e9cf857608 --- /dev/null +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import SessionQueryService, { + buildSessionEventRecords, + buildSessionEventSearchDocuments, + compileSessionTextFilter, + extractSessionEventText, + filterSessionEventDocuments, + filterSessionResults, + SessionSearchService, + type SessionEventSearchHit, + type SessionEventSearchRequest, + type SessionQueryErrorCode, + type SessionSearchExecContext, + type SessionSearchHit, + type SessionSearchPage, + type SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' + +const id = SessionId('session') + +function header(value: string, extra: Partial = {}): SessionHeader { + return { version: SESSION_FORMAT_VERSION, id: SessionId(value), createdAt: 10, ...extra } +} + +function expectCode(code: SessionQueryErrorCode): Error { + return expect.objectContaining({ code }) as Error +} + +describe('session-query semantic extraction', () => { + it('extracts first-party message, tool, todo, and failure detail', () => { + const callId = CallId('call') + const messageContent: SessionEvent<'user/message'>['data']['content'] = [ + { type: 'text', text: ' visible ' }, + { type: 'reasoning', text: 'thought' }, + { type: 'tool-call', id: callId, name: 'read', arguments: '{"path":"a"}' }, + { + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text: 'nested' }], + isError: false, + }, + { type: 'future-content', payload: 'hidden' } as never, + ] + const events: SessionEvent[] = [ + { type: 'user/message', seq: 0, time: 1, data: { content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: messageContent }, surfaceOp: 'append' }, + { type: 'context/message', seq: 2, time: 3, data: { content: messageContent, source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: 'append' }, + { type: 'steering/message', seq: 3, time: 4, data: { turn: 1, content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'prompt/blocked', seq: 4, time: 5, data: { content: [{ type: 'text', text: 'unsafe' }], source: { kind: 'user' }, reason: 'policy' } }, + { type: 'tool/call', seq: 5, time: 6, data: { turn: 1, step: 1, callId, name: 'bash', arguments: '{"cmd":"pwd"}' } }, + { type: 'tool/result', seq: 6, time: 7, data: { turn: 1, step: 1, callId, content: [{ type: 'text', text: 'failed' }], isError: true, error: { name: 'Oops', code: 'E_OOPS' } }, surfaceOp: 'append' }, + { type: 'tool/result', seq: 7, time: 8, data: { turn: 1, step: 1, callId, content: [], isError: false }, surfaceOp: 'append' }, + { type: 'todo/write', seq: 8, time: 9, data: { todos: [{ status: 'in_progress', content: 'ship search' }] } }, + ] + + for (const event of events.slice(0, 4)) { + expect(extractSessionEventText(event)).toBe('visible\nthought\nread\n{"path":"a"}\nnested') + } + expect(extractSessionEventText(events[4]!)).toBe('unsafe\npolicy') + expect(extractSessionEventText(events[5]!)).toBe('bash\n{"cmd":"pwd"}') + expect(extractSessionEventText(events[6]!)).toBe('failed\nOops\nE_OOPS') + expect(extractSessionEventText(events[7]!)).toBe('') + expect(extractSessionEventText(events[8]!)).toBe('in_progress\nship search') + }) + + it('extracts meaningful turn outcomes and skips structural or unknown events', () => { + const reasons: Array<[SessionEvent<'turn/end'>['data']['reason'], string]> = [ + [{ kind: 'error', step: 2, message: 'boom', code: 'E' }, 'error\nboom\nE'], + [{ kind: 'error', step: 2, message: 'boom' }, 'error\nboom'], + [{ kind: 'aborted', reason: 'cancelled' }, 'aborted\ncancelled'], + [{ kind: 'aborted' }, 'aborted'], + [{ kind: 'rejected', reason: 'denied' }, 'rejected\ndenied'], + [{ kind: 'disposed' }, 'disposed'], + [{ kind: 'max-tokens' }, 'max-tokens'], + [{ kind: 'interrupted' }, 'interrupted'], + [{ kind: 'completed' }, ''], + [{ kind: 'future-status' } as never, ''], + ] + for (const [reason, text] of reasons) { + expect(extractSessionEventText({ type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason } })).toBe(text) + } + const structural: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, + { type: 'step/end', seq: 2, time: 1, data: { turn: 1, step: 1 } }, + { type: 'assistant/chunk', seq: 3, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } }, + { type: 'request/header', seq: 4, time: 1, data: { header: { config: { model: 'test' } }, reason: 'initial' } }, + { type: 'request/header-delta', seq: 5, time: 1, data: {} }, + { type: 'future/event', seq: 6, time: 1, data: { text: 'hidden' } } as never, + ] + expect(structural.map(extractSessionEventText)).toEqual(['', '', '', '', '', '', '']) + }) +}) + +describe('session-query document and filter helpers', () => { + const events: SessionEvent[] = [ + { type: 'user/message', seq: 0, time: 10, data: { content: [{ type: 'text', text: 'Hello\n(AI)+' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } }, + { type: 'assistant/message', seq: 2, time: 12, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] }, surfaceOp: { op: 'replace', start: 0, end: 0 } }, + { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'interrupted' } } }, + ] + + it('classifies every event and omits non-semantic documents', () => { + expect(buildSessionEventRecords(id, events).map(record => record.surface)) + .toEqual(['shadowed', 'log-only', 'current', 'log-only']) + const documents = buildSessionEventSearchDocuments(id, events) + expect(documents.map(document => [document.seq, document.text, document.surface])).toEqual([ + [0, 'Hello\n(AI)+', 'shadowed'], + [2, 'replacement', 'current'], + [3, 'interrupted', 'log-only'], + ]) + }) + + it('applies every session clause with OR values and validates closed values', () => { + const parent = SessionId('parent') + const records = [ + { header: header('a', { cwd: '/a', parentSession: parent }), live: true, persisted: false, marker: 1 }, + { header: header('b', { createdAt: 20 }), live: false, persisted: true, marker: 2 }, + ] + expect(filterSessionResults(records, [ + { kind: 'id', values: [SessionId('a'), SessionId('x')] }, + { kind: 'cwd', values: ['/a', null] }, + { kind: 'created-at', from: 5, to: 15 }, + { kind: 'parent', values: [parent, null] }, + { kind: 'availability', values: ['live'] }, + ])).toEqual([records[0]]) + expect(filterSessionResults(records, [{ kind: 'cwd', values: [null] }])).toEqual([records[1]]) + expect(filterSessionResults(records, [{ kind: 'parent', values: [null] }])).toEqual([records[1]]) + expect(filterSessionResults(records, [{ kind: 'availability', values: ['persisted'] }])).toEqual([records[1]]) + expect(() => filterSessionResults(records, [{ kind: 'availability', values: ['remote' as never] }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) + + it('applies event metadata and safe literal text clauses', () => { + const documents = buildSessionEventSearchDocuments(id, events).map((document, marker) => ({ ...document, marker })) + expect(filterSessionEventDocuments(documents, [ + { kind: 'seq', from: 0, to: 1 }, + { kind: 'time', from: 9, to: 11 }, + { kind: 'type', values: ['user/message', 'tool/result'] }, + { kind: 'surface', values: ['shadowed'] }, + { kind: 'text', text: 'hello (ai)+' }, + ])).toEqual([documents[0]]) + expect(compileSessionTextFilter('CAFÉ').test('café')).toBe(true) + expect(filterSessionEventDocuments(documents)).toEqual(documents) + expect(filterSessionEventDocuments(documents, [{ kind: 'surface', values: [] }])).toEqual([]) + expect(() => filterSessionEventDocuments(documents, [{ kind: 'surface', values: ['future' as never] }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => compileSessionTextFilter(' \n ')).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) + + it('rejects malformed range filters and malformed surfaces', () => { + const documents = buildSessionEventSearchDocuments(id, events) + for (const filter of [ + { kind: 'seq', from: Number.NaN }, + { kind: 'seq', to: Number.POSITIVE_INFINITY }, + { kind: 'time', from: 2, to: 1 }, + ] as const) { + expect(() => filterSessionEventDocuments(documents, [filter])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + } + expect(() => filterSessionResults([], [{ kind: 'created-at', from: Number.NaN }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => filterSessionResults([{ header: header('x'), live: true, persisted: false }], [ + { kind: 'created-at', from: Number.NaN }, + ])).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + const malformed: SessionEvent[] = [{ + type: 'assistant/message', + seq: 0, + time: 1, + data: { turn: 1, step: 1, content: [{ type: 'text', text: 'bad' }] }, + surfaceOp: { op: 'replace', start: 9, end: 9 }, + }] + expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) + }) + + it('exposes the scan path on the concrete exact-read service', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionQueryService) + const session = ctx.sessions.create(id) + session.append('user/message', { content: [{ type: 'text', text: 'Alpha\n beta' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('user/message', { content: [{ type: 'text', text: 'other' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + await expect(ctx.sessionQuery.filterEvents(id, [{ kind: 'text', text: 'alpha beta' }])) + .resolves.toMatchObject([{ seq: 0, text: 'Alpha\n beta' }]) + }) +}) + +class TestSearchService extends SessionSearchService { + searchSessions(_request: SessionSearchRequest, _exec?: SessionSearchExecContext): Promise> { + return Promise.resolve({ items: [] }) + } + + searchEvents(_request: SessionEventSearchRequest, _exec?: SessionSearchExecContext): Promise> { + return Promise.resolve({ items: [] }) + } +} + +it('registers the abstract search seam under its independent ctx key', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(TestSearchService) + await expect(ctx.sessionSearch.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionSearch.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] }) + await fiber.dispose() + expect(ctx.sessionSearch).toBeUndefined() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 456ce0896b..997f00ff5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -778,6 +778,31 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/session-query/session-query-sqlite: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-sqlite': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-sqlite + '@deepseek-ai/dsh-session-query': + specifier: workspace:^ + version: link:../session-query + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/skill/skill: dependencies: schemastery: diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 05eaaed337..413917aa9a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -93,7 +93,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session', title: 'In-memory session store', mode: 'core', - consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'subagent-inprocess', 'invariants'], + consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants'], note: 'Owns append-only Session instances and emits the durable session event feed.', }, { @@ -102,7 +102,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Durable session persistence seam', mode: 'seam', implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'], - consumers: ['agent-loop', 'acp', 'session-query'], + consumers: ['agent-loop', 'acp', 'session-query', 'session-query-sqlite'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, { @@ -110,7 +110,15 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session-query', title: 'Exact session-history reads', mode: 'seam', - note: 'Resolves live and optional persisted logs into one logical corpus for exact reads.', + note: 'Resolves live and optional persisted logs into one logical corpus for exact reads and semantic scans.', + }, + { + key: 'sessionSearch', + pkg: 'session-query', + title: 'Full-text session search', + mode: 'seam', + implementations: ['session-query-sqlite'], + note: 'The concrete backend owns source reconciliation, ranking, snippets, and cursor generations as one lifecycle.', }, { key: 'systemPrompt', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 7b83f1004e..0d0a0b871d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -49,6 +49,14 @@ { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchDocument", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPage", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryErrorCode", "source": "packages/session-query/session-query/src/config.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" }, diff --git a/tsconfig.build.json b/tsconfig.build.json index 7797998a30..d2470e0ff5 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -19,6 +19,7 @@ { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/session-query/session-query" }, + { "path": "./packages/session-query/session-query-sqlite" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/ui/user-interaction" }, diff --git a/tsconfig.json b/tsconfig.json index 778a26ff8a..c62cc70518 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,7 @@ { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/session-query/session-query" }, + { "path": "./packages/session-query/session-query-sqlite" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/ui/user-interaction" }, From f88ca85ffdd5c3b86ff859bda2c9f05967ade11e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 12:10:24 +0800 Subject: [PATCH 002/207] fix(session-query): harden SQLite search reconciliation --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 8 +- docs/core-data-structures/persistence.md | 19 +- docs/core-data-structures/session-query.md | 12 +- docs/module-graph.md | 6 +- .../2026-07-10-session-query-service.md | 2 +- ...026-07-10-sqlite-session-query-provider.md | 16 +- .../cordis/tool-cordis/src/api-catalog.ts | 20 +- .../session-persistence-jsonl/README.md | 1 + .../session-persistence-jsonl/src/index.ts | 40 +- .../session-persistence-sqlite/README.md | 3 +- .../session-persistence-sqlite/src/index.ts | 24 +- .../session-persistence-sqlite/src/schema.ts | 19 +- .../tests/sqlite.spec.ts | 17 +- .../session-persistence/README.md | 7 +- .../session-persistence/package.json | 2 + .../session-persistence/src/coordinator.ts | 4 +- .../session-persistence/src/index.ts | 19 + .../session-persistence/src/revision.ts | 15 + .../session-persistence/tests/contract.ts | 22 +- .../tests/persistence.spec.ts | 12 +- .../session-persistence/tsconfig.json | 3 + .../session-query-sqlite/README.md | 10 +- .../session-query-sqlite/src/index.ts | 326 ++++++++++++---- .../session-query-sqlite/src/query.ts | 151 ++++++- .../session-query-sqlite/src/schema.ts | 11 +- .../session-query-sqlite/tests/query.spec.ts | 65 +++- .../session-query-sqlite/tests/sqlite.spec.ts | 367 +++++++++++++++++- .../session-query/session-query/README.md | 3 +- .../session-query/session-query/package.json | 2 + .../session-query/session-query/src/corpus.ts | 21 +- .../session-query/session-query/src/cursor.ts | 15 + .../session-query/src/filters.ts | 121 +++++- .../session-query/session-query/src/index.ts | 62 ++- .../session-query/session-query/src/types.ts | 9 +- .../tests/search-helpers.spec.ts | 27 ++ .../session-query/tests/session-query.spec.ts | 67 +++- .../session-query/session-query/tsconfig.json | 3 + pnpm-lock.yaml | 6 + scripts/type-equiv.manifest.json | 3 + 40 files changed, 1315 insertions(+), 227 deletions(-) create mode 100644 packages/session-persistence/session-persistence/src/revision.ts create mode 100644 packages/session-query/session-query/src/cursor.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3a802231d7..e8a06e469e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -613,7 +613,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-query/session-query-sqlite/src/index.ts:58`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:67`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-skill` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index fef691aae1..e83b96bd11 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -195,11 +195,12 @@ abstract create(meta: SessionHeader): Promise abstract append(id: SessionId, events: readonly SessionEvent[]): Promise abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> abstract list(): Promise +abstract listSnapshots(): Promise ``` Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:102`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:112`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` @@ -207,12 +208,13 @@ Live-preferred logical-corpus and exact-event read service. ```ts cordis-catalog listSessions(): Promise +async filterSessions(filters: readonly SessionResultFilter[]): Promise async listEvents(sessionId: SessionId): Promise async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFilter[], ): Promise async readEvent(request: SessionEventReadRequest): Promise ``` -Source: [`packages/session-query/session-query/src/index.ts:83`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:96`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessions` — `SessionStore` @@ -244,7 +246,7 @@ abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExec abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise> ``` -Source: [`packages/session-query/session-query/src/index.ts:54`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:67`](../../packages/session-query/session-query/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 7bf102924b..9535686f86 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -2,7 +2,7 @@ The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md). -The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). +The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining create/append/load plus lightweight list/snapshot observation over the existing `SessionEvent` — **no parallel persisted event type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). ## The flush checkpoint @@ -78,9 +78,24 @@ interface CreateSessionOptions { Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. +## Lightweight source revisions + +Consumers of derived state compare a cheap opaque revision before loading a full event log. The persistence backend owns its representation and changes it transactionally with append or mutating load repair; callers compare it only for equality. + +```ts type-equiv +export type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'> +``` + +```ts type-equiv +export interface SessionPersistenceSnapshot { + header: SessionHeader + revision: SessionPersistenceRevision +} +``` + ## The backends -Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: +Both implement the same abstract `SessionPersistence` (create/append/load/list/listSnapshots over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: - **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path. - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 27ef9959de..d1ed41b775 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -58,19 +58,23 @@ export interface SessionEventSearchDocument extends SessionEventRecord { } ``` -`ctx.sessionQuery.filterEvents(sessionId, filters)` returns these documents in ascending seq order. Messages, reasoning, tool calls/results, blocked prompts, todos, and failure/status detail contribute semantic text; structural events and stream chunks do not. +`ctx.sessionQuery.filterSessions(filters)` applies `SessionResultFilter` to the complete logical corpus; `ctx.sessionQuery.filterEvents(sessionId, filters)` returns matching documents in ascending seq order. Messages, reasoning, tool calls/results, blocked prompts, todos, and failure/status detail contribute semantic text; structural events and stream chunks do not. ## Full-text search pages The independent `ctx.sessionSearch` seam has two scopes. `searchSessions()` groups the corpus by strongest matching event; `searchEvents()` searches one session. Requests bind an opaque cursor to the normalized query, metadata filters, and limit. The event text scan is intentionally absent from provider metadata filters. +```ts type-equiv +export type SessionSearchCursor = Branded<'SessionSearchCursor'> +``` + ```ts type-equiv export interface SessionSearchRequest { query: string sessionFilters?: readonly SessionResultFilter[] eventFilters?: readonly SessionEventMetadataFilter[] limit?: number - cursor?: string + cursor?: SessionSearchCursor } ``` @@ -80,14 +84,14 @@ export interface SessionEventSearchRequest { query: string filters?: readonly SessionEventMetadataFilter[] limit?: number - cursor?: string + cursor?: SessionSearchCursor } ``` ```ts type-equiv export interface SessionSearchPage { items: readonly T[] - nextCursor?: string + nextCursor?: SessionSearchCursor } ``` diff --git a/docs/module-graph.md b/docs/module-graph.md index 6fcec53dd4..7ad4cd8c70 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -151,6 +151,7 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_web_search_exa --> pkg_web pkg_web_search_perplexity --> pkg_web + pkg_session_persistence --> pkg_brand pkg_session_persistence --> pkg_session pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session @@ -168,6 +169,7 @@ flowchart TD pkg_session_persistence_jsonl --> pkg_session_persistence pkg_session_persistence_sqlite --> pkg_session pkg_session_persistence_sqlite --> pkg_session_persistence + pkg_session_query --> pkg_brand pkg_session_query --> pkg_llm pkg_session_query --> pkg_session pkg_session_query --> pkg_session_persistence @@ -361,7 +363,7 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`web`](../packages/web/web) | -| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | +| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | @@ -369,7 +371,7 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | -| [`session-query`](../packages/session-query/session-query) | `session-query` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | +| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md index be496311cc..10baa3e2c3 100644 --- a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md @@ -10,7 +10,7 @@ Full-text search is related but materially larger. Putting provider coordination ## Decision -`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-read service over one logical corpus. It exposes `listSessions()`, `listEvents(sessionId)`, provider-independent `filterEvents(sessionId, filters)`, and bounded `readEvent(request)`. The package also declares the separate abstract `ctx.sessionSearch` contract and shared semantic extraction used by the [SQLite search decision](2026-07-10-sqlite-session-query-provider.md), but `ctx.sessionQuery` does not coordinate providers or synchronize a derived index. +`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-read service over one logical corpus. It exposes `listSessions()`, provider-independent `filterSessions(filters)`, `listEvents(sessionId)`, `filterEvents(sessionId, filters)`, and bounded `readEvent(request)`. The package also declares the separate abstract `ctx.sessionSearch` contract and shared semantic extraction used by the [SQLite search decision](2026-07-10-sqlite-session-query-provider.md), but `ctx.sessionQuery` does not coordinate providers or synchronize a derived index. The service observes the optional `ctx.sessionPersistence` binding dynamically but retains no persisted cache or invalidation listener. Each cross-corpus list asks the active backend for authoritative metadata, then overlays a fresh live-store list. Matching ids become one `SessionRecord`: the live header wins and `live`/`persisted` independently report source availability. Immutable header disagreement is `SESSION_QUERY_SOURCE_CONFLICT`. diff --git a/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md index d620ece108..fafa7f3569 100644 --- a/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md +++ b/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md @@ -10,19 +10,19 @@ Splitting those concerns across a provider coordinator and a database implementa ## Decision -`@deepseek-ai/dsh-session-query` declares an independent abstract `ctx.sessionSearch` service without changing the exact-read `ctx.sessionQuery` key. `searchSessions(request, exec?)` returns cursor-paginated `SessionSearchHit`s grouped by each session's strongest matching event; `searchEvents(request, exec?)` returns `SessionEventSearchHit`s within one logical session. Both requests require `query`, accept `limit` and an opaque `cursor`, and support an optional abort signal. Session search accepts `sessionFilters` plus event metadata filters; event search accepts event metadata filters. Results expose bounded plain-text snippets but no provider identifier or numeric relevance score. +`@deepseek-ai/dsh-session-query` declares an independent abstract `ctx.sessionSearch` service without changing the exact-read `ctx.sessionQuery` key. `searchSessions(request, exec?)` returns cursor-paginated `SessionSearchHit`s grouped by each session's strongest matching event; `searchEvents(request, exec?)` returns `SessionEventSearchHit`s within one logical session. Both requests require `query`, accept `limit` and an owned branded `SessionSearchCursor`, and support an optional abort signal. Session search accepts `sessionFilters` plus event metadata filters; event search accepts event metadata filters. Results expose bounded plain-text snippets but no provider identifier or numeric relevance score. `@deepseek-ai/dsh-session-query-sqlite` is the sole concrete owner of `ctx.sessionSearch`. It depends on live `ctx.sessions`, observes optional `ctx.sessionPersistence` dynamically, and owns a dedicated derived SQLite database. There is no search-provider registry, coordinator, persistence event, or agent-loop integration. -The interface package also owns shared first-party semantic extraction and provider-independent filtering. `SessionResultFilter` covers id, nullable cwd, created-at range, nullable parent, and availability. `SessionEventResultFilter` covers seq/time ranges, event type, surface, and literal semantic text. Arrays are ANDed and list values are ORed. The text clause escapes caller input into a Unicode case-insensitive regular expression whose whitespace runs match one or more whitespace characters; it is available through `ctx.sessionQuery.filterEvents()` and is not delegated to an FTS provider. +The interface package also owns shared first-party semantic extraction and provider-independent filtering. `SessionResultFilter` covers id, nullable cwd, created-at range, nullable parent, and availability; `ctx.sessionQuery.filterSessions()` applies it without an FTS provider. `SessionEventResultFilter` covers seq/time ranges, event type, surface, and literal semantic text. Arrays are ANDed and list values are ORed. The text clause escapes caller input into a Unicode case-insensitive regular expression whose whitespace runs match one or more whitespace characters; it is available through `ctx.sessionQuery.filterEvents()` and is not delegated to an FTS provider. ## Search semantics Each semantic event is one FTS document carrying session metadata, event metadata, surface classification, and extracted text. All `current`, `shadowed`, and `log-only` documents participate unless a surface filter narrows them. Metadata filters compile to parameterized SQL before ranking. Session results partition matching documents by session and retain the strongest one. -Ordering is deterministic: BM25 ascending, event time descending, session id ascending for the cross-session scope, and seq descending. Provider scores remain private. Snippets normalize whitespace and are bounded by Unicode code points. Opaque cursors bind to the service instance, scope, canonical normalized request, offset, and relevant generation. Any corpus change invalidates cross-session cursors; a within-session cursor changes only when its target source/generation changes, so unrelated sessions do not invalidate it. Reopening creates a new service instance and invalidates old cursors. +Ordering is deterministic and comparable across the persistent and TEMP FTS tables: actual FTS5 highlighted-match span count descending, indexed document code-point length ascending, event time descending, session id ascending for the cross-session scope, and seq descending. Snippets use those actual highlight positions, strip the reserved markers, normalize whitespace, and bound by Unicode code points. Opaque cursors bind to the service instance, scope, canonical normalized request, offset, and relevant generation. Any corpus change invalidates cross-session cursors; a within-session cursor changes only when its target source/generation changes, so unrelated sessions do not invalidate it. Reopening creates a new service instance and invalidates old cursors. -Queries are trimmed, whitespace-normalized, and quoted as one literal FTS5 phrase. Embedded quotes are doubled before binding, so MATCH operators such as `OR`, `NEAR`, quotes, parentheses, and `*` remain data rather than executable query syntax. Phrase matching follows tokenizer tokens rather than arbitrary substrings. +Queries are trimmed, whitespace-normalized, and quoted as one literal FTS5 phrase. Embedded quotes are doubled before binding, so MATCH operators such as `OR`, `NEAR`, quotes, parentheses, and `*` remain data rather than executable query syntax. NUL is rejected before SQLite execution. Reserved highlight noncharacters and NUL in documents are normalized before indexing, making inserted presentation markers collision-free. Phrase matching follows tokenizer tokens rather than arbitrary substrings. ## Tokenizer choice @@ -32,11 +32,11 @@ Both persistent and live FTS5 tables use `unicode61`. The implementation experim The shared extractor includes message text, reasoning, nested tool-call/result content, tool names and arguments, blocked-prompt reasons, todo status/content, and error or terminal status detail. Structural boundaries, stream chunks, request headers, successful completion markers, and unknown declaration-merged event/content variants produce no document. Surface classification reuses `foldSurface()` so search agrees with model-history derivation. -One serialized operation observes complete persisted and live sources, computes stable fingerprints, reconciles rows in one transaction, and executes the query. Unchanged persisted sessions retain their rows and generation. New, changed, and deleted persisted sessions update on the next search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. +One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. Persisted documents survive restarts. Live sessions use connection-local TEMP tables, shadow the persisted base for the same id, and reveal that base on detach. Closing the database drops live rows. Unmounting persistence hides durable rows without treating absence as authoritative deletion; remounting observes and reconciles the backend again. Conflicting immutable live and durable headers fail rather than combining sources. -The derived schema has its own application id and monotonic schema version. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused, which prevents an accidentally configured canonical session database from being reset. +The derived schema has its own application id and monotonic schema version. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused before journal-mode mutation, which prevents an accidentally configured canonical session database from being changed. One service in one process exclusively owns a derived-index path; cross-process writers are unsupported because generations and live TEMP shadow state are connection-owned. Cancellation rejects queued operations and caller waits around asynchronous source observation without committing an aborted observation. Node's synchronous `DatabaseSync` MATCH call cannot be interrupted once it is executing on the JavaScript thread, so the service checks the signal at serialized boundaries but does not promise mid-statement preemption. @@ -46,11 +46,11 @@ Cancellation rejects queued operations and caller waits around asynchronous sour - **Add a phase-one provider registry and coordinator** — rejected because one implementation provides no evidence for registration semantics and would split one reconciliation lifecycle across two owners. - **Persist live overrides immediately** — rejected because live events are not canonical until the existing checkpoint commits. - **Use the FTS5 trigram tokenizer** — rejected because it omits useful queries shorter than three characters and measured about 2.1× the index size of `unicode61`; literal substring filtering remains available through the scan path. -- **Return BM25 scores** — rejected because provider-specific numeric scales are unstable across corpus changes. +- **Use FTS5 BM25 independently in each table** — rejected because scores from differently populated persistent and TEMP corpora are not comparable; actual matched spans and document length have one shared scale. ## Consequences -Search has a small provider-neutral API while its only backend owns every derived-index state transition. The separate database adds configuration and a reconciliation read before queries, but index corruption, reset, and tokenizer changes cannot endanger canonical logs. Persistent fingerprints avoid rewriting unchanged sessions; TEMP live overlays preserve current-session truth without making uncheckpointed events durable. +Search has a small provider-neutral API while its only backend owns every derived-index state transition. The separate database adds configuration and a lightweight snapshot read before queries, but index corruption, reset, and tokenizer changes cannot endanger canonical logs. Durable revisions avoid full-log reads and rewrites for unchanged sessions; TEMP live overlays preserve current-session truth without making uncheckpointed events durable. The chosen tokenizer supports short tokens with a smaller index but does not promise substring recall. Literal phrases make query syntax safe and predictable at the cost of excluding boolean/full MATCH expressions. Cancellation is effective while queued or awaiting sources, but synchronous SQLite execution remains a non-preemptible section. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2859353cfa..7322698cdd 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -149,6 +149,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise', 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', 'abstract list(): Promise', + 'abstract listSnapshots(): Promise', ], }, { @@ -156,6 +157,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Live-preferred logical-corpus and exact-event read service.', methods: [ 'listSessions(): Promise', + 'async filterSessions(filters: readonly SessionResultFilter[]): Promise', 'async listEvents(sessionId: SessionId): Promise', 'async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFilter[], ): Promise', 'async readEvent(request: SessionEventReadRequest): Promise', @@ -834,7 +836,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventSearchRequest', - declaration: 'export interface SessionEventSearchRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: string;\n}', + declaration: 'export interface SessionEventSearchRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', }, { name: 'SessionEventSurface', @@ -860,6 +862,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionId', declaration: 'export type SessionId = Branded<\'SessionId\'>;', }, + { + name: 'SessionPersistenceRevision', + declaration: 'export type SessionPersistenceRevision = Branded<\'SessionPersistenceRevision\'>;', + }, + { + name: 'SessionPersistenceSnapshot', + declaration: 'export interface SessionPersistenceSnapshot {\n header: SessionHeader;\n revision: SessionPersistenceRevision;\n}', + }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', @@ -872,6 +882,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionResultRange', declaration: 'export interface SessionResultRange {\n from?: number;\n to?: number;\n}', }, + { + name: 'SessionSearchCursor', + declaration: 'export type SessionSearchCursor = Branded<\'SessionSearchCursor\'>;', + }, { name: 'SessionSearchExecContext', declaration: 'export interface SessionSearchExecContext {\n signal?: AbortSignal;\n}', @@ -882,11 +896,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionSearchPage', - declaration: 'export interface SessionSearchPage {\n items: readonly T[];\n nextCursor?: string;\n}', + declaration: 'export interface SessionSearchPage {\n items: readonly T[];\n nextCursor?: SessionSearchCursor;\n}', }, { name: 'SessionSearchRequest', - declaration: 'export interface SessionSearchRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: string;\n}', + declaration: 'export interface SessionSearchRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', }, { name: 'SkillCandidate', diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 990ad2fcc4..0be2c785b3 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -25,6 +25,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. - **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). - **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. +- **Lightweight revisions.** `listSnapshots()` reads each header and returns an opaque identity from the file device/inode, size, and nanosecond mtime/ctime. Under the append-only single-writer contract, unchanged files retain revisions while append, repair, or replacement changes them without parsing event lines. - **Format version.** Only the current `SESSION_FORMAT_VERSION` (v0) is supported; `load` rejects any other version. While the harness is unreleased the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 (no bump until the first tagged release) and non-current logs are rejected — there is no migration (no persisted user data to preserve). ## Write path diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index bd31e0ae47..0aea7927d1 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -11,7 +11,7 @@ * (the `session/event` → buffer → `session/flush` drain, per-session * serialization, write cursors, fork-seed persistence, HMR live-adoption, * crash-repair sequencing, dispose quiescence) lives in the backend-agnostic - * {@link PersistenceCoordinator} this class composes. The four public + * {@link PersistenceCoordinator} this class composes. The stateful public * {@link SessionPersistence} methods delegate to the coordinator. * * @module @deepseek-ai/dsh-session-persistence-jsonl @@ -19,12 +19,12 @@ import { Context } from 'cordis' import z from 'schemastery' -import { open, mkdir, readFile, readdir, link, rm, truncate } from 'node:fs/promises' +import { open, mkdir, readFile, readdir, link, rm, stat, truncate } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { - SessionPersistence, PersistenceCoordinator, - type PersistenceBackend, type StoredPrefix, + SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, + type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -179,20 +179,44 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /** List all stored sessions' metadata (header line only — no full-log parse). */ async list(): Promise { - const metas: SessionHeader[] = [] + return (await this.listArtifacts()).map(artifact => artifact.header) + } + + /** List metadata plus a stat-derived identity for each append-only log. */ + async listSnapshots(): Promise { + const snapshots: SessionPersistenceSnapshot[] = [] + for (const artifact of await this.listArtifacts()) { + const identity = await stat(artifact.path, { bigint: true }) + snapshots.push({ + header: artifact.header, + revision: SessionPersistenceRevision([ + identity.dev, + identity.ino, + identity.size, + identity.mtimeNs, + identity.ctimeNs, + ].join(':')), + }) + } + return snapshots + } + + private async listArtifacts(): Promise> { + const artifacts: Array<{ header: SessionHeader; path: string }> = [] for (const dir of await this.listCwdDirs()) { for (const name of await this.listJsonl(dir)) { // Read ONLY the header line, not the whole log: a session picker must // scale with the number of sessions, not the total size of every // conversation (the log persists every assistant/chunk verbatim). - const first = await this.readFirstLine(`${dir}/${name}`) + const path = `${dir}/${name}` + const first = await this.readFirstLine(path) if (first === undefined) continue // empty/half-written file const meta = parseHeaderMeta(first) if (meta === undefined) continue // not a session header - metas.push(meta) + artifacts.push({ header: meta, path }) } } - return metas + return artifacts } // --- materialization / append / repair (file mechanics) --- diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 082b60af88..6e6006dc6b 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -6,7 +6,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed. +Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) and a monotonic per-log revision live in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). The repo's `engines.node` is `^22.19.0 || >=24.0.0` (Node 22.19+ or 24+), matching the LTS floor required by the installed Pi adapter dependency; `node:sqlite` itself ships without the `--experimental-sqlite` flag from Node 22.13 (LTS) and 23.4 / 24 (Current) on. The range deliberately excludes Node 23 because that line is non-LTS/EOL and still has flagged runtime features before 23.6. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and the configured `journal_mode` (default `wal`; pick a rollback-journal mode like `delete` on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software). @@ -14,6 +14,7 @@ The repo's `engines.node` is `^22.19.0 || >=24.0.0` (Node 22.19+ or 24+), matchi - **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.) - **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row). +- **Lightweight revisions.** `listSnapshots()` reads the monotonic revision stored beside each session header. Append and mutating load repair increment it in the same transaction as their event changes, so unchanged observations are stable and no full-log count or parse is required. - **Interrupted-turn close on load.** `load()` reads every stored event ordered by `seq` and finds the longest seq-contiguous, parseable prefix — INCLUDING the real events of an interrupted final turn after the last `turn/end` (the loop only flushes at `turn/end`, so a process killed mid-turn leaves real, fully-written rows past it). A single turn can be huge in a long-horizon task, so those events are **preserved, never truncated**: `load()` CLOSES the orphaned turn by durably appending the minimal synthetic boundary events (an error `tool/result` for every assistant tool call left unanswered, a `step/end` if a step was open, then a `turn/end` carrying `{ kind: 'interrupted' }`), inside one transaction that also DELETEs any never-fully-written torn tail row. `load()` is therefore mutating — after it the stored rows are balanced and the cursor is truthful, so the next `append` continues cleanly. The boundary (last `turn/end`, torn-tail detection) is computed from the `seq`/`type` columns so a malformed `data` in a torn tail row is never parsed (discarded, not unloadable). A parse error or `seq` gap inside the committed region (at or before the last real `turn/end`) makes the session unloadable. A session whose only turn never closed keeps its metadata row and stays present in `list()` — the same as the JSONL backend, whose file likewise survives a first append that never reached `turn/end`. ## Configuration (schemastery) diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 30387b4837..3a1c7ffbcc 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -11,7 +11,7 @@ * Like the JSONL backend it supplies ONLY the storage primitives (the * {@link PersistenceBackend} hooks below — INSERT/DELETE/SELECT inside * transactions); all the write-path orchestration lives in the backend-agnostic - * {@link PersistenceCoordinator} this class composes. The four public + * {@link PersistenceCoordinator} this class composes. The stateful public * {@link SessionPersistence} methods delegate to the coordinator. * * @module @deepseek-ai/dsh-session-persistence-sqlite @@ -23,8 +23,8 @@ import { DatabaseSync } from 'node:sqlite' import { mkdir } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { - SessionPersistence, PersistenceCoordinator, - type PersistenceBackend, type StoredPrefix, + SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, + type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { Session, SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -181,6 +181,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const [surfaceSeqs, surfaceOp] = surfaceBindings(event) insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp) } + this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id) this.db.exec('COMMIT') } catch (error) { this.db.exec('ROLLBACK') @@ -209,6 +210,9 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp) } } + if (tornMarker !== undefined || closers.length > 0) { + this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id) + } this.db.exec('COMMIT') } catch (error) { // The DELETE+INSERT cannot collide (a row at a closer's seq is preserved or @@ -230,6 +234,16 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return rows.map(rowToMeta) } + /** List metadata with an append-only event-count revision per session. */ + async listSnapshots(): Promise { + await this.ready + const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[] + return rows.map(row => ({ + header: rowToMeta(row), + revision: SessionPersistenceRevision(`revision:${row.revision}`), + })) + } + /** Close the database handle (awaited by the coordinator's dispose, post-drain). */ async close(): Promise { await this.ready @@ -250,8 +264,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers */ private writeRow(meta: SessionHeader): void { this.db.prepare(` - INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, revision) + VALUES (?, ?, ?, ?, ?, ?, 0) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 2dacbe04a0..2f238b0131 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -15,7 +15,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 4 +export const SCHEMA_VERSION = 5 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). @@ -31,6 +31,8 @@ export interface SessionRow { cwd: string | null parent_session: string | null seed_length: number | null + /** Monotonic log-change token incremented in each mutating transaction. */ + revision: number } /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */ @@ -67,15 +69,9 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' * current {@link SCHEMA_VERSION}; an existing database whose version is NOT the * current one (written by a different, incompatible build — older or newer) is * REJECTED rather than opened against a layout this build does not understand. - * There are no migrations: an earlier layout is not upgraded in place — it is - * rejected. v1 had a different `sessions` shape; v2 lacked all of - * `seed_length`/`source_event_seqs`/`surface_op`. v3 is SKIPPED: two unmerged - * branches each shipped a DISTINCT v3 (one adding only `seed_length`, the other - * adding only the surface columns), so an on-disk v3 is ambiguous — it could be - * either sibling layout, neither of which has all of this build's columns. v4 - * is the merged layout carrying every column; bumping past the collided v3 - * makes the version check reject both sibling v3 databases instead of opening - * one against columns it does not have. + * There are no migrations: an incompatible layout is rejected. The current + * sessions row carries every header field plus its monotonic snapshot revision; + * the events row carries the complete surface metadata. * @param path - the SQLite database file to open (created when absent). * @param journalMode - the journal pragma to apply — a closed in-code union, validated by the plugin Config. * @returns the open handle with pragmas applied and both tables ensured. @@ -105,7 +101,8 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy created_at INTEGER NOT NULL, cwd TEXT, parent_session TEXT, - seed_length INTEGER + seed_length INTEGER, + revision INTEGER NOT NULL ) STRICT `) db.exec(` diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a6eda72685..98924ca691 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -258,11 +258,11 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => { // Two unmerged branches each shipped a DISTINCT layout under user_version 3 // (one added only `seed_length`, the other only the surface columns). The - // merged build is v4; an on-disk v3 is ambiguous and is missing at least one + // the current build rejects every older layout; an on-disk v3 is ambiguous and is missing at least one // of this build's columns, so it MUST be rejected, not opened. Stamp a v3 // database and confirm the version check refuses it. const path = await freshDbPath() - openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION (4) + openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION const db = openDatabase(path, 'wal') db.exec('PRAGMA user_version = 3') db.close() @@ -338,7 +338,18 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(4) + expect(SCHEMA_VERSION).toBe(5) + }) + + it('keeps the revision stable for an empty repair hook', async () => { + const b = await backend() + const m = meta('empty-repair') + await b.ctx.sessionPersistence.create(m) + await b.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const before = await b.ctx.sessionPersistence.listSnapshots() + await (b.ctx.sessionPersistence as SessionPersistenceSqlite).commitRepair(m, undefined, []) + expect(await b.ctx.sessionPersistence.listSnapshots()).toEqual(before) + await b.dispose() }) }) diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index bf7da03757..32958bfaaa 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -12,6 +12,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `append(id, events): Promise` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | | `list(): Promise` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | +| `listSnapshots(): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log is unchanged and changes after append or mutating load repair. | ## Invariants every backend must honor @@ -24,7 +25,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l The two first-party backends were byte-identical (or same-algorithm) for ALL of their write-path orchestration — the in-memory bookkeeping (per-id state, write-behind buffers, per-id serialization chains, per-session init promises), the `session/event` → buffer → `session/flush` drain, lazy materialization, crash-tail repair on load, the four `session/created` adoption cases (new / HMR-adopt / collision / ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives differed (write bytes vs. INSERT rows). -`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its four public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice). +`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates the stateful write/read methods to the coordinator. Lightweight snapshot listing remains a backend storage primitive because its revision identity is backend-owned. The `PersistenceBackend` hooks (the only seam between the coordinator and storage): @@ -38,11 +39,11 @@ The `PersistenceBackend` hooks (the only seam between the coordinato | `list()` | List all stored metadata. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | -The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). The public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator. See [the write-coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). +The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must also provide trustworthy lightweight snapshot revisions. See [the write-coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). ## Testing backends -Import `runPersistenceContract` from `tests/contract.ts` (the public-API contract) and `runCoordinatorContract` from `tests/coordinator-contract.ts` (the shared write-path orchestration: adoption, HMR, collision, dispose-drain, crash-tail repair) and call each with a fixture for your backend. Every backend is held to the same append-only / contiguous-seq / lazy-materialization / serializability semantics AND the same orchestration, so a backend's own spec is left with only storage-mechanics tests (path sanitization, fsync rollback; schema version, transaction rollback) on top. +Import `runPersistenceContract` from `tests/contract.ts` (the public API, including stable/change-sensitive lightweight revisions) and `runCoordinatorContract` from `tests/coordinator-contract.ts` (the shared write-path orchestration: adoption, HMR, collision, dispose-drain, crash-tail repair) and call each with a fixture for your backend. Every backend is held to the same append-only / contiguous-seq / lazy-materialization / serializability semantics AND the same orchestration, so a backend's own spec is left with only storage-mechanics tests (path sanitization, fsync rollback; schema version, transaction rollback) on top. Three backends run these suites: an in-memory reference (in `tests/`), `dsh-session-persistence-jsonl` (append-only file log) and `dsh-session-persistence-sqlite` (`node:sqlite`, each `SessionEvent` one row `(session_id, seq, type, time, data, source_event_seqs, surface_op)`). All passing the same contract + coordinator suite is the proof that the seam is genuinely backend-agnostic — lazy materialization, crash-tail-on-load, and contiguous-seq hold identically over file bytes and over a transactional store. diff --git a/packages/session-persistence/session-persistence/package.json b/packages/session-persistence/session-persistence/package.json index ed6c80dfd9..8b7140e221 100644 --- a/packages/session-persistence/session-persistence/package.json +++ b/packages/session-persistence/session-persistence/package.json @@ -22,10 +22,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index b1fc118a21..7857b57a3c 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -14,7 +14,7 @@ * {@link PersistenceBackend} hook object. * * The abstract {@link SessionPersistence} service's public API is independent of - * this: a backend IS a `SessionPersistence` (its four public methods delegate to + * this: a backend IS a `SessionPersistence` (its write/read methods delegate to * a coordinator it composes), so a third-party backend MAY implement the service * directly without using the coordinator at all. * @@ -146,7 +146,7 @@ async function settledErrors(promises: Iterable>): Promise + + /** + * List materialized sessions with cheap per-log change tokens. + * + * Repeated observations of an unchanged log return the same revision. A + * successful mutating {@link load} repair changes the next listed revision. + * @returns one header and opaque revision per materialized session without loading full logs. + */ + abstract listSnapshots(): Promise } export default SessionPersistence diff --git a/packages/session-persistence/session-persistence/src/revision.ts b/packages/session-persistence/session-persistence/src/revision.ts new file mode 100644 index 0000000000..41378eb3e4 --- /dev/null +++ b/packages/session-persistence/session-persistence/src/revision.ts @@ -0,0 +1,15 @@ +/** Opaque revision identity for lightweight persistence observations. */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Backend-owned token that changes whenever one persisted session log changes. */ +export type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'> + +/** + * Brand a backend revision for the provider-neutral persistence contract. + * @param value - backend-owned opaque revision representation. + * @returns the same runtime string with persistence-revision identity. + */ +export function SessionPersistenceRevision(value: string): SessionPersistenceRevision { + return value as SessionPersistenceRevision +} diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 789aa72c91..d1699c58a5 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -102,11 +102,16 @@ export function runPersistenceContract(name: string, make: () => Promise snapshot.header.id === m.id)?.revision // load PRESERVES the interrupted turn's events (a turn can be huge — they // must not be truncated) and closes the orphaned turn with synthetic // boundary events: step/end (the step was open) then turn/end {interrupted}. const loaded = await persistence.load(m.id) + const afterRepair = (await persistence.listSnapshots()) + .find(snapshot => snapshot.header.id === m.id)?.revision + expect(afterRepair).not.toBe(beforeRepair) expect(loaded.events.map(e => e.type)).toEqual([ 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1 'turn/start', 'step/start', 'step/end', 'turn/end', // turn 2: real events + synthetic closers @@ -173,18 +178,33 @@ export function runPersistenceContract(name: string, make: () => Promise m.id)).not.toContain(SessionId('empty')) + expect((await persistence.listSnapshots()).map(snapshot => snapshot.header.id)) + .not.toContain(SessionId('empty')) } finally { await dispose() } }) - it('list() includes a session once it has events', async () => { + it('lists stable lightweight revisions that change after an append', async () => { const { persistence, dispose } = await make() try { const m = meta('s2') await persistence.create(m) await persistence.append(m.id, oneTurnLog()) expect((await persistence.list()).map(x => x.id)).toContain(m.id) + const first = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id) + const repeated = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id) + expect(first).toBeDefined() + expect(repeated?.revision).toBe(first?.revision) + + await persistence.append(m.id, [{ + type: 'turn/start', + seq: 6, + time: 7, + data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }, + }]) + const changed = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id) + expect(changed?.revision).not.toBe(first?.revision) } finally { await dispose() } diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 9c766d4b66..00e9863bb8 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -3,8 +3,8 @@ import { Context } from 'cordis' import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { - SessionPersistence, PersistenceCoordinator, assertSerializable, seedCoversPrefix, - type PersistenceBackend, type StoredPrefix, + SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, assertSerializable, seedCoversPrefix, + type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts' @@ -109,6 +109,14 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend async list(): Promise { return [...this.store.values()].map(e => structuredClone(e.meta)) } + + + async listSnapshots(): Promise { + return [...this.store.values()].map(entry => ({ + header: structuredClone(entry.meta), + revision: SessionPersistenceRevision(`events:${entry.events.length}`), + })) + } } // Run the shared contract against the in-memory backend. diff --git a/packages/session-persistence/session-persistence/tsconfig.json b/packages/session-persistence/session-persistence/tsconfig.json index e817086a6a..84c6f5ccb0 100644 --- a/packages/session-persistence/session-persistence/tsconfig.json +++ b/packages/session-persistence/session-persistence/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../util/brand" + }, { "path": "../../core/session" } diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 1f3344887f..001ea87d7d 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -1,22 +1,22 @@ # @deepseek-ai/dsh-session-query-sqlite -SQLite FTS5 implementation of `ctx.sessionSearch`. The service searches the live-preferred logical session corpus, groups cross-session results by their strongest event, and keeps provider-specific BM25 scores private. +SQLite FTS5 implementation of `ctx.sessionSearch`. The service searches the live-preferred logical session corpus and groups cross-session results by their strongest event. ## Search contract `searchSessions(request, exec?)` returns `SessionSearchHit` pages across the corpus; `searchEvents(request, exec?)` returns `SessionEventSearchHit` pages within one session. Queries are required, trimmed, whitespace-normalized literal phrases. FTS5 syntax such as quotes, `OR`, `NEAR`, and `*` is treated as data rather than executable MATCH syntax. Metadata filters are parameterized SQL predicates applied before ranking. -Ordering is deterministic: relevance first, then event time, session id where applicable, and seq. Cross-session results expose the selected event as `bestMatch`; both scopes return plain-text snippets bounded in Unicode code points. Cursors are opaque, bind to the normalized request and service instance, and fail when the relevant generation changes. A within-session cursor survives unrelated-session changes; a cross-session cursor does not. +Relevance is source-comparable across persistent and TEMP tables: actual FTS5 highlighted-match span count descending, then stored document code-point length ascending. Event time, session id where applicable, and seq break remaining ties. Cross-session results expose the selected event as `bestMatch`; both scopes derive whitespace-normalized plain text from FTS5 highlight positions and bound it in Unicode code points. Cursors are opaque branded values, bind to the normalized request and service instance, and fail when the relevant generation changes. A within-session cursor survives unrelated-session changes; a cross-session cursor does not. All three surfaces (`current`, `shadowed`, and `log-only`) are searchable by default. Pass a surface filter to narrow them. ## Source and index lifecycle -The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine observes complete sources, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Stable fingerprints preserve unchanged persisted rows and generations; new, changed, and deleted durable sessions reconcile on the next search. Source or transaction failure commits nothing, and the next search retries. +The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine compares lightweight durable snapshot revisions, loads only new or changed logs, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Repeated queries and unchanged reopen load no full durable logs; new, changed, deleted, or load-repaired sources reconcile on the next stable observation. Source or transaction failure commits nothing, and the next search retries. Persisted FTS rows live in a dedicated derived database. Connection-local TEMP tables hold live rows, which shadow the durable base for the same session and reveal it when the live owner disappears. Unmounting persistence hides durable rows without discarding the cache; remounting reconciles it. Closing or reopening the database drops every live overlay while retaining persisted rows. -The database is disposable but reset is guarded: a recognized incompatible search schema rebuilds in place, while an unrelated or canonical database is refused. Never point `path` at the session-persistence database. +The database is disposable but reset is guarded: a recognized incompatible search schema rebuilds in place, while an unrelated or canonical database is refused before mutating journal mode. Never point `path` at the session-persistence database. Exactly one service in one process owns a derived-index path; external writers or a second process are unsupported because generations and TEMP shadow state are connection-owned. ## Configuration @@ -30,6 +30,6 @@ The database is disposable but reset is guarded: a recognized incompatible searc ## Tokenizer and limits -The index uses FTS5 `unicode61`. In the implementation experiment it supported the two-character query `AI` and produced an index about 2.1× smaller than the trigram alternative. The trade-off is token/phrase recall rather than arbitrary substring recall: `AI` does not match the token `BRAID`. Use `ctx.sessionQuery.filterEvents()` with a `text` clause when a literal whitespace-flexible substring scan is required. +The index uses FTS5 `unicode61`. In the implementation experiment it supported the two-character query `AI` and produced an index about 2.1× smaller than the trigram alternative. The trade-off is token/phrase recall rather than arbitrary substring recall: `AI` does not match the token `BRAID`. Use `ctx.sessionQuery.filterEvents()` with a `text` clause when a literal whitespace-flexible substring scan is required. NUL is rejected in queries; reserved highlight markers and NUL in documents are normalized before indexing so presentation markers cannot collide with source text. Abort signals stop queued work and caller waits around asynchronous source observation. Node's synchronous `DatabaseSync` API cannot interrupt a MATCH statement already executing on the JavaScript thread; the signal is checked immediately before and after the serialized observation/reconciliation boundary. diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index a6cba8d866..dd1ddb532a 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -6,12 +6,17 @@ import { createHash, randomUUID } from 'node:crypto' import { DatabaseSync } from 'node:sqlite' -import { Context } from 'cordis' +import { Context, type Fiber } from 'cordis' import z from 'schemastery' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import type { + SessionPersistenceRevision, + SessionPersistenceSnapshot, +} from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, + SessionSearchCursor, SessionSearchService, assertSessionHeadersCompatible, buildSessionEventSearchDocuments, @@ -22,6 +27,7 @@ import type { SessionEventSearchRequest, SessionSearchExecContext, SessionSearchHit, + SessionSearchCursor as SessionSearchCursorValue, SessionSearchPage, SessionSearchRequest, } from '@deepseek-ai/dsh-session-query' @@ -32,6 +38,8 @@ import { import { type NormalizedEventRequest, type NormalizedSessionRequest, + FTS_HIGHLIGHT_END, + FTS_HIGHLIGHT_START, buildEventWhere, buildSessionWhere, makeSnippet, @@ -39,6 +47,7 @@ import { normalizeSessionRequest, quoteFtsData, requestFingerprint, + sanitizeFtsText, } from './query.ts' export { @@ -78,19 +87,30 @@ interface ResolvedConfig { interface ObservedSession { header: SessionHeader - events: SessionEvent[] documents: SessionEventSearchDocument[] fingerprint: string } +interface ObservedPersistedSession { + header: SessionHeader + revision: SessionPersistenceRevision + loaded?: ObservedSession +} + interface Observation { persistence: SessionPersistence | undefined persistenceRevision: number - persisted: Map + persisted: Map live: Map } -interface IndexedRow { +interface IndexedPersistedRow { + id: string + revision: string + generation: number +} + +interface IndexedLiveRow { id: string fingerprint: string generation: number @@ -109,8 +129,9 @@ interface SearchRow { type: string time: number surface: string - text: string - score: number + marked_text: string + match_count: number + document_length: number } interface CursorPayload { @@ -149,27 +170,32 @@ export class SessionSearchSqlite extends SessionSearchService { private _localGeneration = 0 private _tail: Promise = Promise.resolve() private _closed = false + private _closePromise: Promise | undefined + private readonly _optionalPersistenceFiber: Fiber constructor(ctx: Context, config: Config) { super(ctx) this.config = resolveConfig(config) this._ready = this._open() - ctx.effect(() => { - const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { - const service = childCtx.sessionPersistence - const binding = {} - this._persistenceBinding = binding - this._persistence = service + // Attach a rejection observer immediately; callers still receive the same + // rejection from `_ready`, including when no search is ever attempted. + void this._ready.catch(() => undefined) + this._optionalPersistenceFiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { + const service = childCtx.sessionPersistence + const binding = {} + this._persistenceBinding = binding + this._persistence = service + this._persistenceRevision += 1 + childCtx.effect(() => () => { + /* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */ + if (this._persistenceBinding !== binding) return + this._persistenceBinding = undefined + this._persistence = undefined this._persistenceRevision += 1 - childCtx.effect(() => () => { - /* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */ - if (this._persistenceBinding !== binding) return - this._persistenceBinding = undefined - this._persistence = undefined - this._persistenceRevision += 1 - }, 'sessionSearchSqlite.persistenceBinding') - }) - return () => void fiber.dispose() + }, 'sessionSearchSqlite.persistenceBinding') + }) + ctx.effect(() => { + return () => this._optionalPersistenceFiber.dispose() }, 'sessionSearchSqlite.optionalPersistence') ctx.effect(() => async () => this.close(), 'sessionSearchSqlite.close') } @@ -179,17 +205,18 @@ export class SessionSearchSqlite extends SessionSearchService { exec?: SessionSearchExecContext, ): Promise> { const normalized = normalizeSessionRequest(request, this.config) - return this._serialized(exec?.signal, async () => { - await this._ensureReady(exec?.signal) - await this._reconcile(exec?.signal) - assertNotAborted(exec?.signal) + const signal = exec?.signal + return this._serialized(signal, async () => { + await this._ensureReady(signal) + await this._reconcile(signal) + assertNotAborted(signal) const generation = String(this._globalGeneration) const fingerprint = requestFingerprint(normalized) const offset = normalized.cursor === undefined ? 0 : decodeCursor(normalized.cursor, this._instance, 'sessions', fingerprint, generation) const rows = this._querySessions(normalized, offset) - return page(rows, normalized.limit, row => this._sessionHit(row, normalized.query), cursorOffset => encodeCursor({ + return page(rows, normalized.limit, row => this._sessionHit(row), cursorOffset => encodeCursor({ version: 1, instance: this._instance, scope: 'sessions', @@ -205,17 +232,18 @@ export class SessionSearchSqlite extends SessionSearchService { exec?: SessionSearchExecContext, ): Promise> { const normalized = normalizeEventRequest(request, this.config) - return this._serialized(exec?.signal, async () => { - await this._ensureReady(exec?.signal) - await this._reconcile(exec?.signal) - assertNotAborted(exec?.signal) + const signal = exec?.signal + return this._serialized(signal, async () => { + await this._ensureReady(signal) + await this._reconcile(signal) + assertNotAborted(signal) const generation = this._targetGeneration(normalized.sessionId) const fingerprint = requestFingerprint(normalized) const offset = normalized.cursor === undefined ? 0 : decodeCursor(normalized.cursor, this._instance, 'events', fingerprint, generation) const rows = this._queryEvents(normalized, offset) - return page(rows, normalized.limit, row => this._eventHit(row, normalized.query), cursorOffset => encodeCursor({ + return page(rows, normalized.limit, row => this._eventHit(row), cursorOffset => encodeCursor({ version: 1, instance: this._instance, scope: 'events', @@ -227,8 +255,12 @@ export class SessionSearchSqlite extends SessionSearchService { } /** Close the database after every accepted operation reaches quiescence. */ - async close(): Promise { - if (this._closed) return + close(): Promise { + this._closePromise ??= this._close() + return this._closePromise + } + + private async _close(): Promise { this._closed = true await this._tail try { @@ -287,20 +319,20 @@ export class SessionSearchSqlite extends SessionSearchService { } private async _reconcile(signal: AbortSignal | undefined): Promise { - const observation = await this._observeStable(signal) - assertNotAborted(signal) const db = this._requireDb() const persistedRows = db.prepare( - 'SELECT id, fingerprint, generation FROM persisted_sessions', - ).all() as unknown as IndexedRow[] + 'SELECT id, revision, generation FROM persisted_sessions', + ).all() as unknown as IndexedPersistedRow[] const liveRows = db.prepare( 'SELECT id, fingerprint, generation FROM temp.live_sessions', - ).all() as unknown as IndexedRow[] + ).all() as unknown as IndexedLiveRow[] const persistedById = new Map(persistedRows.map(row => [row.id as SessionId, row])) const liveById = new Map(liveRows.map(row => [row.id as SessionId, row])) + const observation = await this._observeStable(persistedById, signal) + assertNotAborted(signal) const persistentChanges = observation.persistence === undefined ? [] - : [...observation.persisted.values()].filter(entry => persistedById.get(entry.header.id)?.fingerprint !== entry.fingerprint) + : [...observation.persisted.values()].filter(entry => entry.loaded !== undefined) const persistentDeletes = observation.persistence === undefined ? [] : persistedRows.filter(row => !observation.persisted.has(row.id as SessionId)) @@ -327,13 +359,17 @@ export class SessionSearchSqlite extends SessionSearchService { db.exec('BEGIN IMMEDIATE') began = true for (const row of persistentDeletes) this._deleteSession('persisted', row.id as SessionId) - for (const entry of persistentChanges) this._replaceSession('persisted', entry, nextMainGeneration) + for (const entry of persistentChanges) { + /* v8 ignore next -- observation loads every entry whose revision differs */ + if (entry.loaded === undefined) throw new Error(`missing loaded revision for session "${entry.header.id}"`) + this._replacePersistedSession(entry.loaded, entry.revision, nextMainGeneration) + } if (persistentChanges.length > 0 || persistentDeletes.length > 0) { db.prepare('UPDATE search_state SET global_generation = ? WHERE singleton = 1').run(nextMainGeneration) } for (const row of liveDeletes) this._deleteSession('live', row.id as SessionId) for (const { entry, generation } of liveReplacements) { - this._replaceSession('live', entry, generation) + this._replaceLiveSession(entry, generation) } db.exec('COMMIT') } catch (error: unknown) { @@ -360,21 +396,39 @@ export class SessionSearchSqlite extends SessionSearchService { this._lastPersistenceRevision = observation.persistenceRevision } - private async _observeStable(signal: AbortSignal | undefined): Promise { + private async _observeStable( + indexed: ReadonlyMap, + signal: AbortSignal | undefined, + ): Promise { for (;;) { assertNotAborted(signal) const persistence = this._persistence const persistenceRevision = this._persistenceRevision - const persisted = new Map() + let persisted = new Map() if (persistence !== undefined) { try { - const headers = await waitWithAbort(persistence.list(), signal) - for (const listed of headers) { - const loaded = await waitWithAbort(persistence.load(listed.id), signal) - assertSessionHeadersCompatible(listed, loaded.meta) - persisted.set(listed.id, observeSession(loaded.meta, loaded.events)) + const canReuseIndexed = this._lastPersistenceRevision === undefined + || this._lastPersistenceRevision === persistenceRevision + const before = await waitWithAbort(persistence.listSnapshots(), signal) + persisted = materializePersistenceSnapshots(before) + for (const entry of persisted.values()) { + if (canReuseIndexed && indexed.get(entry.header.id)?.revision === entry.revision) continue + const loaded = await waitWithAbort(persistence.load(entry.header.id), signal) + assertSessionHeadersCompatible(entry.header, loaded.meta) + entry.loaded = observeSession(loaded.meta, loaded.events) } + const after = materializePersistenceSnapshots( + await waitWithAbort(persistence.listSnapshots(), signal), + ) + if (!samePersistenceSnapshots(persisted, after)) continue + if (this._persistenceRevision !== persistenceRevision) continue } catch (error: unknown) { + if (isAbort(error) || signal?.aborted) { + throw new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED', { + cause: error, + }) + } + if (this._persistenceRevision !== persistenceRevision) continue if (error instanceof SessionQueryError) throw error throw new SessionQueryError( `session-search persistence observation failed: ${errorMessage(error)}`, @@ -414,13 +468,50 @@ export class SessionSearchSqlite extends SessionSearchService { } } - private _replaceSession(source: 'persisted' | 'live', entry: ObservedSession, generation: number): void { - this._deleteSession(source, entry.header.id) + private _replacePersistedSession( + entry: ObservedSession, + revision: SessionPersistenceRevision, + generation: number, + ): void { + this._deleteSession('persisted', entry.header.id) const db = this._requireDb() - const sessionTable = source === 'persisted' ? 'persisted_sessions' : 'temp.live_sessions' - const docsTable = source === 'persisted' ? 'persisted_docs' : 'temp.live_docs' db.prepare(` - INSERT INTO ${sessionTable} + INSERT INTO persisted_sessions + (id, version, created_at, cwd, parent_session, seed_length, revision, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + entry.header.id, + entry.header.version, + entry.header.createdAt, + entry.header.cwd ?? null, + entry.header.parentSession ?? null, + entry.header.seedLength ?? null, + revision, + generation, + ) + const insert = db.prepare(` + INSERT INTO persisted_docs (text, session_id, seq, type, time, surface, codepoint_length) + VALUES (?, ?, ?, ?, ?, ?, ?) + `) + for (const document of entry.documents) { + const text = sanitizeFtsText(document.text) + insert.run( + text, + document.sessionId, + document.seq, + document.type, + document.time, + document.surface, + Array.from(text).length, + ) + } + } + + private _replaceLiveSession(entry: ObservedSession, generation: number): void { + this._deleteSession('live', entry.header.id) + const db = this._requireDb() + db.prepare(` + INSERT INTO temp.live_sessions (id, version, created_at, cwd, parent_session, seed_length, fingerprint, generation) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run( @@ -434,11 +525,20 @@ export class SessionSearchSqlite extends SessionSearchService { generation, ) const insert = db.prepare(` - INSERT INTO ${docsTable} (text, session_id, seq, type, time, surface) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO temp.live_docs (text, session_id, seq, type, time, surface, codepoint_length) + VALUES (?, ?, ?, ?, ?, ?, ?) `) for (const document of entry.documents) { - insert.run(document.text, document.sessionId, document.seq, document.type, document.time, document.surface) + const text = sanitizeFtsText(document.text) + insert.run( + text, + document.sessionId, + document.seq, + document.type, + document.time, + document.surface, + Array.from(text).length, + ) } } @@ -455,19 +555,16 @@ export class SessionSearchSqlite extends SessionSearchService { ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY session_id - ORDER BY score ASC, time DESC, seq DESC + ORDER BY match_count DESC, document_length ASC, time DESC, seq DESC ) AS event_rank FROM filtered ) SELECT * FROM ranked WHERE event_rank = 1 - ORDER BY score ASC, time DESC, session_id ASC, seq DESC + ORDER BY match_count DESC, document_length ASC, time DESC, session_id ASC, seq DESC LIMIT ? OFFSET ? `).all( - quoteFtsData(request.query), - this._persistence === undefined ? 0 : 1, - this._persistence === undefined ? 0 : 1, - quoteFtsData(request.query), + ...selectedDocumentsParams(request.query, this._persistence !== undefined), ...sessionWhere.params, ...eventWhere.params, request.limit + 1, @@ -483,13 +580,10 @@ export class SessionSearchSqlite extends SessionSearchService { ${selected.sql} SELECT * FROM matched WHERE ${where} - ORDER BY score ASC, time DESC, seq DESC + ORDER BY match_count DESC, document_length ASC, time DESC, seq DESC LIMIT ? OFFSET ? `).all( - quoteFtsData(request.query), - this._persistence === undefined ? 0 : 1, - this._persistence === undefined ? 0 : 1, - quoteFtsData(request.query), + ...selectedDocumentsParams(request.query, this._persistence !== undefined), request.sessionId, ...eventWhere.params, request.limit + 1, @@ -515,23 +609,23 @@ export class SessionSearchSqlite extends SessionSearchService { ) } - private _sessionHit(row: SearchRow, query: string): SessionSearchHit { + private _sessionHit(row: SearchRow): SessionSearchHit { return { header: rowHeader(row), live: row.live === 1, persisted: row.persisted === 1, - bestMatch: this._eventHit(row, query), + bestMatch: this._eventHit(row), } } - private _eventHit(row: SearchRow, query: string): SessionEventSearchHit { + private _eventHit(row: SearchRow): SessionEventSearchHit { return { sessionId: row.session_id as SessionId, seq: row.seq, type: row.type as SessionEventSearchHit['type'], time: row.time, surface: row.surface as SessionEventSearchHit['surface'], - snippet: makeSnippet(row.text, query, this.config.snippetChars), + snippet: makeSnippet(row.marked_text, this.config.snippetChars), } } @@ -548,7 +642,7 @@ export class SessionSearchSqlite extends SessionSearchService { function selectedDocumentsSql(): { sql: string } { return { - sql: `WITH matched AS ( + sql: `WITH candidates AS ( SELECT pd.session_id AS session_id, ps.version AS version, @@ -562,8 +656,8 @@ function selectedDocumentsSql(): { sql: string } { pd.type AS type, CAST(pd.time AS INTEGER) AS time, pd.surface AS surface, - pd.text AS text, - bm25(persisted_docs) AS score + highlight(persisted_docs, 0, ?, ?) AS marked_text, + CAST(pd.codepoint_length AS INTEGER) AS document_length FROM persisted_docs AS pd JOIN persisted_sessions AS ps ON ps.id = pd.session_id WHERE persisted_docs MATCH ? @@ -585,15 +679,39 @@ function selectedDocumentsSql(): { sql: string } { ld.type AS type, CAST(ld.time AS INTEGER) AS time, ld.surface AS surface, - ld.text AS text, - bm25(live_docs) AS score + highlight(live_docs, 0, ?, ?) AS marked_text, + CAST(ld.codepoint_length AS INTEGER) AS document_length FROM temp.live_docs AS ld JOIN temp.live_sessions AS ls ON ls.id = ld.session_id WHERE live_docs MATCH ? + ), matched AS ( + SELECT *, + ( + length(CAST(marked_text AS BLOB)) + - length(CAST(replace(marked_text, ?, '') AS BLOB)) + ) / ? AS match_count + FROM candidates )`, } } +function selectedDocumentsParams(query: string, persistenceVisible: boolean): Array { + const expression = quoteFtsData(query) + const visible = persistenceVisible ? 1 : 0 + return [ + FTS_HIGHLIGHT_START, + FTS_HIGHLIGHT_END, + expression, + visible, + visible, + FTS_HIGHLIGHT_START, + FTS_HIGHLIGHT_END, + expression, + FTS_HIGHLIGHT_START, + Buffer.byteLength(FTS_HIGHLIGHT_START, 'utf8'), + ] +} + function observeLive(session: Session): ObservedSession { return observeSession( structuredClone(session.header), @@ -606,7 +724,6 @@ function observeSession(header: SessionHeader, events: readonly SessionEvent[]): const detachedEvents = events.map(event => structuredClone(event)) return { header: detachedHeader, - events: detachedEvents, documents: buildSessionEventSearchDocuments(detachedHeader.id, detachedEvents), fingerprint: createHash('sha256') .update(JSON.stringify({ header: detachedHeader, events: detachedEvents })) @@ -614,6 +731,49 @@ function observeSession(header: SessionHeader, events: readonly SessionEvent[]): } } +function materializePersistenceSnapshots( + snapshots: readonly SessionPersistenceSnapshot[], +): Map { + if (!isRuntimeArray(snapshots)) throw new Error('persistence snapshots must be an array') + const result = new Map() + for (const snapshot of snapshots) { + if (typeof snapshot.revision !== 'string') { + throw new Error('persistence snapshot revision must be a string') + } + const header = structuredClone(snapshot.header) + if (result.has(header.id)) { + throw new Error(`persistence listed duplicate session "${header.id}"`) + } + result.set(header.id, { header, revision: snapshot.revision }) + } + return result +} + +function samePersistenceSnapshots( + before: ReadonlyMap, + after: ReadonlyMap, +): boolean { + if (before.size !== after.size) return false + for (const [id, first] of before) { + const second = after.get(id) + if ( + second === undefined + || first.revision !== second.revision + || !sameHeader(first.header, second.header) + ) return false + } + return true +} + +function sameHeader(a: SessionHeader, b: SessionHeader): boolean { + return a.version === b.version + && a.id === b.id + && a.createdAt === b.createdAt + && a.cwd === b.cwd + && a.parentSession === b.parentSession + && a.seedLength === b.seedLength +} + function rowHeader(row: SearchRow): SessionHeader { return { version: row.version, @@ -629,7 +789,7 @@ function page( rows: readonly Row[], limit: number, convert: (row: Row) => Item, - nextCursor: (offset: number) => string, + nextCursor: (offset: number) => SessionSearchCursorValue, offset: number, ): SessionSearchPage { const hasMore = rows.length > limit @@ -639,12 +799,12 @@ function page( } } -function encodeCursor(payload: CursorPayload): string { - return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') +function encodeCursor(payload: CursorPayload): SessionSearchCursorValue { + return SessionSearchCursor(Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')) } function decodeCursor( - cursor: string, + cursor: SessionSearchCursorValue, instance: string, scope: CursorPayload['scope'], fingerprint: string, @@ -762,4 +922,8 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : 'unknown error' } +function isRuntimeArray(value: unknown): boolean { + return Array.isArray(value) +} + export default SessionSearchSqlite diff --git a/packages/session-query/session-query-sqlite/src/query.ts b/packages/session-query/session-query-sqlite/src/query.ts index fd2b5156e6..9654f6ae70 100644 --- a/packages/session-query/session-query-sqlite/src/query.ts +++ b/packages/session-query/session-query-sqlite/src/query.ts @@ -2,16 +2,24 @@ import { SessionQueryError, - filterSessionEventDocuments, - filterSessionResults, + materializeSessionEventResultFilters, + materializeSessionResultFilters, } from '@deepseek-ai/dsh-session-query' import type { + SessionAvailability, SessionEventMetadataFilter, + SessionEventResultFilter, SessionEventSearchRequest, SessionResultFilter, + SessionSearchCursor, SessionSearchRequest, } from '@deepseek-ai/dsh-session-query' +/** Collision-free marker inserted before an FTS5 match by `highlight()`. */ +export const FTS_HIGHLIGHT_START = '\uFDD0' +/** Collision-free marker inserted after an FTS5 match by `highlight()`. */ +export const FTS_HIGHLIGHT_END = '\uFDD1' + /** Limit defaults needed to normalize a search request. */ export interface QueryLimits { /** Page size used when the request omits one. */ @@ -26,7 +34,7 @@ export interface NormalizedSessionRequest { sessionFilters: readonly SessionResultFilter[] eventFilters: readonly SessionEventMetadataFilter[] limit: number - cursor?: string + cursor?: SessionSearchCursor } /** Normalized within-session request. */ @@ -35,7 +43,7 @@ export interface NormalizedEventRequest { query: string filters: readonly SessionEventMetadataFilter[] limit: number - cursor?: string + cursor?: SessionSearchCursor } /** Parameterized SQL predicate fragment. */ @@ -56,16 +64,15 @@ export function normalizeSessionRequest( request: SessionSearchRequest, limits: QueryLimits, ): NormalizedSessionRequest { - const sessionFilters = request.sessionFilters ?? [] - const eventFilters = request.eventFilters ?? [] - filterSessionResults([], sessionFilters) - filterSessionEventDocuments([], eventFilters) + const sessionFilters = materializeSessionResultFilters(request.sessionFilters ?? []) + const eventFilters = materializeMetadataFilters(request.eventFilters ?? []) + const cursor = materializeCursor(request.cursor) return { query: normalizeQuery(request.query), sessionFilters, eventFilters, limit: normalizeLimit(request.limit, limits), - ...request.cursor === undefined ? {} : { cursor: request.cursor }, + ...cursor === undefined ? {} : { cursor }, } } @@ -79,14 +86,17 @@ export function normalizeEventRequest( request: SessionEventSearchRequest, limits: QueryLimits, ): NormalizedEventRequest { - const filters = request.filters ?? [] - filterSessionEventDocuments([], filters) + if (typeof request.sessionId !== 'string') { + throw new SessionQueryError('session-search session id must be text', 'SESSION_QUERY_INVALID_FILTER') + } + const filters = materializeMetadataFilters(request.filters ?? []) + const cursor = materializeCursor(request.cursor) return { sessionId: request.sessionId, query: normalizeQuery(request.query), filters, limit: normalizeLimit(request.limit, limits), - ...request.cursor === undefined ? {} : { cursor: request.cursor }, + ...cursor === undefined ? {} : { cursor }, } } @@ -115,9 +125,23 @@ export function buildSessionWhere(filters: readonly SessionResultFilter[]): SqlW case 'availability': { const availability = [...new Set(filter.values)] if (availability.length === 0) clauses.push('0') - else if (availability.length === 1) clauses.push(`${availability[0]} = 1`) + else if (availability.length === 1) { + const value = availability[0] as SessionAvailability + switch (value) { + case 'live': + clauses.push('live = 1') + break + case 'persisted': + clauses.push('persisted = 1') + break + default: + unknownAvailability(value) + } + } break } + default: + unknownFilter(filter) } } return { sql: clauses.join(' AND '), params } @@ -145,6 +169,8 @@ export function buildEventWhere(filters: readonly SessionEventMetadataFilter[]): case 'surface': addList(clauses, params, 'surface', filter.values) break + default: + unknownFilter(filter) } } return { sql: clauses.join(' AND '), params } @@ -159,6 +185,18 @@ export function quoteFtsData(query: string): string { return `"${query.replaceAll('"', '""')}"` } +/** + * Remove reserved marker collisions before text enters FTS5 or MATCH. + * @param text - extracted document text or normalized caller query. + * @returns text with reserved noncharacters mapped to replacement characters. + */ +export function sanitizeFtsText(text: string): string { + return text + .replaceAll('\0', '\uFFFD') + .replaceAll(FTS_HIGHLIGHT_START, '\uFFFD') + .replaceAll(FTS_HIGHLIGHT_END, '\uFFFD') +} + /** * Build the stable normalized request identity stored in opaque cursors. * @param request - normalized request whose filter ordering is canonicalized. @@ -185,19 +223,16 @@ export function requestFingerprint(request: NormalizedSessionRequest | Normalize /** * Build a whitespace-normalized excerpt no longer than `maxChars`. - * @param text - complete extracted semantic document. - * @param query - normalized literal query used to position the excerpt. + * @param markedText - complete document with FTS5 `highlight()` markers. * @param maxChars - maximum result length in Unicode code points. * @returns bounded plain-text snippet. */ -export function makeSnippet(text: string, query: string, maxChars: number): string { - const clean = text.replace(/\s+/gu, ' ').trim() +export function makeSnippet(markedText: string, maxChars: number): string { + const { text: clean, matchStart } = normalizeMarkedText(markedText) const characters = Array.from(clean) if (characters.length <= maxChars) return clean if (maxChars === 1) return '…' - const foundUnits = clean.toLowerCase().indexOf(query.toLowerCase()) - const found = foundUnits < 0 ? -1 : Array.from(clean.slice(0, foundUnits)).length - let start = found < 0 ? 0 : Math.max(0, found - Math.floor(maxChars / 3)) + let start = Math.max(0, matchStart - Math.floor(maxChars / 3)) let prefix = start > 0 ? '…' : '' let suffix = '…' let contentLength = maxChars - prefix.length - suffix.length @@ -216,6 +251,28 @@ export function makeSnippet(text: string, query: string, maxChars: number): stri return `${prefix}${characters.slice(start, end).join('')}${suffix}` } +function normalizeMarkedText(markedText: string): { text: string; matchStart: number } { + const characters: string[] = [] + let matchStart: number | undefined + for (const character of markedText) { + if (character === FTS_HIGHLIGHT_START) { + matchStart ??= characters.length + continue + } + if (character === FTS_HIGHLIGHT_END) continue + if (/\s/u.test(character)) { + if (characters.length > 0 && characters.at(-1) !== ' ') characters.push(' ') + } else { + characters.push(character) + } + } + if (characters.at(-1) === ' ') characters.pop() + return { + text: characters.join(''), + matchStart: matchStart ?? 0, + } +} + function normalizeQuery(value: string): string { if (typeof value !== 'string') { throw new SessionQueryError('session-search query must be text', 'SESSION_QUERY_INVALID_QUERY') @@ -227,7 +284,44 @@ function normalizeQuery(value: string): string { 'SESSION_QUERY_INVALID_QUERY', ) } - return query + if (query.includes('\0')) { + throw new SessionQueryError( + 'session-search query must not contain NUL', + 'SESSION_QUERY_INVALID_QUERY', + ) + } + return sanitizeFtsText(query) +} + +function materializeCursor(cursor: SessionSearchCursor | undefined): SessionSearchCursor | undefined { + if (cursor === undefined) return undefined + if (typeof cursor !== 'string') { + throw new SessionQueryError('session-search cursor must be text', 'SESSION_QUERY_INVALID_CURSOR') + } + return cursor +} + +function materializeMetadataFilters( + filters: readonly SessionEventMetadataFilter[], +): SessionEventMetadataFilter[] { + const candidates: readonly SessionEventResultFilter[] = filters + for (const filter of candidates) { + switch (filter.kind) { + case 'seq': + case 'time': + case 'type': + case 'surface': + break + case 'text': + throw new SessionQueryError( + 'session-search metadata filters do not accept text clauses', + 'SESSION_QUERY_INVALID_FILTER', + ) + default: + unknownFilter(filter) + } + } + return materializeSessionEventResultFilters(filters) as SessionEventMetadataFilter[] } function normalizeLimit(value: number | undefined, limits: QueryLimits): number { @@ -310,3 +404,18 @@ function compareNullable(a: string | null, b: string | null): number { if (b === null) return 1 return a.localeCompare(b) } + +function unknownAvailability(value: never): never { + throw new SessionQueryError( + `session availability filter contains unknown value "${String(value)}"`, + 'SESSION_QUERY_INVALID_FILTER', + ) +} + +function unknownFilter(filter: never): never { + const kind = (filter as { kind?: unknown }).kind + throw new SessionQueryError( + `session filter contains unknown kind ${typeof kind === 'string' ? `"${kind}"` : '(missing)'}`, + 'SESSION_QUERY_INVALID_FILTER', + ) +} diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 1c9bd98791..8e5c85f676 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -5,7 +5,7 @@ import { mkdir } from 'node:fs/promises' import { dirname, resolve } from 'node:path' /** Current derived-index schema version. Incompatible versions reset in place. */ -export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 1 +export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 2 /** SQLite application id protecting unrelated databases from derived resets. */ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 @@ -24,8 +24,6 @@ export async function openSearchDatabase(path: string, journalMode: JournalMode) if (actual !== ':memory:') await mkdir(dirname(actual), { recursive: true, mode: 0o700 }) const db = new DatabaseSync(actual) try { - // journalMode is a validated closed union, not caller-controlled SQL. - db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`) const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number } const { user_version: version } = db.prepare('PRAGMA user_version').get() as { user_version: number } const userTables = listUserTables(db) @@ -38,6 +36,9 @@ export async function openSearchDatabase(path: string, journalMode: JournalMode) if (applicationId === SESSION_QUERY_SQLITE_APPLICATION_ID && version !== SESSION_QUERY_SQLITE_SCHEMA_VERSION) { resetDerivedSchema(db) } + // Apply mutating pragmas only after refusing foreign or canonical files. + // journalMode is a validated closed union, not caller-controlled SQL. + db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`) ensurePersistentSchema(db) ensureTemporarySchema(db) return db @@ -78,7 +79,7 @@ function ensurePersistentSchema(db: DatabaseSync): void { cwd TEXT, parent_session TEXT, seed_length INTEGER, - fingerprint TEXT NOT NULL, + revision TEXT NOT NULL, generation INTEGER NOT NULL ) STRICT `) @@ -90,6 +91,7 @@ function ensurePersistentSchema(db: DatabaseSync): void { type UNINDEXED, time UNINDEXED, surface UNINDEXED, + codepoint_length UNINDEXED, tokenize = 'unicode61' ) `) @@ -117,6 +119,7 @@ function ensureTemporarySchema(db: DatabaseSync): void { type UNINDEXED, time UNINDEXED, surface UNINDEXED, + codepoint_length UNINDEXED, tokenize = 'unicode61' ) `) diff --git a/packages/session-query/session-query-sqlite/tests/query.spec.ts b/packages/session-query/session-query-sqlite/tests/query.spec.ts index 0faced72e4..f9c0a5d19e 100644 --- a/packages/session-query/session-query-sqlite/tests/query.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/query.spec.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' +import { SessionSearchCursor, type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' import { buildEventWhere, buildSessionWhere, + FTS_HIGHLIGHT_END, + FTS_HIGHLIGHT_START, makeSnippet, normalizeEventRequest, normalizeSessionRequest, @@ -32,13 +34,13 @@ describe('SQLite search request normalization', () => { sessionFilters: [{ kind: 'availability', values: ['live'] }], eventFilters: [{ kind: 'surface', values: ['current'] }], limit: 3, - cursor: 'next', + cursor: SessionSearchCursor('next'), }, limits)).toEqual({ query: 'needle', sessionFilters: [{ kind: 'availability', values: ['live'] }], eventFilters: [{ kind: 'surface', values: ['current'] }], limit: 3, - cursor: 'next', + cursor: SessionSearchCursor('next'), }) expect(normalizeEventRequest({ sessionId: SessionId('s'), query: 'needle' }, limits)).toEqual({ sessionId: SessionId('s'), @@ -50,13 +52,13 @@ describe('SQLite search request normalization', () => { sessionId: SessionId('s'), query: 'needle', filters: [{ kind: 'seq', from: 1 }], - cursor: 'next', + cursor: SessionSearchCursor('next'), }, limits)).toEqual({ sessionId: SessionId('s'), query: 'needle', filters: [{ kind: 'seq', from: 1 }], limit: 2, - cursor: 'next', + cursor: SessionSearchCursor('next'), }) }) @@ -65,11 +67,39 @@ describe('SQLite search request normalization', () => { .toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) expect(() => normalizeSessionRequest({ query: ' \n ' }, limits)) .toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) + expect(() => normalizeSessionRequest({ query: 'bad\0query' }, limits)) + .toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) + expect(() => normalizeEventRequest({ sessionId: 1 as never, query: 'x' }, limits)) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => normalizeEventRequest({ + sessionId: SessionId('s'), + query: 'x', + cursor: 1 as never, + }, limits)).toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR')) + expect(() => normalizeSessionRequest({ + query: 'x', + eventFilters: [{ kind: 'text', text: 'x' } as never], + }, limits)).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => normalizeSessionRequest({ + query: 'x', + eventFilters: [{} as never], + }, limits)).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) for (const limit of [1.5, 0, 4]) { expect(() => normalizeEventRequest({ sessionId: SessionId('s'), query: 'x', limit }, limits)) .toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT')) } }) + + it('materializes owned filter values during normalization', () => { + const values = ['live'] as Array<'live' | 'persisted'> + const filter = { kind: 'availability' as const, values } + const request = { query: 'needle', sessionFilters: [filter] } + const normalized = normalizeSessionRequest(request, limits) + + values[0] = 'persisted' + request.sessionFilters.push({ kind: 'availability', values: ['persisted'] }) + expect(normalized.sessionFilters).toEqual([{ kind: 'availability', values: ['live'] }]) + }) }) describe('SQLite search predicate compilation', () => { @@ -120,6 +150,17 @@ describe('SQLite search predicate compilation', () => { { kind: 'surface', values: [] }, ])).toEqual({ sql: '0 AND 0', params: [] }) }) + + it('rejects runtime-unknown filter discriminants in both SQL builders', () => { + expect(() => buildSessionWhere([{ kind: 'future' } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => buildEventWhere([{ kind: 'future' } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => buildSessionWhere([{ kind: 'availability', values: ['future'] } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => buildSessionWhere([{} as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) }) describe('SQLite query identity and presentation', () => { @@ -169,11 +210,13 @@ describe('SQLite query identity and presentation', () => { }) it('normalizes, bounds, and positions snippets by Unicode code point', () => { - expect(makeSnippet(' short\ntext ', 'absent', 20)).toBe('short text') - expect(makeSnippet('abcdef', 'f', 1)).toBe('…') - expect(makeSnippet('abcdefghij', 'absent', 5)).toBe('abcd…') - expect(makeSnippet('abcdefghij', 'c', 5)).toBe('…bcd…') - expect(makeSnippet('abcdef', 'f', 2)).toBe('a…') - expect(makeSnippet('abcdef', 'f', 5)).toBe('…cdef') + expect(makeSnippet(' short\ntext ', 20)).toBe('short text') + expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 1)).toBe('…') + expect(makeSnippet('abcdefghij', 5)).toBe('abcd…') + expect(makeSnippet(`ab${FTS_HIGHLIGHT_START}c${FTS_HIGHLIGHT_END}defghij`, 5)).toBe('…bcd…') + expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 2)).toBe('a…') + expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 5)).toBe('…cdef') + expect(makeSnippet(` x—${FTS_HIGHLIGHT_START}café${FTS_HIGHLIGHT_END}\n y `, 20)) + .toBe('x—café y') }) }) diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 77d1e0125d..f4904e17d8 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -1,18 +1,25 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context, type Fiber } from 'cordis' import { DatabaseSync } from 'node:sqlite' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' -import SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' +import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' import SessionSearchSqlite, { SESSION_QUERY_SQLITE_APPLICATION_ID, SESSION_QUERY_SQLITE_SCHEMA_VERSION, } from '@deepseek-ai/dsh-session-query-sqlite' -import type { SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' +import { + SessionQueryError, + SessionSearchCursor, + type SessionAvailability, + type SessionQueryErrorCode, + type SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' const temporaryDirectories: string[] = [] @@ -48,19 +55,36 @@ function expectCode(code: SessionQueryErrorCode): Error { class TestPersistence extends SessionPersistence { static entries = new Map() + static revisions = new Map() + static nextRevision = 0 + static loads = new Map() + static loadEffect: ((entry: { meta: SessionHeader; events: SessionEvent[] }) => void) | undefined static listGate: Promise | undefined static listStarted: (() => void) | undefined + static snapshotEffect: (() => void | Promise) | undefined + static snapshotOverride: (() => SessionPersistenceSnapshot[]) | undefined static failure: unknown static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { - this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) + this.entries = new Map() + this.revisions = new Map() + this.loads = new Map() + this.loadEffect = undefined + for (const entry of entries) this.set(entry) this.listGate = undefined this.listStarted = undefined + this.snapshotEffect = undefined + this.snapshotOverride = undefined this.failure = undefined } + static set(entry: { meta: SessionHeader; events: SessionEvent[] }): void { + this.entries.set(entry.meta.id, structuredClone(entry)) + this.revisions.set(entry.meta.id, ++this.nextRevision) + } + create(meta: SessionHeader): Promise { - TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) + TestPersistence.set({ meta, events: [] }) return Promise.resolve() } @@ -68,13 +92,21 @@ class TestPersistence extends SessionPersistence { const entry = TestPersistence.entries.get(id) if (entry === undefined) return Promise.reject(new Error('missing test session')) entry.events.push(...structuredClone(events)) + TestPersistence.revisions.set(id, ++TestPersistence.nextRevision) return Promise.resolve() } async load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + TestPersistence.loads.set(id, (TestPersistence.loads.get(id) ?? 0) + 1) if (TestPersistence.failure !== undefined) throw TestPersistence.failure const entry = TestPersistence.entries.get(id) if (entry === undefined) throw new Error('missing test session') + if (TestPersistence.loadEffect !== undefined) { + const effect = TestPersistence.loadEffect + TestPersistence.loadEffect = undefined + effect(entry) + TestPersistence.revisions.set(id, ++TestPersistence.nextRevision) + } return structuredClone(entry) } @@ -84,6 +116,20 @@ class TestPersistence extends SessionPersistence { if (TestPersistence.failure !== undefined) throw TestPersistence.failure return [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)) } + + + async listSnapshots(): Promise { + TestPersistence.listStarted?.() + await TestPersistence.listGate + if (TestPersistence.failure !== undefined) throw TestPersistence.failure + const snapshots = TestPersistence.snapshotOverride?.() + ?? [...TestPersistence.entries.values()].map(entry => ({ + header: structuredClone(entry.meta), + revision: SessionPersistenceRevision(`test:${TestPersistence.revisions.get(entry.meta.id)}`), + })) + await TestPersistence.snapshotEffect?.() + return snapshots + } } async function liveContext(config: ConstructorParameters[1] = { path: ':memory:' }): Promise { @@ -175,6 +221,44 @@ describe('SQLite session search', () => { await expect(ctx.sessionSearch.searchSessions({ query: '*' })).resolves.toEqual({ items: [] }) }) + it('ranks live and persisted matches on one source-comparable contract', async () => { + const persisted = header('z-persisted') + TestPersistence.reset([ + { meta: persisted, events: messageEvents('needle needle', 10) }, + ...Array.from({ length: 12 }, (_, index) => ({ + meta: header(`filler-${index}`), + events: messageEvents('needle', 10), + })), + ]) + const ctx = await liveContext() + const persistence = await ctx.plugin(TestPersistence) + ctx.sessions.create(SessionId('a-live'), { + seed: messageEvents('needle needle', 10), + meta: { createdAt: persisted.createdAt }, + }) + + const result = await ctx.sessionSearch.searchSessions({ + query: 'needle', + sessionFilters: [{ kind: 'id', values: [SessionId('a-live'), persisted.id] }], + }) + expect(result.items.map(item => item.header.id)).toEqual([SessionId('a-live'), persisted.id]) + await persistence.dispose() + }) + + it('positions snippets from FTS5 matches across diacritics and punctuation', async () => { + const ctx = await liveContext({ path: ':memory:', snippetChars: 14 }) + const session = ctx.sessions.create(SessionId('snippet'), { + seed: messageEvents('long long long—café,\nnext value', 10), + }) + + const page = await ctx.sessionSearch.searchEvents({ sessionId: session.id, query: 'CAFE' }) + expect(page.items).toHaveLength(1) + expect(page.items[0]!.snippet).toContain('café') + expect(page.items[0]!.snippet).toContain('—') + expect(page.items[0]!.snippet).not.toContain('\n') + expect(Array.from(page.items[0]!.snippet).length).toBeLessThanOrEqual(14) + }) + it('binds cursors to requests and only invalidates within-session pages for target changes', async () => { const ctx = await liveContext({ path: ':memory:', defaultLimit: 1, maxLimit: 5 }) const target = ctx.sessions.create(SessionId('target'), { @@ -193,7 +277,7 @@ describe('SQLite session search', () => { if (eventPage.nextCursor === undefined || sessionPage.nextCursor === undefined) throw new Error('expected cursors') const eventKeys = eventPage.items.map(item => `${item.sessionId}:${item.seq}`) - let eventCursor: string | undefined = eventPage.nextCursor + let eventCursor: ReturnType | undefined = eventPage.nextCursor while (eventCursor !== undefined) { const next = await ctx.sessionSearch.searchEvents({ sessionId: target.id, @@ -208,7 +292,7 @@ describe('SQLite session search', () => { expect(new Set(eventKeys).size).toBe(eventKeys.length) const sessionIds = sessionPage.items.map(item => item.header.id) - let sessionCursor: string | undefined = sessionPage.nextCursor + let sessionCursor: ReturnType | undefined = sessionPage.nextCursor while (sessionCursor !== undefined) { const next = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1, cursor: sessionCursor }) sessionIds.push(...next.items.map(item => item.header.id)) @@ -251,6 +335,7 @@ describe('SQLite session search', () => { { sessionId: session.id, query: 'needle', limit: 4 }, { sessionId: session.id, query: 'needle', filters: [{ kind: 'seq', from: 2, to: 1 }] }, { sessionId: session.id, query: 'needle', filters: [{ kind: 'surface', values: ['future'] }] }, + { sessionId: session.id, query: 'bad\0query' }, ] as const) { await expect(ctx.sessionSearch.searchEvents(request as never)).rejects.toBeInstanceOf(Error) } @@ -258,7 +343,24 @@ describe('SQLite session search', () => { query: 'needle', sessionFilters: [{ kind: 'availability', values: ['remote' as never] }], })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - await expect(ctx.sessionSearch.searchEvents({ sessionId: session.id, query: 'needle', cursor: 'not-json' })) + await expect(ctx.sessionSearch.searchSessions({ + query: 'needle', + sessionFilters: [{ kind: 'future' } as never], + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchSessions({ + query: 'needle', + eventFilters: [{ kind: 'future' } as never], + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: session.id, + query: 'needle', + filters: [{ kind: 'future' } as never], + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: session.id, + query: 'needle', + cursor: SessionSearchCursor('not-json'), + })) .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR')) await expect(ctx.sessionSearch.searchEvents({ sessionId: SessionId('absent'), query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) @@ -280,6 +382,37 @@ describe('SQLite session search', () => { }) describe('SQLite reconciliation and source lifecycle', () => { + it('owns queued request and filter values before waiting for the serializer', async () => { + const durable = header('owned') + TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) + const ctx = await liveContext() + const persistence = await ctx.plugin(TestPersistence) + let release!: () => void + TestPersistence.listGate = new Promise((resolve) => { release = resolve }) + let markStarted!: () => void + const started = new Promise((resolve) => { markStarted = resolve }) + TestPersistence.listStarted = () => { + TestPersistence.listStarted = undefined + markStarted() + } + const blocking = ctx.sessionSearch.searchSessions({ query: 'needle' }) + await started + + const availability: SessionAvailability[] = ['persisted'] + const request: SessionSearchRequest = { + query: 'needle', + sessionFilters: [{ kind: 'availability', values: availability }], + } + const queued = ctx.sessionSearch.searchSessions(request) + request.query = 'absent' + availability[0] = 'live' + release() + + await expect(blocking).resolves.toMatchObject({ items: [{ header: durable }] }) + await expect(queued).resolves.toMatchObject({ items: [{ header: durable }] }) + await persistence.dispose() + }) + it('mounts persistence dynamically, shadows with TEMP live rows, reveals, and hides on unmount', async () => { const shared = header('shared', 10, { cwd: '/work' }) const durable = header('durable', 5) @@ -311,7 +444,7 @@ describe('SQLite reconciliation and source lifecycle', () => { .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) }) - it('restarts observation when persistence unmounts during an asynchronous list', async () => { + it('discards a stale list rejection when persistence unmounts during observation', async () => { const durable = header('racing') TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) const ctx = await liveContext() @@ -328,10 +461,140 @@ describe('SQLite reconciliation and source lifecycle', () => { const search = ctx.sessionSearch.searchSessions({ query: 'needle' }) await started await persistenceFiber.dispose() + TestPersistence.failure = new Error('stale backend rejection') release() await expect(search).resolves.toEqual({ items: [] }) }) + it('retries against a replacement after the prior binding rejects', async () => { + const durable = header('replacement') + TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) + const ctx = await liveContext() + const prior = await ctx.plugin(TestPersistence) + let rejectPrior!: (reason: unknown) => void + TestPersistence.listGate = new Promise((_resolve, reject) => { rejectPrior = reject }) + let markStarted!: () => void + const started = new Promise((resolve) => { markStarted = resolve }) + TestPersistence.listStarted = () => { + TestPersistence.listStarted = undefined + markStarted() + } + + const search = ctx.sessionSearch.searchSessions({ query: 'needle' }) + await started + await prior.dispose() + TestPersistence.listGate = undefined + const replacement = await ctx.plugin(TestPersistence) + rejectPrior(new Error('stale prior binding')) + await expect(search).resolves.toMatchObject({ items: [{ header: durable }] }) + await replacement.dispose() + }) + + it('reloads a replacement source even when its opaque revisions collide', async () => { + const durable = header('colliding-replacement') + TestPersistence.reset([{ meta: durable, events: messageEvents('old content') }]) + const revision = TestPersistence.revisions.get(durable.id)! + const ctx = await liveContext() + const prior = await ctx.plugin(TestPersistence) + await expect(ctx.sessionSearch.searchSessions({ query: 'old' })) + .resolves.toMatchObject({ items: [{ header: durable }] }) + await prior.dispose() + + TestPersistence.set({ meta: durable, events: messageEvents('new needle') }) + TestPersistence.revisions.set(durable.id, revision) + const replacement = await ctx.plugin(TestPersistence) + const internals = ctx.sessionSearch as unknown as { + _lastPersistenceRevision: number + _persistenceRevision: number + } + expect(internals._persistenceRevision).not.toBe(internals._lastPersistenceRevision) + const page = await ctx.sessionSearch.searchSessions({ query: 'new needle' }) + expect(TestPersistence.loads.get(durable.id)).toBe(2) + expect(page).toMatchObject({ items: [{ header: durable }] }) + await expect(ctx.sessionSearch.searchSessions({ query: 'old' })).resolves.toEqual({ items: [] }) + expect(TestPersistence.loads.get(durable.id)).toBe(2) + await replacement.dispose() + }) + + it('retries when a successful observation belongs to a source unmounted during listing', async () => { + const durable = header('successful-unmount') + TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) + const ctx = await liveContext() + const persistence = await ctx.plugin(TestPersistence) + let lists = 0 + TestPersistence.snapshotEffect = async () => { + lists += 1 + if (lists === 2) await persistence.dispose() + } + + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).resolves.toEqual({ items: [] }) + expect(lists).toBe(2) + }) + + it('retries when the snapshot population changes during observation', async () => { + const first = header('first') + const added = header('added-during-list') + TestPersistence.reset([{ meta: first, events: messageEvents('first needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + TestPersistence.snapshotEffect = () => { + TestPersistence.snapshotEffect = undefined + TestPersistence.set({ meta: added, events: messageEvents('added needle') }) + } + + const page = await ctx.sessionSearch.searchSessions({ query: 'needle' }) + expect(page.items.map(item => item.header.id).sort()).toEqual([added.id, first.id].sort()) + expect(TestPersistence.loads.get(first.id)).toBe(2) + expect(TestPersistence.loads.get(added.id)).toBe(1) + }) + + it('retries if the source revision changes while live sessions are observed', async () => { + const durable = header('live-boundary-retry') + TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const internals = ctx.sessionSearch as unknown as { _persistenceRevision: number } + const originalList = ctx.sessions.list.bind(ctx.sessions) + let bumped = false + const list = vi.spyOn(ctx.sessions, 'list').mockImplementation(() => { + if (!bumped) { + bumped = true + internals._persistenceRevision += 1 + } + return originalList() + }) + + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .resolves.toMatchObject({ items: [{ header: durable }] }) + expect(TestPersistence.loads.get(durable.id)).toBe(2) + list.mockRestore() + }) + + it('rejects malformed snapshots and preserves typed persistence failures', async () => { + const durable = header('invalid-snapshot') + TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + + TestPersistence.snapshotOverride = () => 'not-an-array' as never + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TestPersistence.snapshotOverride = () => [{ header: durable, revision: 1 as never }] + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TestPersistence.snapshotOverride = () => [ + { header: durable, revision: SessionPersistenceRevision('duplicate:1') }, + { header: durable, revision: SessionPersistenceRevision('duplicate:2') }, + ] + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + + TestPersistence.snapshotOverride = undefined + const typed = new SessionQueryError('typed persistence failure', 'SESSION_QUERY_PERSISTENCE_FAILED') + TestPersistence.failure = typed + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).rejects.toBe(typed) + }) + it('rejects immutable header conflicts between live and persisted sources', async () => { const shared = header('conflict', 10) TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }]) @@ -358,6 +621,9 @@ describe('SQLite reconciliation and source lifecycle', () => { const firstPersistence = await first.plugin(TestPersistence) const firstSearch = await first.plugin(SessionSearchSqlite, { path }) await first.sessionSearch.searchSessions({ query: 'needle' }) + expect(Object.fromEntries(TestPersistence.loads)).toEqual({ unchanged: 1, changed: 1, deleted: 1 }) + await first.sessionSearch.searchSessions({ query: 'needle' }) + expect(Object.fromEntries(TestPersistence.loads)).toEqual({ unchanged: 1, changed: 1, deleted: 1 }) await firstSearch.dispose() await firstPersistence.dispose() @@ -368,14 +634,20 @@ describe('SQLite reconciliation and source lifecycle', () => { const added = header('added') TestPersistence.entries.delete(deleted.id) - TestPersistence.entries.set(changed.id, { meta: changed, events: messageEvents('changed needle') }) - TestPersistence.entries.set(added.id, { meta: added, events: messageEvents('added needle') }) + TestPersistence.set({ meta: changed, events: messageEvents('changed needle') }) + TestPersistence.set({ meta: added, events: messageEvents('added needle') }) const second = new Context() await second.plugin(SessionStore) const secondPersistence = await second.plugin(TestPersistence) const secondSearch = await second.plugin(SessionSearchSqlite, { path }) const result = await second.sessionSearch.searchSessions({ query: 'needle' }) expect(result.items.map(item => item.header.id).sort()).toEqual([added.id, changed.id, unchanged.id].sort()) + expect(Object.fromEntries(TestPersistence.loads)).toEqual({ + unchanged: 1, + changed: 2, + deleted: 1, + added: 1, + }) await secondSearch.dispose() await secondPersistence.dispose() @@ -409,10 +681,27 @@ describe('SQLite reconciliation and source lifecycle', () => { await expect(second.sessionSearch.searchSessions({ query: 'live' })).resolves.toEqual({ items: [] }) await expect(second.sessionSearch.searchSessions({ query: 'persisted' })) .resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] }) + expect(TestPersistence.loads.get(shared.id)).toBe(1) await searchAgain.dispose() await persistenceAgain.dispose() }) + it('refreshes the stored revision after a mutating load repair', async () => { + const durable = header('repair') + TestPersistence.reset([{ meta: durable, events: messageEvents('before repair') }]) + TestPersistence.loadEffect = (entry) => { + entry.events = messageEvents('repaired needle') + } + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + + await expect(ctx.sessionSearch.searchSessions({ query: 'repaired' })) + .resolves.toMatchObject({ items: [{ header: durable }] }) + expect(TestPersistence.loads.get(durable.id)).toBe(2) + await ctx.sessionSearch.searchSessions({ query: 'repaired' }) + expect(TestPersistence.loads.get(durable.id)).toBe(2) + }) + it('recovers on the next search after source and SQLite transaction failures', async () => { TestPersistence.reset([{ meta: header('durable'), events: messageEvents('durable needle') }]) const ctx = await liveContext() @@ -462,15 +751,18 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const foreignPath = await temporaryPath('foreign.db') const foreign = new DatabaseSync(foreignPath) + foreign.exec('PRAGMA journal_mode = WAL') foreign.exec('CREATE TABLE canonical(value TEXT)') foreign.exec("INSERT INTO canonical VALUES ('safe')") foreign.close() - const foreignCtx = await liveContext({ path: foreignPath }) + const foreignCtx = await liveContext({ path: foreignPath, journalMode: 'delete' }) await expect(foreignCtx.sessionSearch.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) const stillForeign = new DatabaseSync(foreignPath) expect(stillForeign.prepare('SELECT value FROM canonical').get()).toEqual({ value: 'safe' }) + expect(stillForeign.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'wal' }) stillForeign.close() + await (foreignCtx.sessionSearch as SessionSearchSqlite).close() const otherAppPath = await temporaryPath('other-app.db') const otherApp = new DatabaseSync(otherAppPath) @@ -479,6 +771,25 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const otherAppCtx = await liveContext({ path: otherAppPath }) await expect(otherAppCtx.sessionSearch.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + await (otherAppCtx.sessionSearch as SessionSearchSqlite).close() + }) + + it('observes asynchronous open rejection even when no query is made', async () => { + const path = await temporaryPath('never-queried.db') + const foreign = new DatabaseSync(path) + foreign.exec('CREATE TABLE canonical(value TEXT)') + foreign.close() + const unhandled: unknown[] = [] + const onUnhandled = (reason: unknown) => { unhandled.push(reason) } + process.on('unhandledRejection', onUnhandled) + try { + const ctx = await liveContext({ path }) + await new Promise((resolve) => { setImmediate(resolve) }) + expect(unhandled).toEqual([]) + await (ctx.sessionSearch as SessionSearchSqlite).close() + } finally { + process.off('unhandledRejection', onUnhandled) + } }) it('cancels both queued and in-flight source waits without committing them', async () => { @@ -518,7 +829,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => releaseBlocking() await expect(blocking).resolves.toEqual({ items: [] }) - TestPersistence.entries.set(SessionId('uncommitted'), { + TestPersistence.set({ meta: header('uncommitted'), events: messageEvents('durable needle'), }) @@ -560,14 +871,38 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await started const queued = search.searchSessions({ query: 'needle' }) const closing = search.close() + const repeatedClose = search.close() + expect(repeatedClose).toBe(closing) release() await expect(accepted).resolves.toEqual({ items: [] }) await expect(queued).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) - await closing + await Promise.all([closing, repeatedClose]) await expect(search.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) - await search.close() + expect(search.close()).toBe(closing) + }) + + it('awaits optional-persistence child-fiber quiescence on disposal', async () => { + TestPersistence.reset() + const ctx = new Context() + await ctx.plugin(SessionStore) + const search = await ctx.plugin(SessionSearchSqlite, { path: ':memory:' }) + const persistence = await ctx.plugin(TestPersistence) + const optional = (ctx.sessionSearch as unknown as { + _optionalPersistenceFiber: Fiber + })._optionalPersistenceFiber + let release!: () => void + const cleanup = new Promise((resolve) => { release = resolve }) + optional.ctx.effect(() => () => cleanup) + + let settled = false + const disposing = search.dispose().then(() => { settled = true }) + await Promise.resolve() + expect(settled).toBe(false) + release() + await disposing + await persistence.dispose() }) it('combines the real SQLite persistence backend with the real search service keylessly', async () => { diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 6f9e9bbd7a..917984f0cd 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -7,6 +7,7 @@ This is trusted context-wide infrastructure. It performs no caller authorization ## Reads - `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. +- `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. - `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. - `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. @@ -21,7 +22,7 @@ The text clause is deliberately independent of FTS providers: caller text is esc ## Full-text seam -`SessionSearchService` owns the independent `ctx.sessionSearch` key. `searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. Both return opaque cursor pages, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above. +`SessionSearchService` owns the independent `ctx.sessionSearch` key. `searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above. The package has no provider coordinator or registration protocol. A concrete backend owns observation, reconciliation, ranking, cursor generations, and query execution as one lifecycle; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md). diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 9f78d4f1db..449610a5a4 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -22,6 +22,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", @@ -36,6 +37,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index c8ddca3caa..4a27c34e58 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -1,6 +1,6 @@ /** Live/persisted logical-corpus resolution for session-query. */ -import type { Context } from 'cordis' +import type { Context, Fiber } from 'cordis' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' import type { SessionRecord } from './types.ts' @@ -18,18 +18,19 @@ export interface LogicalSession { /** Resolves a live-preferred corpus against the persistence service mounted now. */ export class SessionCorpus { private _persistence: SessionPersistence | undefined + private readonly _optionalPersistenceFiber: Fiber constructor(private readonly _ctx: Context) { + this._optionalPersistenceFiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => { + const service = childCtx.sessionPersistence + this._persistence = service + childCtx.effect(() => () => { + /* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */ + if (this._persistence === service) this._persistence = undefined + }, 'sessionQuery.persistenceBinding') + }) _ctx.effect(() => { - const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => { - const service = childCtx.sessionPersistence - this._persistence = service - childCtx.effect(() => () => { - /* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */ - if (this._persistence === service) this._persistence = undefined - }, 'sessionQuery.persistenceBinding') - }) - return () => void fiber.dispose() + return () => this._optionalPersistenceFiber.dispose() }, 'sessionQuery.optionalPersistence') } diff --git a/packages/session-query/session-query/src/cursor.ts b/packages/session-query/session-query/src/cursor.ts new file mode 100644 index 0000000000..8ee2a6660d --- /dev/null +++ b/packages/session-query/session-query/src/cursor.ts @@ -0,0 +1,15 @@ +/** Opaque cursor identity for session-search pagination. */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Provider-owned opaque continuation token returned by session search. */ +export type SessionSearchCursor = Branded<'SessionSearchCursor'> + +/** + * Brand an encoded provider cursor for the public search contract. + * @param value - opaque encoded cursor value. + * @returns the same runtime string with session-search cursor identity. + */ +export function SessionSearchCursor(value: string): SessionSearchCursor { + return value as SessionSearchCursor +} diff --git a/packages/session-query/session-query/src/filters.ts b/packages/session-query/session-query/src/filters.ts index c7a7b40dd4..91ae640615 100644 --- a/packages/session-query/session-query/src/filters.ts +++ b/packages/session-query/session-query/src/filters.ts @@ -1,6 +1,12 @@ /** Pure provider-independent predicates for logical sessions and event text. */ -import type { SessionRecord, SessionEventSearchDocument, SessionEventResultFilter, SessionResultFilter, SessionResultRange } from './types.ts' +import type { + SessionEventResultFilter, + SessionEventSearchDocument, + SessionRecord, + SessionResultFilter, + SessionResultRange, +} from './types.ts' import { SessionQueryError } from './config.ts' /** @@ -31,6 +37,66 @@ export function filterSessionEventDocuments predicates.every(predicate => predicate(document))) } +/** + * Copy and validate logical-session filters before an asynchronous boundary. + * @param filters - caller-owned clauses to materialize. + * @returns detached validated clauses. + */ +export function materializeSessionResultFilters( + filters: readonly SessionResultFilter[], +): SessionResultFilter[] { + assertArray(filters) + return filters.map((filter) => { + switch (filter.kind) { + case 'id': + return { kind: filter.kind, values: copyStrings(filter.kind, filter.values) } + case 'cwd': + return { kind: filter.kind, values: copyNullableStrings(filter.kind, filter.values) } + case 'created-at': + return copyRange(filter.kind, filter) + case 'parent': + return { kind: filter.kind, values: copyNullableStrings(filter.kind, filter.values) } + case 'availability': { + const values = copyStrings(filter.kind, filter.values) + assertAllowedValues(filter.kind, values, ['live', 'persisted']) + return { kind: filter.kind, values } + } + default: + return unknownFilter(filter) + } + }) +} + +/** + * Copy and validate event filters before an asynchronous boundary. + * @param filters - caller-owned clauses to materialize. + * @returns detached validated clauses. + */ +export function materializeSessionEventResultFilters( + filters: readonly SessionEventResultFilter[], +): SessionEventResultFilter[] { + assertArray(filters) + return filters.map((filter) => { + switch (filter.kind) { + case 'seq': + case 'time': + return copyRange(filter.kind, filter) + case 'type': + return { kind: filter.kind, values: copyStrings(filter.kind, filter.values) } + case 'surface': { + const values = copyStrings(filter.kind, filter.values) + assertAllowedValues(filter.kind, values, ['current', 'shadowed', 'log-only']) + return { kind: filter.kind, values } + } + case 'text': + if (typeof filter.text !== 'string') throw invalidFilter('text filter text must be a string') + return { kind: filter.kind, text: filter.text } + default: + return unknownFilter(filter) + } + }) +} + /** * Compile a literal case-insensitive, whitespace-flexible semantic-text match. * @param text - caller-provided literal text. @@ -66,6 +132,8 @@ function sessionPredicate(filter: SessionResultFilter): (record: SessionRecord) case 'availability': assertAllowedValues(filter.kind, filter.values, ['live', 'persisted']) return record => filter.values.some(value => value === 'live' ? record.live : record.persisted) + default: + return unknownFilter(filter) } } @@ -88,9 +156,47 @@ function eventPredicate(filter: SessionEventResultFilter): (document: SessionEve const pattern = compileSessionTextFilter(filter.text) return document => pattern.test(document.text) } + default: + return unknownFilter(filter) } } +function copyStrings(name: string, values: readonly T[]): T[] { + if (!isRuntimeArray(values) || values.some(value => typeof value !== 'string')) { + throw invalidFilter(`${name} filter values must be an array of strings`) + } + return [...values] +} + +function assertArray(value: unknown): void { + if (!Array.isArray(value)) throw invalidFilter('filters must be an array') +} + +function copyNullableStrings(name: string, values: readonly (T | null)[]): Array { + if (!isRuntimeArray(values) || values.some(value => value !== null && typeof value !== 'string')) { + throw invalidFilter(`${name} filter values must be an array of strings or null`) + } + return [...values] +} + +function copyRange( + kind: K, + range: SessionResultRange, +): { kind: K } & SessionResultRange { + const copy = { + kind, + ...range.from === undefined ? {} : { from: range.from }, + ...range.to === undefined ? {} : { to: range.to }, + } + validateRange(kind, copy) + return copy +} + +function unknownFilter(filter: never): never { + const kind = (filter as { kind?: unknown }).kind + throw invalidFilter(`unknown filter kind ${typeof kind === 'string' ? `"${kind}"` : '(missing)'}`) +} + function assertAllowedValues( name: string, values: readonly string[], @@ -125,8 +231,13 @@ function matchesRange(value: number, range: SessionResultRange): boolean { } function invalidRange(name: string, detail: string): SessionQueryError { - return new SessionQueryError( - `session ${name} filter ${detail}`, - 'SESSION_QUERY_INVALID_FILTER', - ) + return invalidFilter(`${name} filter ${detail}`) +} + +function invalidFilter(detail: string): SessionQueryError { + return new SessionQueryError(`session ${detail}`, 'SESSION_QUERY_INVALID_FILTER') +} + +function isRuntimeArray(value: unknown): boolean { + return Array.isArray(value) } diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 243c86746b..2c2155eebc 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -16,6 +16,7 @@ import type { SessionEventSearchRequest, SessionEventWindow, SessionRecord, + SessionResultFilter, SessionSearchExecContext, SessionSearchHit, SessionSearchPage, @@ -28,14 +29,26 @@ import { } from './config.ts' import { SessionCorpus } from './corpus.ts' import { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts' -import { filterSessionEventDocuments } from './filters.ts' +import { + filterSessionEventDocuments, + filterSessionResults, + materializeSessionEventResultFilters, + materializeSessionResultFilters, +} from './filters.ts' export type * from './types.ts' +export { SessionSearchCursor } from './cursor.ts' export type { Config, SessionQueryErrorCode } from './config.ts' export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts' export { extractSessionEventText } from './extraction.ts' export { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts' -export { compileSessionTextFilter, filterSessionEventDocuments, filterSessionResults } from './filters.ts' +export { + compileSessionTextFilter, + filterSessionEventDocuments, + filterSessionResults, + materializeSessionEventResultFilters, + materializeSessionResultFilters, +} from './filters.ts' export { assertSessionHeadersCompatible } from './sources.ts' declare module 'cordis' { @@ -109,6 +122,16 @@ export class SessionQueryService extends Service { return this._corpus.listSessions() } + /** + * Filter the complete logical corpus with provider-independent predicates. + * @param filters - ANDed session metadata and availability clauses. + * @returns matching cloned records in deterministic newest-first order. + */ + async filterSessions(filters: readonly SessionResultFilter[]): Promise { + const ownedFilters = materializeSessionResultFilters(filters) + return this._filterSessions(ownedFilters) + } + /** * List lightweight raw-log event records for one logical session. * @param sessionId - live-preferred session id to read. @@ -128,6 +151,18 @@ export class SessionQueryService extends Service { async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFilter[], + ): Promise { + const ownedFilters = materializeSessionEventResultFilters(filters) + return this._filterEvents(sessionId, ownedFilters) + } + + private async _filterSessions(filters: readonly SessionResultFilter[]): Promise { + return filterSessionResults(await this._corpus.listSessions(), filters) + } + + private async _filterEvents( + sessionId: SessionId, + filters: readonly SessionEventResultFilter[], ): Promise { const loaded = await this._corpus.load(sessionId) const documents = buildSessionEventSearchDocuments(sessionId, loaded.events) @@ -142,16 +177,27 @@ export class SessionQueryService extends Service { async readEvent(request: SessionEventReadRequest): Promise { const before = this._readWindow('before', request.before) const after = this._readWindow('after', request.after) - const loaded = await this._corpus.load(request.sessionId) - const target = loaded.events[request.seq] - if (target === undefined || target.seq !== request.seq) { + const sessionId = request.sessionId + const seq = request.seq + return this._readEvent(sessionId, seq, before, after) + } + + private async _readEvent( + sessionId: SessionId, + seq: number, + before: number, + after: number, + ): Promise { + const loaded = await this._corpus.load(sessionId) + const target = loaded.events[seq] + if (target === undefined || target.seq !== seq) { throw new SessionQueryError( - `session "${request.sessionId}" has no event at seq ${request.seq}`, + `session "${sessionId}" has no event at seq ${seq}`, 'SESSION_QUERY_EVENT_NOT_FOUND', ) } - const startSeq = Math.max(0, request.seq - before) - const endSeq = Math.min(loaded.events.length - 1, request.seq + after) + const startSeq = Math.max(0, seq - before) + const endSeq = Math.min(loaded.events.length - 1, seq + after) return { session: loaded.header, target, diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index d0de0dd48a..a223084f56 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -5,6 +5,9 @@ */ import type { SessionEvent, SessionEventType, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionSearchCursor } from './cursor.ts' + +export type { SessionSearchCursor } from './cursor.ts' /** Whether an event is current model context, replaced context, or raw-log-only. */ export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' @@ -106,7 +109,7 @@ export interface SessionSearchPage { /** Results for this page in contract-defined order. */ items: readonly T[] /** Opaque continuation cursor, absent on the final page. */ - nextCursor?: string + nextCursor?: SessionSearchCursor } /** Controls shared by cross-session and within-session search calls. */ @@ -126,7 +129,7 @@ export interface SessionSearchRequest { /** Maximum sessions in this page. */ limit?: number /** Opaque cursor returned for the identical normalized request. */ - cursor?: string + cursor?: SessionSearchCursor } /** Within-session full-text search request. */ @@ -140,7 +143,7 @@ export interface SessionEventSearchRequest { /** Maximum events in this page. */ limit?: number /** Opaque cursor returned for the identical normalized request. */ - cursor?: string + cursor?: SessionSearchCursor } /** One event full-text search hit with a bounded plain-text excerpt. */ diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index e9cf857608..8ee9cf1ccf 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -10,6 +10,8 @@ import SessionQueryService, { extractSessionEventText, filterSessionEventDocuments, filterSessionResults, + materializeSessionEventResultFilters, + materializeSessionResultFilters, SessionSearchService, type SessionEventSearchHit, type SessionEventSearchRequest, @@ -177,6 +179,31 @@ describe('session-query document and filter helpers', () => { expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) }) + it('owns filters and rejects malformed runtime filter shapes deterministically', () => { + expect(materializeSessionResultFilters([{ kind: 'created-at', to: 2 }])) + .toEqual([{ kind: 'created-at', to: 2 }]) + expect(() => materializeSessionResultFilters('not-an-array' as never)) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => materializeSessionResultFilters([{ kind: 'id', values: 'bad' } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => materializeSessionResultFilters([{ kind: 'id', values: [1] } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => materializeSessionResultFilters([{ kind: 'cwd', values: 'bad' } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => materializeSessionResultFilters([{ kind: 'parent', values: [1] } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => materializeSessionResultFilters([{} as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => materializeSessionEventResultFilters([{ kind: 'text', text: 1 } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => materializeSessionEventResultFilters([{ kind: 'future' } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => filterSessionResults([], [{ kind: 'future' } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => filterSessionEventDocuments([], [{ kind: 'future' } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) + it('exposes the scan path on the concrete exact-read service', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 3b50feee45..e1432722f2 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context, type Fiber } from 'cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' -import SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' import SessionQueryService, { + type SessionEventSurface, type SessionQueryErrorCode, } from '@deepseek-ai/dsh-session-query' @@ -59,6 +60,14 @@ class TestPersistence extends SessionPersistence { TestPersistence.afterList?.() return Promise.resolve(headers) } + + + async listSnapshots() { + return [...TestPersistence.entries.values()].map(entry => ({ + header: structuredClone(entry.meta), + revision: SessionPersistenceRevision(`events:${entry.events.length}`), + })) + } } async function liveContext(config: ConstructorParameters[1] = {}): Promise { @@ -94,6 +103,38 @@ describe('session-query exact reads', () => { expect(older.header.createdAt).toBe(1) }) + it('filters sessions symmetrically and owns mutable filter values immediately', async () => { + const durable = header('durable-filter', 1) + TestPersistence.reset([{ meta: durable, events: eventLog('durable') }]) + const ctx = await liveContext() + const live = ctx.sessions.create(SessionId('live-filter'), { meta: { createdAt: 2 } }) + live.append( + 'user/message', + { content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + const persistence = await ctx.plugin(TestPersistence) + + const ids = [durable.id] + const filtered = ctx.sessionQuery.filterSessions([{ kind: 'id', values: ids }]) + ids[0] = live.id + await expect(filtered).resolves.toEqual([{ + header: durable, + live: false, + persisted: true, + }]) + + const surfaces: SessionEventSurface[] = ['current'] + const events = ctx.sessionQuery.filterEvents(live.id, [{ kind: 'surface', values: surfaces }]) + surfaces[0] = 'shadowed' + await expect(events).resolves.toMatchObject([{ sessionId: live.id, surface: 'current', text: 'live' }]) + await expect(ctx.sessionQuery.filterSessions([{ kind: 'future' } as never])) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionQuery.filterEvents(live.id, [{ kind: 'future' } as never])) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await persistence.dispose() + }) + it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('surface')) @@ -267,4 +308,26 @@ describe('session-query exact reads', () => { await fiber.dispose() expect(ctx.sessionQuery).toBeUndefined() }) + + it('awaits optional-persistence child-fiber quiescence on disposal', async () => { + TestPersistence.reset() + const ctx = new Context() + await ctx.plugin(SessionStore) + const query = await ctx.plugin(SessionQueryService) + const persistence = await ctx.plugin(TestPersistence) + const optional = (ctx.sessionQuery as unknown as { + _corpus: { _optionalPersistenceFiber: Fiber } + })._corpus._optionalPersistenceFiber + let release!: () => void + const cleanup = new Promise((resolve) => { release = resolve }) + optional.ctx.effect(() => () => cleanup) + + let settled = false + const disposing = query.dispose().then(() => { settled = true }) + await Promise.resolve() + expect(settled).toBe(false) + release() + await disposing + await persistence.dispose() + }) }) diff --git a/packages/session-query/session-query/tsconfig.json b/packages/session-query/session-query/tsconfig.json index 7153dae8bb..1a254e5379 100644 --- a/packages/session-query/session-query/tsconfig.json +++ b/packages/session-query/session-query/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../util/brand" + }, { "path": "../../llm/llm" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 997f00ff5c..902c362355 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -720,6 +720,9 @@ importers: packages/session-persistence/session-persistence: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -765,6 +768,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 0d0a0b871d..ba2d75a8ff 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -45,6 +45,8 @@ { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionPersistenceRevision", "source": "packages/session-persistence/session-persistence/src/revision.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionPersistenceSnapshot", "source": "packages/session-persistence/session-persistence/src/index.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, @@ -52,6 +54,7 @@ { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchDocument", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchCursor", "source": "packages/session-query/session-query/src/cursor.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPage", "source": "packages/session-query/session-query/src/types.ts" }, From 35edf2a825b40e4bac80d3e38d7c5334dbe1dd85 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 12:32:18 +0800 Subject: [PATCH 003/207] fix(session-query): qualify persistence revisions by store --- docs/config-catalog.md | 2 +- ...026-07-10-sqlite-session-query-provider.md | 2 +- .../session-persistence-jsonl/README.md | 2 +- .../tests/jsonl.spec.ts | 23 ++++++++ .../session-persistence-sqlite/README.md | 4 +- .../session-persistence-sqlite/src/index.ts | 35 ++++++++--- .../session-persistence-sqlite/src/schema.ts | 38 +++++++++--- .../tests/sqlite.spec.ts | 59 +++++++++++++++++-- .../session-persistence/README.md | 2 +- .../session-persistence/src/index.ts | 4 +- .../session-persistence/src/revision.ts | 5 +- .../session-query-sqlite/README.md | 2 +- .../session-query-sqlite/tests/sqlite.spec.ts | 45 ++++++++++++++ 13 files changed, 192 insertions(+), 31 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e8a06e469e..aa37295146 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -574,7 +574,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:50`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:51`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` diff --git a/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md index fafa7f3569..b77279949b 100644 --- a/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md +++ b/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md @@ -32,7 +32,7 @@ Both persistent and live FTS5 tables use `unicode61`. The implementation experim The shared extractor includes message text, reasoning, nested tool-call/result content, tool names and arguments, blocked-prompt reasons, todo status/content, and error or terminal status detail. Structural boundaries, stream chunks, request headers, successful completion markers, and unknown declaration-merged event/content variants produce no document. Surface classification reuses `foldSurface()` so search agrees with model-history derivation. -One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. +One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each source-qualified opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. A revision identifies its backing persistence store as well as the backend-local log revision, so reopening against the same store reuses indexed rows while switching to an independent store cannot collide on a session id and local counter. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. Persisted documents survive restarts. Live sessions use connection-local TEMP tables, shadow the persisted base for the same id, and reveal that base on detach. Closing the database drops live rows. Unmounting persistence hides durable rows without treating absence as authoritative deletion; remounting observes and reconciles the backend again. Conflicting immutable live and durable headers fail rather than combining sources. diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 0be2c785b3..2043967cb8 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -25,7 +25,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. - **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). - **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. -- **Lightweight revisions.** `listSnapshots()` reads each header and returns an opaque identity from the file device/inode, size, and nanosecond mtime/ctime. Under the append-only single-writer contract, unchanged files retain revisions while append, repair, or replacement changes them without parsing event lines. +- **Lightweight revisions.** `listSnapshots()` reads each header and returns an opaque identity from the file device/inode, size, and nanosecond mtime/ctime. Under the append-only single-writer contract, unchanged files retain revisions while append, repair, replacement, or switching to an independent root changes them without parsing event lines. - **Format version.** Only the current `SESSION_FORMAT_VERSION` (v0) is supported; `load` rejects any other version. While the harness is unreleased the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 (no bump until the first tagged release) and non-current logs are rejected — there is no migration (no persisted user data to preserve). ## Write path diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 757ba03aac..6f5aa81ee0 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -147,6 +147,29 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect(loaded.events).toEqual(log) // chunks preserved, contiguous seqs }) + it('source-qualifies revisions across roots while preserving same-log reopen identity', async () => { + const m = meta('revision-source') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const revision = (await ctx.sessionPersistence.listSnapshots())[0]?.revision + + const reopenedCtx = new Context() + await reopenedCtx.plugin(SessionStore) + await reopenedCtx.plugin(SessionPersistenceJsonl, { root }) + expect((await reopenedCtx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revision) + + const otherRoot = await freshRoot() + const otherCtx = new Context() + await otherCtx.plugin(SessionStore) + await otherCtx.plugin(SessionPersistenceJsonl, { root: otherRoot }) + await otherCtx.sessionPersistence.create(m) + await otherCtx.sessionPersistence.append(m.id, oneTurnLog()) + expect((await otherCtx.sessionPersistence.listSnapshots())[0]?.revision).not.toBe(revision) + + await reopenedCtx.fiber.dispose() + await otherCtx.fiber.dispose() + }) + it('persists a forked child seed through the existing session write path', async () => { const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } }) appendClosedTurn(source) diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 6e6006dc6b..e45f1d2371 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -6,7 +6,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) and a monotonic per-log revision live in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). +Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). The repo's `engines.node` is `^22.19.0 || >=24.0.0` (Node 22.19+ or 24+), matching the LTS floor required by the installed Pi adapter dependency; `node:sqlite` itself ships without the `--experimental-sqlite` flag from Node 22.13 (LTS) and 23.4 / 24 (Current) on. The range deliberately excludes Node 23 because that line is non-LTS/EOL and still has flagged runtime features before 23.6. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and the configured `journal_mode` (default `wal`; pick a rollback-journal mode like `delete` on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software). @@ -14,7 +14,7 @@ The repo's `engines.node` is `^22.19.0 || >=24.0.0` (Node 22.19+ or 24+), matchi - **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.) - **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row). -- **Lightweight revisions.** `listSnapshots()` reads the monotonic revision stored beside each session header. Append and mutating load repair increment it in the same transaction as their event changes, so unchanged observations are stable and no full-log count or parse is required. +- **Lightweight revisions.** `listSnapshots()` combines the database's immutable random store id and physical file identity with the monotonic revision stored beside each session header; an in-memory database uses the store id alone. Append and mutating load repair increment the local counter in the same transaction as their event changes, so unchanged same-file observations are stable, independent stores and file replacements cannot collide on a local counter, and no full-log count or parse is required. - **Interrupted-turn close on load.** `load()` reads every stored event ordered by `seq` and finds the longest seq-contiguous, parseable prefix — INCLUDING the real events of an interrupted final turn after the last `turn/end` (the loop only flushes at `turn/end`, so a process killed mid-turn leaves real, fully-written rows past it). A single turn can be huge in a long-horizon task, so those events are **preserved, never truncated**: `load()` CLOSES the orphaned turn by durably appending the minimal synthetic boundary events (an error `tool/result` for every assistant tool call left unanswered, a `step/end` if a step was open, then a `turn/end` carrying `{ kind: 'interrupted' }`), inside one transaction that also DELETEs any never-fully-written torn tail row. `load()` is therefore mutating — after it the stored rows are balanced and the cursor is truthful, so the next `append` continues cleanly. The boundary (last `turn/end`, torn-tail detection) is computed from the `seq`/`type` columns so a malformed `data` in a torn tail row is never parsed (discarded, not unloadable). A parse error or `seq` gap inside the committed region (at or before the last real `turn/end`) makes the session unloadable. A session whose only turn never closed keeps its metadata row and stays present in `list()` — the same as the JSONL backend, whose file likewise survives a first append that never reached `turn/end`. ## Configuration (schemastery) diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 3a1c7ffbcc..546f285824 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -19,6 +19,7 @@ import { Context } from 'cordis' import z from 'schemastery' +import { statSync } from 'node:fs' import { DatabaseSync } from 'node:sqlite' import { mkdir } from 'node:fs/promises' import { dirname, resolve } from 'node:path' @@ -84,6 +85,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers override readonly name = 'session-persistence-sqlite' private db!: DatabaseSync + private storeIdentity!: string private ready: Promise private coordinator: PersistenceCoordinator @@ -98,12 +100,29 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers } private async openDb(path: string, journalMode: JournalMode): Promise { - if (path !== ':memory:') { - const abs = resolve(path) - await mkdir(dirname(abs), { recursive: true, mode: 0o700 }) - this.db = openDatabase(abs, journalMode) - } else { - this.db = openDatabase(path, journalMode) + const actual = path === ':memory:' ? path : resolve(path) + if (actual !== ':memory:') await mkdir(dirname(actual), { recursive: true, mode: 0o700 }) + this.db = openDatabase(actual, journalMode) + try { + const row = this.db.prepare( + 'SELECT store_id FROM persistence_state WHERE singleton = 1', + ).get() as { store_id: string } | undefined + /* v8 ignore next -- openDatabase inserts the singleton before returning. */ + if (row === undefined) { + throw new Error(`session database at "${actual}" has no store identity`) + } + if (row.store_id.length === 0) { + throw new Error(`session database at "${actual}" has no valid store identity`) + } + if (actual !== ':memory:') { + const identity = statSync(actual, { bigint: true }) + this.storeIdentity = `file:${identity.dev}:${identity.ino}:${identity.birthtimeNs}:store:${row.store_id}` + } else { + this.storeIdentity = `memory:store:${row.store_id}` + } + } catch (error: unknown) { + this.db.close() + throw error } } @@ -234,13 +253,13 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return rows.map(rowToMeta) } - /** List metadata with an append-only event-count revision per session. */ + /** List metadata with a source-qualified monotonic revision per session. */ async listSnapshots(): Promise { await this.ready const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[] return rows.map(row => ({ header: rowToMeta(row), - revision: SessionPersistenceRevision(`revision:${row.revision}`), + revision: SessionPersistenceRevision(`${this.storeIdentity}:revision:${row.revision}`), })) } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 2f238b0131..caf1766f22 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -1,12 +1,14 @@ /** * Schema + load-time helpers for the SQLite session-persistence backend: the - * DDL (a `sessions` metadata table and a 1:1 `events` row per `SessionEvent`), - * the database open/configure step, and the last-`turn/end` cut that gives the - * SQLite backend the SAME crash-tail-on-load semantics as the JSONL backend. + * DDL (a store-identity row, `sessions` metadata, and a 1:1 `events` row per + * `SessionEvent`), the database open/configure step, and the last-`turn/end` + * cut that gives the SQLite backend the SAME crash-tail-on-load semantics as + * the JSONL backend. * * @module dsh-session-persistence-sqlite/schema */ +import { randomUUID } from 'node:crypto' import { DatabaseSync } from 'node:sqlite' import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepseek-ai/dsh-session' @@ -15,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 5 +export const SCHEMA_VERSION = 6 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). @@ -70,14 +72,25 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' * current one (written by a different, incompatible build — older or newer) is * REJECTED rather than opened against a layout this build does not understand. * There are no migrations: an incompatible layout is rejected. The current - * sessions row carries every header field plus its monotonic snapshot revision; - * the events row carries the complete surface metadata. + * persistence-state row carries an immutable random store id, the sessions row + * carries every header field plus its monotonic snapshot revision, and the + * events row carries the complete surface metadata. * @param path - the SQLite database file to open (created when absent). * @param journalMode - the journal pragma to apply — a closed in-code union, validated by the plugin Config. - * @returns the open handle with pragmas applied and both tables ensured. + * @returns the open handle with pragmas applied and all three tables ensured. */ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync { const db = new DatabaseSync(path) + try { + configureDatabase(db, path, journalMode) + return db + } catch (error: unknown) { + db.close() + throw error + } +} + +function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void { db.exec('PRAGMA foreign_keys = ON') // journalMode is a closed in-code union (validated by the plugin Config), not // user-controlled SQL — safe to interpolate (PRAGMA takes no bound params). @@ -85,7 +98,6 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy // `PRAGMA user_version` always returns exactly one row { user_version }. const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number } if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) { - db.close() throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`) } if (onDisk === 0) { @@ -94,6 +106,15 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy // constant (SCHEMA_VERSION is a trusted in-code number, not user input). db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) } + db.exec(` + CREATE TABLE IF NOT EXISTS persistence_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + store_id TEXT NOT NULL + ) STRICT + `) + db.prepare( + 'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)', + ).run(randomUUID()) db.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, @@ -117,7 +138,6 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy PRIMARY KEY (session_id, seq) ) STRICT `) - return db } /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 98924ca691..5b85f65908 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { existsSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -245,12 +245,12 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { dbNewer.close() expect(() => openDatabase(path, 'wal')).toThrow(/incompatible with this build/) - // A stale OLDER version (e.g. a pre-summary-drop v1 DB) is also rejected — - // we do not migrate (unreleased software, no backward-compat). + // The immediately preceding layout lacks the required store identity and is + // rejected rather than migrated (unreleased software, no backward-compat). const olderPath = await freshDbPath() openDatabase(olderPath, 'wal').close() const dbOlder = openDatabase(olderPath, 'wal') - dbOlder.exec('PRAGMA user_version = 1') + dbOlder.exec(`PRAGMA user_version = ${SCHEMA_VERSION - 1}`) dbOlder.close() expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/) }) @@ -337,8 +337,46 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await fiber2.dispose() }) + it('source-qualifies revisions across stores while preserving same-file reopen identity', async () => { + const pathA = await freshDbPath() + const pathB = await freshDbPath() + const m = meta('revision-source') + const a = await backend(pathA) + await a.ctx.sessionPersistence.create(m) + await a.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const revisionA = (await a.ctx.sessionPersistence.listSnapshots())[0]?.revision + await a.dispose() + + const probeA = openDatabase(pathA, 'wal') + const storeIdA = (probeA.prepare( + 'SELECT store_id FROM persistence_state WHERE singleton = 1', + ).get() as { store_id: string }).store_id + probeA.close() + + const aliasA = `${pathA}.alias` + await symlink(pathA, aliasA) + const reopenedA = await backend(aliasA) + expect((await reopenedA.ctx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revisionA) + await reopenedA.dispose() + + const b = await backend(pathB) + await b.ctx.sessionPersistence.create(m) + await b.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const revisionB = (await b.ctx.sessionPersistence.listSnapshots())[0]?.revision + const probeB = openDatabase(pathB, 'wal') + const storeIdB = (probeB.prepare( + 'SELECT store_id FROM persistence_state WHERE singleton = 1', + ).get() as { store_id: string }).store_id + probeB.close() + expect(storeIdB).not.toBe(storeIdA) + expect(revisionB).not.toBe(revisionA) + expect(String(revisionA)).toMatch(/:revision:1$/) + expect(String(revisionB)).toMatch(/:revision:1$/) + await b.dispose() + }) + it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(5) + expect(SCHEMA_VERSION).toBe(6) }) it('keeps the revision stable for an empty repair hook', async () => { @@ -354,6 +392,17 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) describe('SessionPersistenceSqlite: edge cases', () => { + it('rejects and closes a current-schema database with an invalid store identity', async () => { + const path = await freshDbPath() + const db = openDatabase(path, 'wal') + db.exec("UPDATE persistence_state SET store_id = '' WHERE singleton = 1") + db.close() + + const b = await backend(path) + await expect(b.ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/no valid store identity/) + await expect(b.dispose()).resolves.toBeUndefined() + }) + it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => { const path = await freshDbPath() const m = meta('rollback-insert') diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 32958bfaaa..d16bfcda40 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -12,7 +12,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `append(id, events): Promise` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | | `list(): Promise` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | -| `listSnapshots(): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log is unchanged and changes after append or mutating load repair. | +| `listSnapshots(): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. | ## Invariants every backend must honor diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 84ab0f321a..b647befeee 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -34,7 +34,7 @@ export { SessionPersistenceRevision } from './revision.ts' export interface SessionPersistenceSnapshot { /** Detached metadata for one materialized session. */ header: SessionHeader - /** Opaque token that changes whenever this stored log changes. */ + /** Opaque source-qualified token that changes whenever this stored log changes. */ revision: SessionPersistenceRevision } @@ -172,6 +172,8 @@ export abstract class SessionPersistence extends Service { * * Repeated observations of an unchanged log return the same revision. A * successful mutating {@link load} repair changes the next listed revision. + * Revisions also distinguish independently backed stores so backend-local + * counters cannot compare equal across different persistence sources. * @returns one header and opaque revision per materialized session without loading full logs. */ abstract listSnapshots(): Promise diff --git a/packages/session-persistence/session-persistence/src/revision.ts b/packages/session-persistence/session-persistence/src/revision.ts index 41378eb3e4..cb037ffafc 100644 --- a/packages/session-persistence/session-persistence/src/revision.ts +++ b/packages/session-persistence/session-persistence/src/revision.ts @@ -2,7 +2,10 @@ import type { Branded } from '@deepseek-ai/dsh-brand' -/** Backend-owned token that changes whenever one persisted session log changes. */ +/** + * Backend-owned token that identifies both one storage source and one revision + * of a persisted session log. + */ export type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'> /** diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 001ea87d7d..d2f337cafd 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -12,7 +12,7 @@ All three surfaces (`current`, `shadowed`, and `log-only`) are searchable by def ## Source and index lifecycle -The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine compares lightweight durable snapshot revisions, loads only new or changed logs, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Repeated queries and unchanged reopen load no full durable logs; new, changed, deleted, or load-repaired sources reconcile on the next stable observation. Source or transaction failure commits nothing, and the next search retries. +The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine compares source-qualified lightweight durable snapshot revisions, loads only new or changed logs, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Repeated queries and unchanged same-store reopen load no full durable logs; switching stores, or observing new, changed, deleted, or load-repaired sources, reconciles on the next stable observation. Source or transaction failure commits nothing, and the next search retries. Persisted FTS rows live in a dedicated derived database. Connection-local TEMP tables hold live rows, which shadow the durable base for the same session and reveal it when the live owner disappears. Unmounting persistence hides durable rows without discarding the cache; remounting reconciles it. Closing or reopening the database drops every live overlay while retaining persisted rows. diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index f4904e17d8..9897331549 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -926,4 +926,49 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await expect(ctx.sessionPersistence.load(meta.id)).resolves.toMatchObject({ meta, events: [{ seq: 0 }] }) await persistence.dispose() }) + + it('reconciles colliding local revisions when a derived index reopens against another SQLite store', async () => { + const persistencePathA = await temporaryPath('canonical-a.db') + const persistencePathB = await temporaryPath('canonical-b.db') + const searchPath = await temporaryPath('derived-collision.db') + const shared = header('same-id', 10) + + const first = new Context() + await first.plugin(SessionStore) + const persistenceA = await first.plugin(SessionPersistenceSqlite, { path: persistencePathA }) + await first.sessionPersistence.create(shared) + await first.sessionPersistence.append(shared.id, messageEvents('alpha source')) + const loadA = vi.spyOn(first.sessionPersistence, 'load') + const searchA = await first.plugin(SessionSearchSqlite, { path: searchPath }) + await expect(first.sessionSearch.searchSessions({ query: 'alpha' })) + .resolves.toMatchObject({ items: [{ header: shared }] }) + expect(loadA).toHaveBeenCalledTimes(1) + await searchA.dispose() + await persistenceA.dispose() + + const reopened = new Context() + await reopened.plugin(SessionStore) + const persistenceAAgain = await reopened.plugin(SessionPersistenceSqlite, { path: persistencePathA }) + const reopenedLoad = vi.spyOn(reopened.sessionPersistence, 'load') + const searchAAgain = await reopened.plugin(SessionSearchSqlite, { path: searchPath }) + await expect(reopened.sessionSearch.searchSessions({ query: 'alpha' })) + .resolves.toMatchObject({ items: [{ header: shared }] }) + expect(reopenedLoad).not.toHaveBeenCalled() + await searchAAgain.dispose() + await persistenceAAgain.dispose() + + const second = new Context() + await second.plugin(SessionStore) + const persistenceB = await second.plugin(SessionPersistenceSqlite, { path: persistencePathB }) + await second.sessionPersistence.create(shared) + await second.sessionPersistence.append(shared.id, messageEvents('bravo source')) + const loadB = vi.spyOn(second.sessionPersistence, 'load') + const searchB = await second.plugin(SessionSearchSqlite, { path: searchPath }) + await expect(second.sessionSearch.searchSessions({ query: 'bravo' })) + .resolves.toMatchObject({ items: [{ header: shared }] }) + await expect(second.sessionSearch.searchSessions({ query: 'alpha' })).resolves.toEqual({ items: [] }) + expect(loadB).toHaveBeenCalledTimes(1) + await searchB.dispose() + await persistenceB.dispose() + }) }) From 9eb49f61a9334429837325849d34979f95fdd368 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 12:41:46 +0800 Subject: [PATCH 004/207] chore(knip): include session query loader e2e --- knip.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/knip.json b/knip.json index 825980f205..03fcd1b30d 100644 --- a/knip.json +++ b/knip.json @@ -45,6 +45,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/session-query/session-query-sqlite": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/code-runtime/code-runtime-worker": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] From f1426511be657e9ca45663f9724678ffe3a79ca4 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 16:35:17 +0800 Subject: [PATCH 005/207] test(hooks): wait for SubagentStop marker output --- packages/hooks/hooks-claude/tests/coverage.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index f376708688..9632d075dd 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' @@ -612,7 +612,6 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server await waitForIdle(ctx, handle.agent as ReactLoopAgent) expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir - const { readFileSync } = await import('node:fs') const where = readFileSync(marker, 'utf8').trim() // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. expect(where.endsWith(sessionDir.split('/').pop()!)).toBe(true) @@ -643,9 +642,10 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server const childHandle = await ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } }) ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' }) - await waitFor(() => existsSync(marker)) + // Redirection creates the marker before `pwd` writes it, so wait for the + // trailing newline that marks the command's complete output. + await waitFor(() => existsSync(marker) && readFileSync(marker, 'utf8').endsWith('\n')) expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir - const { readFileSync } = await import('node:fs') const where = readFileSync(marker, 'utf8').trim() // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. expect(where.endsWith(childDir.split('/').pop()!)).toBe(true) From 4505c6c55a515b5eed9a22a7ae112de5de413ba6 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 13:55:23 +0800 Subject: [PATCH 006/207] refactor(session-query): simplify persistence binding (round 1) --- .../session-query-sqlite/src/index.ts | 58 +++++++++---------- .../session-query-sqlite/tests/sqlite.spec.ts | 29 +++++++--- 2 files changed, 48 insertions(+), 39 deletions(-) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index dd1ddb532a..4a510e5324 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -97,9 +97,12 @@ interface ObservedPersistedSession { loaded?: ObservedSession } +interface PersistenceBinding { + readonly service?: SessionPersistence +} + interface Observation { - persistence: SessionPersistence | undefined - persistenceRevision: number + persistenceBinding: PersistenceBinding persisted: Map live: Map } @@ -161,10 +164,8 @@ export class SessionSearchSqlite extends SessionSearchService { private readonly _instance = randomUUID() private readonly _ready: Promise private _db: DatabaseSync | undefined - private _persistence: SessionPersistence | undefined - private _persistenceBinding: object | undefined - private _persistenceRevision = 0 - private _lastPersistenceRevision: number | undefined + private _persistenceBinding: PersistenceBinding = {} + private _lastPersistenceBinding: PersistenceBinding | undefined private _persistenceEpoch = 0 private _globalGeneration = 0 private _localGeneration = 0 @@ -182,16 +183,12 @@ export class SessionSearchSqlite extends SessionSearchService { void this._ready.catch(() => undefined) this._optionalPersistenceFiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { const service = childCtx.sessionPersistence - const binding = {} + const binding = { service } this._persistenceBinding = binding - this._persistence = service - this._persistenceRevision += 1 childCtx.effect(() => () => { /* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */ if (this._persistenceBinding !== binding) return - this._persistenceBinding = undefined - this._persistence = undefined - this._persistenceRevision += 1 + this._persistenceBinding = {} }, 'sessionSearchSqlite.persistenceBinding') }) ctx.effect(() => { @@ -330,16 +327,16 @@ export class SessionSearchSqlite extends SessionSearchService { const liveById = new Map(liveRows.map(row => [row.id as SessionId, row])) const observation = await this._observeStable(persistedById, signal) assertNotAborted(signal) - const persistentChanges = observation.persistence === undefined + const persistentChanges = observation.persistenceBinding.service === undefined ? [] : [...observation.persisted.values()].filter(entry => entry.loaded !== undefined) - const persistentDeletes = observation.persistence === undefined + const persistentDeletes = observation.persistenceBinding.service === undefined ? [] : persistedRows.filter(row => !observation.persisted.has(row.id as SessionId)) const liveChanges = [...observation.live.values()].filter(entry => liveById.get(entry.header.id)?.fingerprint !== entry.fingerprint) const liveDeletes = liveRows.filter(row => !observation.live.has(row.id as SessionId)) - const pointerChanged = this._lastPersistenceRevision !== undefined - && this._lastPersistenceRevision !== observation.persistenceRevision + const pointerChanged = this._lastPersistenceBinding !== undefined + && this._lastPersistenceBinding !== observation.persistenceBinding const hasWrites = persistentChanges.length > 0 || persistentDeletes.length > 0 || liveChanges.length > 0 @@ -393,7 +390,7 @@ export class SessionSearchSqlite extends SessionSearchService { if (hasWrites || pointerChanged) this._globalGeneration += 1 if (pointerChanged) this._persistenceEpoch += 1 this._localGeneration = nextLocalGeneration - this._lastPersistenceRevision = observation.persistenceRevision + this._lastPersistenceBinding = observation.persistenceBinding } private async _observeStable( @@ -402,13 +399,13 @@ export class SessionSearchSqlite extends SessionSearchService { ): Promise { for (;;) { assertNotAborted(signal) - const persistence = this._persistence - const persistenceRevision = this._persistenceRevision + const persistenceBinding = this._persistenceBinding + const persistence = persistenceBinding.service let persisted = new Map() if (persistence !== undefined) { try { - const canReuseIndexed = this._lastPersistenceRevision === undefined - || this._lastPersistenceRevision === persistenceRevision + const canReuseIndexed = this._lastPersistenceBinding === undefined + || this._lastPersistenceBinding === persistenceBinding const before = await waitWithAbort(persistence.listSnapshots(), signal) persisted = materializePersistenceSnapshots(before) for (const entry of persisted.values()) { @@ -421,14 +418,14 @@ export class SessionSearchSqlite extends SessionSearchService { await waitWithAbort(persistence.listSnapshots(), signal), ) if (!samePersistenceSnapshots(persisted, after)) continue - if (this._persistenceRevision !== persistenceRevision) continue + if (this._persistenceBinding !== persistenceBinding) continue } catch (error: unknown) { if (isAbort(error) || signal?.aborted) { throw new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED', { cause: error, }) } - if (this._persistenceRevision !== persistenceRevision) continue + if (this._persistenceBinding !== persistenceBinding) continue if (error instanceof SessionQueryError) throw error throw new SessionQueryError( `session-search persistence observation failed: ${errorMessage(error)}`, @@ -444,8 +441,8 @@ export class SessionSearchSqlite extends SessionSearchService { if (durable !== undefined) assertSessionHeadersCompatible(observed.header, durable.header) live.set(session.id, observed) } - if (this._persistenceRevision === persistenceRevision) { - return { persistence, persistenceRevision, persisted, live } + if (this._persistenceBinding === persistenceBinding) { + return { persistenceBinding, persisted, live } } } } @@ -564,7 +561,7 @@ export class SessionSearchSqlite extends SessionSearchService { ORDER BY match_count DESC, document_length ASC, time DESC, session_id ASC, seq DESC LIMIT ? OFFSET ? `).all( - ...selectedDocumentsParams(request.query, this._persistence !== undefined), + ...selectedDocumentsParams(request.query, this._persistenceBinding.service !== undefined), ...sessionWhere.params, ...eventWhere.params, request.limit + 1, @@ -583,7 +580,7 @@ export class SessionSearchSqlite extends SessionSearchService { ORDER BY match_count DESC, document_length ASC, time DESC, seq DESC LIMIT ? OFFSET ? `).all( - ...selectedDocumentsParams(request.query, this._persistence !== undefined), + ...selectedDocumentsParams(request.query, this._persistenceBinding.service !== undefined), request.sessionId, ...eventWhere.params, request.limit + 1, @@ -597,7 +594,7 @@ export class SessionSearchSqlite extends SessionSearchService { 'SELECT generation FROM temp.live_sessions WHERE id = ?', ).get(sessionId) as { generation: number } | undefined if (live !== undefined) return `live:${live.generation}` - if (this._persistence !== undefined) { + if (this._persistenceBinding.service !== undefined) { const persisted = db.prepare( 'SELECT generation FROM persisted_sessions WHERE id = ?', ).get(sessionId) as { generation: number } | undefined @@ -713,10 +710,7 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar } function observeLive(session: Session): ObservedSession { - return observeSession( - structuredClone(session.header), - session.events.map(event => structuredClone(event)), - ) + return observeSession(session.header, session.events) } function observeSession(header: SessionHeader, events: readonly SessionEvent[]): ObservedSession { diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 9897331549..3de325b35f 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -326,6 +326,24 @@ describe('SQLite session search', () => { })).rejects.toThrow(expectCode('SESSION_QUERY_STALE_CURSOR')) }) + it('invalidates session cursors after transient persistence topology changes', async () => { + TestPersistence.reset() + const ctx = await liveContext({ path: ':memory:', defaultLimit: 1, maxLimit: 5 }) + ctx.sessions.create(SessionId('first'), { seed: messageEvents('needle first') }) + ctx.sessions.create(SessionId('second'), { seed: messageEvents('needle second') }) + const page = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1 }) + if (page.nextCursor === undefined) throw new Error('expected cursor') + + const persistence = await ctx.plugin(TestPersistence) + await persistence.dispose() + + await expect(ctx.sessionSearch.searchSessions({ + query: 'needle', + limit: 1, + cursor: page.nextCursor, + })).rejects.toThrow(expectCode('SESSION_QUERY_STALE_CURSOR')) + }) + it('rejects invalid requests, filters, cursors, and direct config', async () => { const ctx = await liveContext({ path: ':memory:', defaultLimit: 2, maxLimit: 3 }) const session = ctx.sessions.create(SessionId('valid'), { seed: messageEvents('needle') }) @@ -503,11 +521,6 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.set({ meta: durable, events: messageEvents('new needle') }) TestPersistence.revisions.set(durable.id, revision) const replacement = await ctx.plugin(TestPersistence) - const internals = ctx.sessionSearch as unknown as { - _lastPersistenceRevision: number - _persistenceRevision: number - } - expect(internals._persistenceRevision).not.toBe(internals._lastPersistenceRevision) const page = await ctx.sessionSearch.searchSessions({ query: 'new needle' }) expect(TestPersistence.loads.get(durable.id)).toBe(2) expect(page).toMatchObject({ items: [{ header: durable }] }) @@ -553,13 +566,15 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) const ctx = await liveContext() await ctx.plugin(TestPersistence) - const internals = ctx.sessionSearch as unknown as { _persistenceRevision: number } + const internals = ctx.sessionSearch as unknown as { + _persistenceBinding: { service?: SessionPersistence } + } const originalList = ctx.sessions.list.bind(ctx.sessions) let bumped = false const list = vi.spyOn(ctx.sessions, 'list').mockImplementation(() => { if (!bumped) { bumped = true - internals._persistenceRevision += 1 + internals._persistenceBinding = { ...internals._persistenceBinding } } return originalList() }) From de863aab9ab674a1528f575695fd62d1b151966a Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 14:04:49 +0800 Subject: [PATCH 007/207] fix(session-query): release stale persistence binding (round 2) --- .../session-query-sqlite/src/index.ts | 19 ++++++++++--------- .../session-query-sqlite/tests/sqlite.spec.ts | 7 +++++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 4a510e5324..1590a1d61c 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -98,6 +98,7 @@ interface ObservedPersistedSession { } interface PersistenceBinding { + readonly identity: symbol readonly service?: SessionPersistence } @@ -164,8 +165,8 @@ export class SessionSearchSqlite extends SessionSearchService { private readonly _instance = randomUUID() private readonly _ready: Promise private _db: DatabaseSync | undefined - private _persistenceBinding: PersistenceBinding = {} - private _lastPersistenceBinding: PersistenceBinding | undefined + private _persistenceBinding: PersistenceBinding = { identity: Symbol() } + private _lastPersistenceIdentity: symbol | undefined private _persistenceEpoch = 0 private _globalGeneration = 0 private _localGeneration = 0 @@ -183,12 +184,12 @@ export class SessionSearchSqlite extends SessionSearchService { void this._ready.catch(() => undefined) this._optionalPersistenceFiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { const service = childCtx.sessionPersistence - const binding = { service } + const binding = { identity: Symbol(), service } this._persistenceBinding = binding childCtx.effect(() => () => { /* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */ if (this._persistenceBinding !== binding) return - this._persistenceBinding = {} + this._persistenceBinding = { identity: Symbol() } }, 'sessionSearchSqlite.persistenceBinding') }) ctx.effect(() => { @@ -335,8 +336,8 @@ export class SessionSearchSqlite extends SessionSearchService { : persistedRows.filter(row => !observation.persisted.has(row.id as SessionId)) const liveChanges = [...observation.live.values()].filter(entry => liveById.get(entry.header.id)?.fingerprint !== entry.fingerprint) const liveDeletes = liveRows.filter(row => !observation.live.has(row.id as SessionId)) - const pointerChanged = this._lastPersistenceBinding !== undefined - && this._lastPersistenceBinding !== observation.persistenceBinding + const pointerChanged = this._lastPersistenceIdentity !== undefined + && this._lastPersistenceIdentity !== observation.persistenceBinding.identity const hasWrites = persistentChanges.length > 0 || persistentDeletes.length > 0 || liveChanges.length > 0 @@ -390,7 +391,7 @@ export class SessionSearchSqlite extends SessionSearchService { if (hasWrites || pointerChanged) this._globalGeneration += 1 if (pointerChanged) this._persistenceEpoch += 1 this._localGeneration = nextLocalGeneration - this._lastPersistenceBinding = observation.persistenceBinding + this._lastPersistenceIdentity = observation.persistenceBinding.identity } private async _observeStable( @@ -404,8 +405,8 @@ export class SessionSearchSqlite extends SessionSearchService { let persisted = new Map() if (persistence !== undefined) { try { - const canReuseIndexed = this._lastPersistenceBinding === undefined - || this._lastPersistenceBinding === persistenceBinding + const canReuseIndexed = this._lastPersistenceIdentity === undefined + || this._lastPersistenceIdentity === persistenceBinding.identity const before = await waitWithAbort(persistence.listSnapshots(), signal) persisted = materializePersistenceSnapshots(before) for (const entry of persisted.values()) { diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 3de325b35f..0a62fff1b3 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -567,14 +567,17 @@ describe('SQLite reconciliation and source lifecycle', () => { const ctx = await liveContext() await ctx.plugin(TestPersistence) const internals = ctx.sessionSearch as unknown as { - _persistenceBinding: { service?: SessionPersistence } + _persistenceBinding: { identity: symbol; service?: SessionPersistence } } const originalList = ctx.sessions.list.bind(ctx.sessions) let bumped = false const list = vi.spyOn(ctx.sessions, 'list').mockImplementation(() => { if (!bumped) { bumped = true - internals._persistenceBinding = { ...internals._persistenceBinding } + internals._persistenceBinding = { + ...internals._persistenceBinding, + identity: Symbol(), + } } return originalList() }) From 92fd92fa697639ece53fcbc8175daf8589b263e4 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 14:12:50 +0800 Subject: [PATCH 008/207] test(session-query): name binding retry precisely (round 3) --- .../session-query/session-query-sqlite/tests/sqlite.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 0a62fff1b3..b4a69acbbd 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -561,7 +561,7 @@ describe('SQLite reconciliation and source lifecycle', () => { expect(TestPersistence.loads.get(added.id)).toBe(1) }) - it('retries if the source revision changes while live sessions are observed', async () => { + it('retries if the persistence binding changes while live sessions are observed', async () => { const durable = header('live-boundary-retry') TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) const ctx = await liveContext() From 60fcb494a763dec5ad8e431f922e016799cb529f Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:19:25 -0700 Subject: [PATCH 009/207] docs(i18n): restore prompt-v4 as the pipeline baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 jingtingxiang 拍板将 prompt 回到 v4 基线:模板正文恢复内嵌的 格式/语气/句式/词汇/标点全量约束与 11 组正误例(量词规则按术语表 现行裁定 package→包 写作「由三个包构成的 seam」),不再注入 translation-rules.md——该文件约束人和 agent,不进模板;占位符收敛 为 source_lang/target_lang/terminology 三个,切换行由模型按文档 自身拼写。渲染器、解析器、conformance 门禁与单测同步回 v4 契约: 三段裸 XML(translation/review/final 顺序唯一),容忍整体 ```xml 围栏回显;saxes 依赖随 CDATA 协议一并移除。 --- docs/i18n/translation-prompt.md | 168 +++++++++++++++++++-------- package.json | 1 - pnpm-lock.yaml | 22 ++-- scripts/translation-prompt.spec.ts | 87 +++++--------- scripts/translation-prompt.ts | 144 +++++++---------------- scripts/verify-translation-prompt.ts | 28 ++--- 6 files changed, 213 insertions(+), 237 deletions(-) diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index 8bc15d6e8e..d59170b00a 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -1,6 +1,6 @@ # Translation prompt (pipeline asset) -本文件是自动翻译流水线使用的 prompt 模板;从 `# Translation Prompt` 开始的正文会逐字进入模型请求,因此本文件不参与双语配对(见 [README.md](README.md) 排除清单)。渲染时会把 [translation-rules.md](translation-rules.md) 全文填入 `{{translation_rules}}`,把 [terminology.md](terminology.md) 整表填入 `{{terminology}}`,以免模板另存一份规则而日后失去同步。[style-samples.md](style-samples.md) 定义文体,模板中的 Examples 只用于说明典型问题;术语表、忠实性和结构规则优先于样例,样例只在这些硬性约束内决定文体。修改本文件会改变翻译行为,需正常经过 PR 评审。 +本文件是自动翻译流水线的 prompt 模板;从 `# Translation Prompt` 开始的正文会逐字进入模型请求,因此本文件不参与双语配对(见 [README.md](README.md) 排除清单)。模板正文与内嵌 few-shot 正误例由 jingtingxiang 基于对存量译文的质量评审撰写,是流水线行为的拍板基线。渲染时把 [terminology.md](terminology.md) 整表填入 `{{terminology}}`;除此之外不注入任何其他仓库文件(translation-rules.md 约束人和 agent 的翻译工作,不注入本模板)。[style-samples.md](style-samples.md) 定义文体,模板中的 Examples 只用于说明典型问题,两者冲突时以文体样例为准。修改本文件会改变翻译行为,需正常经过 PR 评审。 ## 占位符契约 @@ -10,18 +10,13 @@ |---|---|---| | `{{source_lang}}` | 源语言名(`English` / `Chinese`) | 由改动侧文件推断:`.zh.md` 被改则为 `Chinese` | | `{{target_lang}}` | 目标语言名(`Chinese` / `English`) | 与 `{{source_lang}}` 相对 | -| `{{translation_rules}}` | [translation-rules.md](translation-rules.md) 全文(Markdown 原文) | 渲染时读取仓库当前版本,不缓存 | | `{{terminology}}` | [terminology.md](terminology.md) 的完整表格(Markdown 原文) | 渲染时读取仓库当前版本,不缓存 | -| `{{source_filename}}` | 源文档的 basename(如 `foo.md` 或 `foo.zh.md`) | 由流水线从待译文件路径取得 | -| `{{source_filename_zh}}` | 中文侧 basename(如 `foo.zh.md`) | 英文源追加 `.zh`;中文源使用自身 basename | -例如,英译中时若源文件是 `foo.md`,`{{source_filename}}` 填 `foo.md`,`{{source_filename_zh}}` 填 `foo.zh.md`;中译英时若源文件是 `foo.zh.md`,两个占位符都填 `foo.zh.md`。 - -流水线只识别上表中的占位符,并且一次翻译整篇文档。它不支持 `{{to}}`、`{{title_prompt}}`、`{{summary_prompt}}`、`{{terms_prompt}}`、`{{imt_style_guide}}` 或 `%%` 分段协议。输出必须是一个以 `` 为根元素的 XML 文档;三个子元素中的 Markdown 内容都放在 CDATA 中。内容出现 `]]>` 时写成 `]]]]>`,XML 解析后仍会还原为原文。 +流水线只识别上表中的占位符,并且一次翻译整篇文档。它不支持 `{{to}}`、`{{title_prompt}}`、`{{summary_prompt}}`、`{{terms_prompt}}`、`{{imt_style_guide}}`、`{{translation_rules}}` 或 `%%` 分段协议;输出采用模板正文规定的三段 XML,流水线解析取 `` 段。 ## Few-shot 金标 -流水线使用**整篇文档**的中英对照作为 few-shot,不是模板内嵌的句子级正误例。以下 5 组配对文档均经过人工评审,并以仓库当前版本为准,随仓库一同更新: +流水线使用**整篇文档**的中英对照作为 few-shot,不是模板内嵌的句子级正误例。以下 5 组配对文档均经过人工评审,以仓库当前版本为准、随仓库更新: - `README.md` ↔ `README.zh.md` - `docs/development.md` ↔ `docs/development.zh.md` @@ -29,58 +24,131 @@ - `docs/i18n/translation-rules.md` ↔ `docs/i18n/translation-rules.zh.md` - `docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md` ↔ 对应 `.zh.md` -注入时按当前翻译方向选择每组的源侧与目标侧:user 消息包含源文档全文,assistant 消息采用模板正文规定的 XML 协议;`translation` 与 `final` 都放入目标文档全文,`review` 填 `- [None] No corrections.`。CDATA 遵循上文的 `]]>` 拆分规则。上下文不足时,按上列顺序从后往前删减示例组数。这 5 组也是评审校准锚点;改动任何一组都会改变流水线行为。 +注入方式:在系统消息(本模板)之后、待译文档之前,每组作为一轮示例对话——user 消息为源文档全文,assistant 消息为定稿译文全文(裸文本,不带三段 XML 包装;只有真实请求要求三段输出)。上下文不足时按上列顺序从后往前删减组数。这 5 组也是评审校准锚点(见 [style-samples.md](style-samples.md)),改动任何一组即改变流水线行为。 ## 模板正文 ````text # Translation Prompt -You are a senior technical translator specializing in LLM and agent development documentation. Translate the complete source document from {{source_lang}} to {{target_lang}} as natural, professional technical prose. +You are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from {{source_lang}} to {{target_lang}}, producing natural, professional technical prose. -## Binding Translation Rules +## Quality Requirements -The canonical repository rules below are injected verbatim. Apply every direction-appropriate requirement. In those rules, the authored document is the source for this request and the generated document is its counterpart. +### Structure and Format Preservation +- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks. +- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions. +- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them. +- Every relative link must point to the same target as in the source. Link text is translated; link targets are not. +- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction. +- After a closing bold marker `**`, always insert a space before the next character. -{{translation_rules}} +### Tone and Style +- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it. +- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions. +- Use polite imperative forms where the text instructs the reader to do something. +- Keep the author's register: concise stays concise, detailed stays detailed. -## Request-Specific Structure +### Sentence Structure +- Break long sentences with commas or semicolons. Avoid run-on sentences. +- Prefer active voice. Convert passive constructions to active if it reads more naturally. +- Translate meaning, not words. Restructure sentences where the target language grammar requires it. +- Do not invent words or expressions that do not exist in natural technical writing of the target language. -- The source basename is `{{source_filename}}`. When translating into Chinese, write `[English]({{source_filename}}) | 中文` immediately after the H1. When translating into English, write `English | [中文]({{source_filename_zh}})` immediately after the H1. -- Emit the switcher for a new pair and flip an existing switcher; never copy it unchanged. +### Word Choice +- Prefer precise, formal vocabulary over casual or colloquial alternatives. +- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language. +- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience. +- Do not use the same word to translate two different source-language terms that carry distinct meanings. +- Avoid repeating the same verb in close proximity; vary word choice for readability. -## Binding Terminology +#### When translating into Chinese +- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: "three-package seam" → "由三个包构成的 seam", not "三包 seam". -Apply the current table below exactly as required by the injected translation rules. +### Punctuation + +#### When translating into Chinese +- Use full-width Chinese punctuation in prose: `,。:;?!()「」`. +- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all. +- Use enumeration commas (、) between parallel items, not regular commas. +- List item endings: use semicolons or no punctuation. Do not end list items with commas. +- Put one half-width space between Chinese text and Latin words/numbers. +- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), render the corresponding Chinese term in italics: *必须*、*禁止*、*应当*、*可以*. + +#### When translating into English +(To be added.) + +## Terminology + +A terminology table is provided below. Follow it strictly: +- Render every listed term exactly as specified. +- First occurrence: write as shown in the "首次出现" column (with parenthetical gloss). Subsequent occurrences: write only the part before the parentheses. +- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later. +- NEVER use translations listed in the "不要译作" column. +- For technical terms not in the table: keep them in the source language. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression. {{terminology}} ## Output Format -Return exactly one well-formed XML document with this root and these three child elements. Do not wrap it in a Markdown code fence. Put all Markdown and review text inside CDATA. If any content contains the CDATA terminator, split it as `]]]]>` so XML parsing reconstructs the original `]]>` sequence. +Produce your output in three XML sections: ```xml - - - - - + +(Complete translation of the source document) + + + +(Self-review notes, one correction per line with category tag, e.g.) +- [Tone] "旁挂记录" → "伴随记录"(生造词) +- [Sentence] 第 3 段补充逗号断句 +- [Punctuation] 两处破折号替换为冒号 +- 无修正 + + + +(Final translation after corrections) + ``` ## Self-Review Instructions -After writing ``, re-read it in the target language without looking at the source. Then apply the injected translation rules as a clause-by-clause comparison against the source and record actual corrections in English inside ``. Apply every recorded correction in ``. If no correction is needed, write only `- [None] No corrections.` in `` and copy `` unchanged into ``. +After writing ``, re-read it in the target language only, without looking at the source. Check by category: + +**Structure** +- Is the heading hierarchy, list shape, and code block content identical to the source? +- Are ALL comments inside code blocks left untranslated (byte-identical to source)? +- Is the language switcher line correctly flipped (not copied from source)? +- Are link targets preserved and bold markers followed by a space? + +**Tone & Style** +- Does every sentence read as if originally written by a native speaker? +- Is there any colloquial, casual, or overly informal phrasing? + +**Sentence Structure** +- Are there run-on sentences that need breaking? +- Are there stiff passive constructions that should be converted to active voice? + +**Word Choice** +- Are there overly literal translations that sound unnatural? +- Is the same target-language word used to translate two distinct source concepts? +- Is any slang or internal jargon present? + +**Terminology** +- Are first-occurrence glosses correctly applied (not missing, not repeated)? +- Are any "不要译作" forbidden translations present? +- Are unlisted terms correctly kept in the source language? + +**Punctuation** (when target is Chinese) +- Are there em-dashes that should be replaced with colons, periods, or commas? +- Are list items ending with commas instead of semicolons? +- Are RFC 2119 keywords rendered in italics? + +Record corrections in `` with category tags. Then output the corrected version in ``. If no corrections are needed, write "无修正" in `` and copy the translation unchanged into ``. ## Examples -Follow the Good versions; these sentence-level examples illustrate error categories, not the assistant-message wire format. +Below are representative examples of common problems and their corrections. Follow the "Good" versions. ### Colloquial verb → Professional verb - Source: `The repo pins pnpm@11.7.0 in package.json` @@ -102,40 +170,40 @@ Follow the Good versions; these sentence-level examples illustrate error categor - Bad: `旁挂记录两侧 blob hash,使一致性可检查` - Good: `伴随记录保存两侧 blob hash,使一致性可检查` +### Em-dash → Colon/period +- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.` +- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。` +- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。` + ### Overly literal → Meaningful rendering - Source: `awkward phrasing is easier to hear without the source anchoring you` - Bad: `没有源文锚着,别扭的表述更容易被听出来` - Good: `不对照原文时,更容易察觉别扭的表达` -### Terminology — keep the binding English form +### Terminology — do not translate what should be kept in English - Source: `typed service seams, and explicit extension points` - Bad: `类型化的服务 seam(扩展点)与显式扩展点` - Good: `类型化的服务 seam 与显式扩展点` -### Slang → Professional phrasing +### Slang/jargon → Professional phrasing - Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs` - Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs` - Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs` -### Chinese → English — idiomatic subject and predicate -- Source: `门禁绿并不代表译文内容正确。` -- Bad: `The gate green does not represent that the translation content is correct.` -- Good: `A green gate does not mean the translation is correct.` +### "For humans" — translate the intent, not the word +- Source: `For humans, start with the development guide` +- Bad: `对于人工读者,请先从开发指南开始`("人工读者"生硬) +- Good: `面向开发者:请先阅读开发指南`("开发者"自然,且中文里冒号在此处更自然) -### Code block comments — never translate +### Code block comments — NEVER translate - Source code block contains: `# REPL agent demo (needs DEEPSEEK_API_KEY)` - Bad: `# REPL agent 演示(需要 DEEPSEEK_API_KEY)` -- Good: `# REPL agent demo (needs DEEPSEEK_API_KEY)` (byte-identical) +- Good: `# REPL agent demo (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte) -### Language switcher — English to Chinese -- Source: `English | [中文](README.zh.md)` -- Bad: `English | [中文](README.zh.md)` -- Good: `[English](README.md) | 中文` - -### Language switcher — Chinese to English -- Source: `[English](README.md) | 中文` -- Bad: `[English](README.md) | 中文` -- Good: `English | [中文](README.zh.md)` +### Language switcher — flip direction +- Source file (English) has: `English | [中文](README.zh.md)` +- Bad (copying source unchanged): `English | [中文](README.zh.md)` +- Good (flipped for Chinese file): `[English](README.md) | 中文` --- diff --git a/package.json b/package.json index aaec1e62c4..51fd789a2b 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,6 @@ "mermaid": "11.16.0", "micromark-extension-gfm": "^3.0.0", "publint": "^0.3.21", - "saxes": "^6.0.0", "tsdown": "^0.22.2", "tsx": "^4.22.4", "typescript": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1a3a78030..0e71da2ea1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,9 +68,6 @@ importers: publint: specifier: ^0.3.21 version: 0.3.21 - saxes: - specifier: ^6.0.0 - version: 6.0.0 tsdown: specifier: ^0.22.2 version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) @@ -819,7 +816,7 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.79.1 - version: 0.79.3(ws@8.21.0)(zod@4.4.3) + version: 0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -3059,6 +3056,10 @@ packages: cpu: [x64] os: [win32] + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -6137,11 +6138,11 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@earendil-works/pi-ai@0.79.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 @@ -6299,12 +6300,14 @@ snapshots: '@exodus/bytes@1.15.1': {} - '@google/genai@1.52.0': + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.7.0 p-retry: 4.6.2 protobufjs: 7.6.4 ws: 8.21.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color @@ -6563,6 +6566,9 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.20.0': optional: true + '@pkgjs/parseargs@0.11.0': + optional: true + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -8034,6 +8040,8 @@ snapshots: jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 jiti@2.7.0: {} diff --git a/scripts/translation-prompt.spec.ts b/scripts/translation-prompt.spec.ts index 93754a79a1..db3f8b31a0 100644 --- a/scripts/translation-prompt.spec.ts +++ b/scripts/translation-prompt.spec.ts @@ -1,5 +1,7 @@ -/** Regression tests for the executable translation prompt contract. */ +/** Unit tests for the prompt-v4 renderer and three-section response parser. */ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parseTranslationResponse, @@ -7,70 +9,43 @@ import { renderTranslationResponse, } from './translation-prompt.ts' -const document = `# Wrapper - -## 模板正文 - -\`\`\`\`text -{{source_lang}} to {{target_lang}} -{{translation_rules}} -{{terminology}} -[English]({{source_filename}}) | [中文]({{source_filename_zh}}) -\`\`\`\` -` +const root = resolve(import.meta.dirname, '..') +const document = readFileSync(join(root, 'docs/i18n/translation-prompt.md'), 'utf8') +const terminology = '| English | 中文 |\n|---|---|\n| agent | agent |' describe('translation prompt rendering', () => { - it('renders every supported placeholder without recursively rewriting injected rules', () => { - const rendered = renderTranslationPrompt(document, { - sourceLanguage: 'English', - sourceFilename: 'guide.md', - translationRules: 'A literal {{source_lang}} in injected rules.', - terminology: '| English | 中文 |', - }) - expect(rendered).toContain('English to Chinese') - expect(rendered).toContain('A literal {{source_lang}} in injected rules.') - expect(rendered).toContain('[English](guide.md) | [中文](guide.zh.md)') + it('renders both directions with every placeholder resolved', () => { + const en = renderTranslationPrompt(document, { sourceLanguage: 'English', terminology }) + expect(en).toContain('from English to Chinese') + expect(en).toContain(terminology) + expect(en).not.toContain('{{') + const zh = renderTranslationPrompt(document, { sourceLanguage: 'Chinese', terminology }) + expect(zh).toContain('from Chinese to English') }) - it('rejects a filename whose suffix contradicts the source language', () => { - expect(() => renderTranslationPrompt(document, { - sourceLanguage: 'Chinese', - sourceFilename: 'guide.md', - translationRules: 'rules', - terminology: 'terms', - })).toThrow('does not match source language Chinese') - }) - - it('rejects malformed template placeholders before injecting rule contents', () => { - expect(() => renderTranslationPrompt(document.replace('{{source_lang}}', '{{source-lang}}'), { - sourceLanguage: 'English', - sourceFilename: 'guide.md', - translationRules: 'A literal {{source_lang}} in injected rules.', - terminology: '| English | 中文 |', - })).toThrow('template contains malformed placeholder syntax') + it('rejects a template with unknown or missing placeholders', () => { + const alien = document.replaceAll('{{terminology}}', '{{terms_prompt}}') + expect(() => renderTranslationPrompt(alien, { sourceLanguage: 'English', terminology })).toThrow(/unsupported placeholder/) + const missing = document.replaceAll('{{terminology}}', '') + expect(() => renderTranslationPrompt(missing, { sourceLanguage: 'English', terminology })).toThrow(/required placeholder/) }) }) -describe('translation response XML', () => { - it('round-trips Markdown and the CDATA terminator', () => { - const response = { - translation: '# Draft\n\nA ]]> marker.', - review: '- [Tone] Fixed.', - final: '# Final\n\nA ]]> marker.', - } +describe('translation response sections', () => { + it('round-trips Markdown bodies', () => { + const response = { translation: '# 标题\n\n正文 **加粗**。', review: '- [Tone] 修正一处。\n- 无修正', final: '# 标题\n\n定稿。' } expect(parseTranslationResponse(renderTranslationResponse(response))).toEqual(response) }) - it('rejects missing, reordered, nested, attributed, or non-CDATA children', () => { - expect(() => parseTranslationResponse('')).toThrow('translation, review, and final') - expect(() => parseTranslationResponse('')) - .toThrow('expected translation, got review') - expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }) - .replace('', ''))) - .toThrow('nested element b is not allowed') - expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }).replace('', ''))) - .toThrow('review must not have attributes') - expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }).replace('', 'x'))) - .toThrow('all response field content must be inside CDATA') + it('tolerates a fenced xml wrapper around the whole response', () => { + const fenced = '```xml\n\nA\n\n\n\n- 无修正\n\n\n\nA\n\n```' + expect(parseTranslationResponse(fenced).final).toBe('A') + }) + + it('rejects missing, unterminated, or duplicated sections', () => { + expect(() => parseTranslationResponse('\nA\n')).toThrow(/missing /) + expect(() => parseTranslationResponse('\nA')).toThrow(/unterminated /) + const dup = '\nA\n\n\nR\n\n\nF\n\n\nG\n' + expect(() => parseTranslationResponse(dup)).toThrow(/duplicate /) }) }) diff --git a/scripts/translation-prompt.ts b/scripts/translation-prompt.ts index e30c962498..0556ff39fa 100644 --- a/scripts/translation-prompt.ts +++ b/scripts/translation-prompt.ts @@ -1,20 +1,16 @@ /** - * Executable renderer and strict response parser for the committed - * documentation-translation prompt contract. + * Executable renderer and response parser for the committed + * documentation-translation prompt contract (prompt-v4). + * + * The v4 contract: three placeholders (`source_lang`, `target_lang`, + * `terminology`), whole-document translation, and a three-section response + * (``, ``, `` in order, bare XML tags with raw + * Markdown bodies). The switcher filename is spelled out by the model from + * the document itself; the pipeline injects no other repository file. */ -import { basename } from 'node:path' -import { SaxesParser } from 'saxes' - /** Placeholder names supported by the committed translation prompt. */ -export const TRANSLATION_PROMPT_PLACEHOLDERS = [ - 'source_lang', - 'target_lang', - 'translation_rules', - 'terminology', - 'source_filename', - 'source_filename_zh', -] as const +export const TRANSLATION_PROMPT_PLACEHOLDERS = ['source_lang', 'target_lang', 'terminology'] as const type TranslationPromptPlaceholder = (typeof TRANSLATION_PROMPT_PLACEHOLDERS)[number] @@ -24,15 +20,11 @@ type TranslationLanguage = 'English' | 'Chinese' /** Inputs that vary for one rendered translation request. */ export interface TranslationPromptInput { sourceLanguage: TranslationLanguage - /** Source basename, including `.md` or `.zh.md`. */ - sourceFilename: string - /** Complete current `translation-rules.md` contents. */ - translationRules: string /** Complete current `terminology.md` contents. */ terminology: string } -/** Parsed contents of the three-element XML response. */ +/** Parsed contents of the three-section response. */ export interface TranslationResponse { translation: string review: string @@ -42,7 +34,7 @@ export interface TranslationResponse { const PLACEHOLDER = /{{([a-z_]+)}}/g const TEMPLATE_OPEN = '## 模板正文\n\n````text\n' const TEMPLATE_CLOSE = '\n````' -const RESPONSE_CHILDREN = ['translation', 'review', 'final'] as const +const RESPONSE_SECTIONS = ['translation', 'review', 'final'] as const /** Extract the machine-consumed text fence from `translation-prompt.md`. */ function extractTranslationPrompt(document: string): string { @@ -61,31 +53,15 @@ export function documentedTranslationPromptPlaceholders(document: string): strin return [...document.slice(0, preambleEnd).matchAll(/^\| `{{([a-z_]+)}}` \|/gm)].map(match => match[1] ?? '') } -/** Render one system prompt from the checked-in template and canonical rules. */ +/** Render one system prompt from the checked-in template. */ export function renderTranslationPrompt(document: string, input: TranslationPromptInput): string { - if (basename(input.sourceFilename) !== input.sourceFilename) { - throw new Error(`translation prompt: sourceFilename must be a basename; got ${JSON.stringify(input.sourceFilename)}`) - } - const sourceIsChinese = input.sourceFilename.endsWith('.zh.md') - if (input.sourceLanguage === 'Chinese' ? !sourceIsChinese : sourceIsChinese || !input.sourceFilename.endsWith('.md')) { - throw new Error(`translation prompt: ${input.sourceFilename} does not match source language ${input.sourceLanguage}`) - } - const targetLanguage: TranslationLanguage = input.sourceLanguage === 'English' ? 'Chinese' : 'English' - const sourceFilenameZh = sourceIsChinese ? input.sourceFilename : input.sourceFilename.replace(/\.md$/, '.zh.md') const values: Record = { source_lang: input.sourceLanguage, target_lang: targetLanguage, - translation_rules: input.translationRules, terminology: input.terminology, - source_filename: input.sourceFilename, - source_filename_zh: sourceFilenameZh, } const template = extractTranslationPrompt(document) - const placeholderFreeTemplate = template.replace(PLACEHOLDER, '') - if (placeholderFreeTemplate.includes('{{') || placeholderFreeTemplate.includes('}}')) { - throw new Error('translation prompt: template contains malformed placeholder syntax') - } const names = [...template.matchAll(PLACEHOLDER)].map(match => match[1] ?? '') const unknown = names.filter(name => !TRANSLATION_PROMPT_PLACEHOLDERS.includes(name as TranslationPromptPlaceholder)) if (unknown.length > 0) throw new Error(`translation prompt: unsupported placeholder(s): ${[...new Set(unknown)].join(', ')}`) @@ -95,77 +71,37 @@ export function renderTranslationPrompt(document: string, input: TranslationProm return template.replace(PLACEHOLDER, (_token, name: string) => values[name as TranslationPromptPlaceholder]) } -/** Escape one value so it remains byte-identical inside an XML CDATA field. */ -function escapeTranslationCdata(value: string): string { - return value.replaceAll(']]>', ']]]]>') -} - -/** Serialize a response using the exact XML wire contract in the prompt. */ +/** Serialize a response in the exact three-section shape the prompt requests. */ export function renderTranslationResponse(response: TranslationResponse): string { - return [ - '', - ``, - ``, - ``, - '', - ].join('\n') + return RESPONSE_SECTIONS.map(section => `<${section}>\n${response[section]}\n`).join('\n\n') } -/** Parse and validate the exact XML response shape emitted by the model. */ -export function parseTranslationResponse(xml: string): TranslationResponse { - const values: TranslationResponse = { translation: '', review: '', final: '' } - const stack: string[] = [] - const cdataFields = new Set() - let rootSeen = false - let childIndex = 0 - const fail = (message: string): never => { - throw new Error(`translation response: ${message}`) - } - const parser = new SaxesParser({ xmlns: false }) +/** + * Parse the three-section response. Sections must each appear exactly once + * and in order; bodies are raw Markdown taken verbatim between the tags. + * A fenced ```xml wrapper around the whole response is tolerated, matching + * the shape some models echo back from the prompt's own example. + */ +export function parseTranslationResponse(text: string): TranslationResponse { + let body = text.trim() + const fenced = /^```(?:xml)?\n([\s\S]*?)\n```$/.exec(body) + if (fenced?.[1] !== undefined) body = fenced[1].trim() - parser.on('opentag', (tag) => { - if (stack.length === 0) { - if (rootSeen) fail('contains more than one root element') - if (tag.name !== 'dsh-translation-response') fail(`expected dsh-translation-response root, got ${tag.name}`) - const attributes = Object.keys(tag.attributes) - if (attributes.length !== 1 || tag.attributes.version !== '1') fail('root must have only version="1"') - rootSeen = true - } else if (stack.length === 1) { - const expected = RESPONSE_CHILDREN[childIndex] - if (tag.name !== expected) fail(`expected ${expected ?? 'no more children'}, got ${tag.name}`) - if (Object.keys(tag.attributes).length !== 0) fail(`${tag.name} must not have attributes`) - childIndex++ - } else { - fail(`nested element ${tag.name} is not allowed`) - } - stack.push(tag.name) - }) - parser.on('text', (value) => { - if (stack.length <= 1 && value.trim() === '') return - fail('all response field content must be inside CDATA') - }) - parser.on('cdata', (value) => { - const field = stack.at(-1) - if (field === undefined || !RESPONSE_CHILDREN.includes(field as (typeof RESPONSE_CHILDREN)[number])) { - fail('CDATA is allowed only inside translation, review, or final') - } - const key = field as (typeof RESPONSE_CHILDREN)[number] - values[key] += value - cdataFields.add(key) - }) - parser.on('closetag', (tag) => { - const expected = stack.pop() - if (expected !== tag.name) fail(`closing ${tag.name} does not match ${expected ?? 'nothing'}`) - }) - parser.on('comment', () => fail('comments are not allowed')) - parser.on('doctype', () => fail('doctypes are not allowed')) - parser.on('processinginstruction', () => fail('processing instructions are not allowed')) - parser.on('error', error => fail(`invalid XML: ${error.message}`)) - parser.write(xml).close() - - if (childIndex !== RESPONSE_CHILDREN.length) fail('translation, review, and final must each appear exactly once and in order') - for (const field of RESPONSE_CHILDREN) { - if (!cdataFields.has(field)) fail(`${field} must contain a CDATA section`) + const values: Partial> = {} + let cursor = 0 + for (const section of RESPONSE_SECTIONS) { + const open = `<${section}>` + const close = `` + const start = body.indexOf(open, cursor) + if (start === -1) throw new Error(`translation response: missing <${section}> section`) + const end = body.indexOf(close, start + open.length) + if (end === -1) throw new Error(`translation response: unterminated <${section}> section`) + values[section] = body.slice(start + open.length, end).replace(/^\n/, '').replace(/\n$/, '') + cursor = end + close.length } - return values + for (const section of RESPONSE_SECTIONS) { + const again = body.indexOf(`<${section}>`, cursor) + if (again !== -1) throw new Error(`translation response: duplicate <${section}> section`) + } + return values as TranslationResponse } diff --git a/scripts/verify-translation-prompt.ts b/scripts/verify-translation-prompt.ts index 66d83e47ad..df72ac73ce 100644 --- a/scripts/verify-translation-prompt.ts +++ b/scripts/verify-translation-prompt.ts @@ -18,37 +18,27 @@ function read(path: string): string { try { const document = read('docs/i18n/translation-prompt.md') - const translationRules = read('docs/i18n/translation-rules.md') const terminology = read('docs/i18n/terminology.md') const documented = documentedTranslationPromptPlaceholders(document) if (documented.join('\n') !== TRANSLATION_PROMPT_PLACEHOLDERS.join('\n')) { throw new Error(`placeholder table must list exactly: ${TRANSLATION_PROMPT_PLACEHOLDERS.join(', ')}`) } - const englishSource = renderTranslationPrompt(document, { - sourceLanguage: 'English', - sourceFilename: 'example.md', - translationRules, - terminology, - }) - const chineseSource = renderTranslationPrompt(document, { - sourceLanguage: 'Chinese', - sourceFilename: 'example.zh.md', - translationRules, - terminology, - }) - if (!englishSource.includes('[English](example.md) | 中文')) throw new Error('English-source render does not carry the Chinese switcher instruction') - if (!chineseSource.includes('English | [中文](example.zh.md)')) throw new Error('Chinese-source render does not carry the English switcher instruction') + const englishSource = renderTranslationPrompt(document, { sourceLanguage: 'English', terminology }) + const chineseSource = renderTranslationPrompt(document, { sourceLanguage: 'Chinese', terminology }) + if (englishSource.includes('{{') || chineseSource.includes('{{')) throw new Error('rendered prompt contains an unresolved placeholder') + if (!englishSource.includes('from English to Chinese')) throw new Error('English-source render does not translate into Chinese') + if (!chineseSource.includes('from Chinese to English')) throw new Error('Chinese-source render does not translate into English') const example = /```xml\n([\s\S]*?)\n```/.exec(englishSource)?.[1] - if (example === undefined) throw new Error('rendered prompt has no XML response example') + if (example === undefined) throw new Error('rendered prompt has no three-section response example') parseTranslationResponse(example) - const roundTrip = { translation: 'first ]]> pass', review: '- [None] No corrections.', final: 'final ]]> text' } + const roundTrip = { translation: 'first pass\n\nwith **markdown**', review: '- 无修正', final: 'final text' } const parsed = parseTranslationResponse(renderTranslationResponse(roundTrip)) - if (JSON.stringify(parsed) !== JSON.stringify(roundTrip)) throw new Error('CDATA split rule does not round-trip response content') + if (JSON.stringify(parsed) !== JSON.stringify(roundTrip)) throw new Error('three-section response does not round-trip') - console.log('verify-translation-prompt: both directions render and the XML response contract parses.') + console.log('verify-translation-prompt: both directions render and the three-section response contract parses.') } catch (error) { const message = error instanceof Error ? error.message : String(error) console.error(`verify-translation-prompt: ${message}`) From 75e9958f11e941ef33753eba037915f6d92be6ec Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 17 Jul 2026 09:39:39 +0800 Subject: [PATCH 010/207] fix(session-query): close review edge cases (round 4) --- docs/config-catalog.md | 8 +-- .../session-persistence-jsonl/src/index.ts | 26 ++++---- .../tests/jsonl.spec.ts | 33 ++++++++++ .../session-persistence-sqlite/README.md | 4 +- .../session-persistence-sqlite/src/index.ts | 11 +++- .../session-persistence-sqlite/src/schema.ts | 5 +- .../tests/sqlite.spec.ts | 25 +++++++- .../session-query-sqlite/README.md | 4 +- .../session-query-sqlite/src/index.ts | 62 +++++++++++++------ .../session-query-sqlite/src/query.ts | 25 +++++--- .../session-query-sqlite/tests/query.spec.ts | 10 ++- .../session-query-sqlite/tests/sqlite.spec.ts | 51 +++++++++++++++ 12 files changed, 213 insertions(+), 51 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c4d5ecc523..6d141185ca 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -627,7 +627,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:39`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:40`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` @@ -654,9 +654,9 @@ export interface Config { path: string /** SQLite journal mode. Defaults to `wal`. */ journalMode?: JournalMode - /** Page size when a request omits `limit`. Defaults to 20. */ + /** Page size when a request omits `limit`. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 20. */ defaultLimit?: number - /** Largest accepted page size. Defaults to 100. */ + /** Largest accepted page size. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 100. */ maxLimit?: number /** Maximum snippet length in Unicode code points. Defaults to 240. */ snippetChars?: number @@ -666,7 +666,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-query/session-query-sqlite/src/index.ts:67`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:72`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-skill` diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index d45f31e3df..b47766e186 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -145,17 +145,21 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi async listSnapshots(): Promise { const snapshots: SessionPersistenceSnapshot[] = [] for (const artifact of await this.listArtifacts()) { - const identity = await stat(artifact.path, { bigint: true }) - snapshots.push({ - header: artifact.header, - revision: SessionPersistenceRevision([ - identity.dev, - identity.ino, - identity.size, - identity.mtimeNs, - identity.ctimeNs, - ].join(':')), - }) + try { + const identity = await stat(artifact.path, { bigint: true }) + snapshots.push({ + header: artifact.header, + revision: SessionPersistenceRevision([ + identity.dev, + identity.ino, + identity.size, + identity.mtimeNs, + identity.ctimeNs, + ].join(':')), + }) + } catch (error: unknown) { + if (!isENOENT(error)) throw error + } } return snapshots } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 0cb4ed3fea..3c826db2eb 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -179,6 +179,39 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { await otherCtx.fiber.dispose() }) + it('omits a snapshot artifact removed after discovery', async () => { + const m = meta('vanishing-snapshot') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as unknown as { + listArtifacts(): Promise> + } + const listArtifacts = persistence.listArtifacts.bind(persistence) + const discovery = vi.spyOn(persistence, 'listArtifacts').mockImplementation(async () => { + const artifacts = await listArtifacts() + await rm(artifacts[0]!.path) + return artifacts + }) + + await expect(ctx.sessionPersistence.listSnapshots()).resolves.toEqual([]) + discovery.mockRestore() + }) + + it('surfaces non-ENOENT snapshot stat failures after discovery', async () => { + const blocker = join(root, 'snapshot-not-a-directory') + await writeFile(blocker, 'x') + const persistence = ctx.sessionPersistence as unknown as { + listArtifacts(): Promise> + } + const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{ + header: meta('snapshot-stat-failure'), + path: join(blocker, 'session.jsonl'), + }]) + + await expect(ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/ENOTDIR/) + discovery.mockRestore() + }) + it('persists a forked child seed through the existing session write path', async () => { const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } }) appendClosedTurn(source) diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index a4a0e645c4..cae28d2bd4 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -6,7 +6,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). +Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. @@ -15,7 +15,7 @@ The repository's Node range supports unflagged `node:sqlite`. The database enabl - **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.) - **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row). - **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor. -- **Lightweight revisions.** `listSnapshots()` combines an immutable store identity, the database file identity, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and prevents independent stores from sharing a revision accidentally. +- **Lightweight revisions.** `listSnapshots()` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. ## Configuration (schemastery) diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 6759704d7f..2f3b941f8b 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -7,6 +7,7 @@ import { Context } from 'cordis' import z from 'schemastery' +import { randomUUID } from 'node:crypto' import { statSync } from 'node:fs' import { DatabaseSync } from 'node:sqlite' import { mkdir } from 'node:fs/promises' @@ -235,7 +236,9 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[] return rows.map(row => ({ header: rowToMeta(row), - revision: SessionPersistenceRevision(`${this.storeIdentity}:revision:${row.revision}`), + revision: SessionPersistenceRevision( + `${this.storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`, + ), })) } @@ -259,8 +262,9 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers */ private writeRow(meta: SessionHeader): void { this.db.prepare(` - INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, revision) - VALUES (?, ?, ?, ?, ?, ?, 0) + INSERT INTO sessions + (id, version, created_at, cwd, parent_session, seed_length, incarnation, revision) + VALUES (?, ?, ?, ?, ?, ?, ?, 0) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, @@ -274,6 +278,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers meta.cwd ?? null, meta.parentSession ?? null, meta.seedLength ?? null, + randomUUID(), ) } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index fe7db15d67..fc397ff742 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 6 +export const SCHEMA_VERSION = 7 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). @@ -33,6 +33,8 @@ export interface SessionRow { cwd: string | null parent_session: string | null seed_length: number | null + /** Stable identity assigned when this log is materialized. */ + incarnation: string /** Monotonic log-change token incremented in each mutating transaction. */ revision: number } @@ -108,6 +110,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM cwd TEXT, parent_session TEXT, seed_length INTEGER, + incarnation TEXT NOT NULL, revision INTEGER NOT NULL ) STRICT `) diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 26b39e22d4..c1d4f8e74f 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -378,8 +378,31 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await b.dispose() }) + it('changes revisions when a deleted session id is materialized again in the same database', async () => { + const path = await freshDbPath() + const m = meta('recreated-revision') + const first = await backend(path) + await first.ctx.sessionPersistence.create(m) + await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const before = (await first.ctx.sessionPersistence.listSnapshots())[0]?.revision + await first.dispose() + + const cleanup = openDatabase(path, 'wal') + cleanup.prepare('DELETE FROM sessions WHERE id = ?').run(m.id) + cleanup.close() + + const second = await backend(path) + await second.ctx.sessionPersistence.create(m) + await second.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const after = (await second.ctx.sessionPersistence.listSnapshots())[0]?.revision + expect(after).not.toBe(before) + expect(String(before)).toMatch(/:revision:1$/) + expect(String(after)).toMatch(/:revision:1$/) + await second.dispose() + }) + it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(6) + expect(SCHEMA_VERSION).toBe(7) }) it('keeps the revision stable for an empty repair hook', async () => { diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 101f03eeca..dba2132f43 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -24,8 +24,8 @@ The database is disposable but reset is guarded: a recognized incompatible searc |---|---:|---| | `path` | required | Dedicated derived-index SQLite path; `:memory:` is supported. | | `journalMode` | `wal` | `wal`, `delete`, `truncate`, or `persist`. | -| `defaultLimit` | `20` | Page size when a request omits `limit`. | -| `maxLimit` | `100` | Largest accepted request page size. | +| `defaultLimit` | `20` | Page size when a request omits `limit`; at most `Number.MAX_SAFE_INTEGER - 1`. | +| `maxLimit` | `100` | Largest accepted request page size; at most `Number.MAX_SAFE_INTEGER - 1`. | | `snippetChars` | `240` | Maximum snippet length in Unicode code points. | ## Tokenizer and limits diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 1590a1d61c..4265405098 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -48,6 +48,7 @@ import { quoteFtsData, requestFingerprint, sanitizeFtsText, + SQLITE_MAX_PAGE_LIMIT, } from './query.ts' export { @@ -63,15 +64,19 @@ export const SESSION_QUERY_SQLITE_MAX_LIMIT = 100 /** Default maximum snippet length in Unicode code points. */ export const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240 +// A serialized search tolerates one transient source change; repeated churn +// fails instead of monopolizing the operation queue. +const STABLE_OBSERVATION_ATTEMPTS = 2 + /** SQLite session-search configuration. */ export interface Config { /** Dedicated derived-index path; `:memory:` is supported for tests. */ path: string /** SQLite journal mode. Defaults to `wal`. */ journalMode?: JournalMode - /** Page size when a request omits `limit`. Defaults to 20. */ + /** Page size when a request omits `limit`. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 20. */ defaultLimit?: number - /** Largest accepted page size. Defaults to 100. */ + /** Largest accepted page size. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 100. */ maxLimit?: number /** Maximum snippet length in Unicode code points. Defaults to 240. */ snippetChars?: number @@ -154,8 +159,8 @@ export class SessionSearchSqlite extends SessionSearchService { static Config: z = z.object({ path: z.string().required(), journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'), - defaultLimit: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_DEFAULT_LIMIT), - maxLimit: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_MAX_LIMIT), + defaultLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_DEFAULT_LIMIT), + maxLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_MAX_LIMIT), snippetChars: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_SNIPPET_CHARS), }) @@ -206,14 +211,14 @@ export class SessionSearchSqlite extends SessionSearchService { const signal = exec?.signal return this._serialized(signal, async () => { await this._ensureReady(signal) - await this._reconcile(signal) + const persistenceBinding = await this._reconcile(signal) assertNotAborted(signal) const generation = String(this._globalGeneration) const fingerprint = requestFingerprint(normalized) const offset = normalized.cursor === undefined ? 0 : decodeCursor(normalized.cursor, this._instance, 'sessions', fingerprint, generation) - const rows = this._querySessions(normalized, offset) + const rows = this._querySessions(normalized, offset, persistenceBinding) return page(rows, normalized.limit, row => this._sessionHit(row), cursorOffset => encodeCursor({ version: 1, instance: this._instance, @@ -233,14 +238,14 @@ export class SessionSearchSqlite extends SessionSearchService { const signal = exec?.signal return this._serialized(signal, async () => { await this._ensureReady(signal) - await this._reconcile(signal) + const persistenceBinding = await this._reconcile(signal) assertNotAborted(signal) - const generation = this._targetGeneration(normalized.sessionId) + const generation = this._targetGeneration(normalized.sessionId, persistenceBinding) const fingerprint = requestFingerprint(normalized) const offset = normalized.cursor === undefined ? 0 : decodeCursor(normalized.cursor, this._instance, 'events', fingerprint, generation) - const rows = this._queryEvents(normalized, offset) + const rows = this._queryEvents(normalized, offset, persistenceBinding) return page(rows, normalized.limit, row => this._eventHit(row), cursorOffset => encodeCursor({ version: 1, instance: this._instance, @@ -316,7 +321,7 @@ export class SessionSearchSqlite extends SessionSearchService { } } - private async _reconcile(signal: AbortSignal | undefined): Promise { + private async _reconcile(signal: AbortSignal | undefined): Promise { const db = this._requireDb() const persistedRows = db.prepare( 'SELECT id, revision, generation FROM persisted_sessions', @@ -392,13 +397,14 @@ export class SessionSearchSqlite extends SessionSearchService { if (pointerChanged) this._persistenceEpoch += 1 this._localGeneration = nextLocalGeneration this._lastPersistenceIdentity = observation.persistenceBinding.identity + return observation.persistenceBinding } private async _observeStable( indexed: ReadonlyMap, signal: AbortSignal | undefined, ): Promise { - for (;;) { + for (let attempt = 0; attempt < STABLE_OBSERVATION_ATTEMPTS; attempt += 1) { assertNotAborted(signal) const persistenceBinding = this._persistenceBinding const persistence = persistenceBinding.service @@ -446,6 +452,10 @@ export class SessionSearchSqlite extends SessionSearchService { return { persistenceBinding, persisted, live } } } + throw new SessionQueryError( + 'session-search persistence observation did not stabilize after one retry', + 'SESSION_QUERY_PERSISTENCE_FAILED', + ) } private _mainGeneration(): number { @@ -540,7 +550,11 @@ export class SessionSearchSqlite extends SessionSearchService { } } - private _querySessions(request: NormalizedSessionRequest, offset: number): SearchRow[] { + private _querySessions( + request: NormalizedSessionRequest, + offset: number, + persistenceBinding: PersistenceBinding, + ): SearchRow[] { const selected = selectedDocumentsSql() const sessionWhere = buildSessionWhere(request.sessionFilters) const eventWhere = buildEventWhere(request.eventFilters) @@ -562,7 +576,7 @@ export class SessionSearchSqlite extends SessionSearchService { ORDER BY match_count DESC, document_length ASC, time DESC, session_id ASC, seq DESC LIMIT ? OFFSET ? `).all( - ...selectedDocumentsParams(request.query, this._persistenceBinding.service !== undefined), + ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), ...sessionWhere.params, ...eventWhere.params, request.limit + 1, @@ -570,7 +584,11 @@ export class SessionSearchSqlite extends SessionSearchService { ) as unknown as SearchRow[] } - private _queryEvents(request: NormalizedEventRequest, offset: number): SearchRow[] { + private _queryEvents( + request: NormalizedEventRequest, + offset: number, + persistenceBinding: PersistenceBinding, + ): SearchRow[] { const selected = selectedDocumentsSql() const eventWhere = buildEventWhere(request.filters) const where = ['session_id = ?', eventWhere.sql].filter(Boolean).join(' AND ') @@ -581,7 +599,7 @@ export class SessionSearchSqlite extends SessionSearchService { ORDER BY match_count DESC, document_length ASC, time DESC, seq DESC LIMIT ? OFFSET ? `).all( - ...selectedDocumentsParams(request.query, this._persistenceBinding.service !== undefined), + ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), request.sessionId, ...eventWhere.params, request.limit + 1, @@ -589,13 +607,13 @@ export class SessionSearchSqlite extends SessionSearchService { ) as unknown as SearchRow[] } - private _targetGeneration(sessionId: SessionId): string { + private _targetGeneration(sessionId: SessionId, persistenceBinding: PersistenceBinding): string { const db = this._requireDb() const live = db.prepare( 'SELECT generation FROM temp.live_sessions WHERE id = ?', ).get(sessionId) as { generation: number } | undefined if (live !== undefined) return `live:${live.generation}` - if (this._persistenceBinding.service !== undefined) { + if (persistenceBinding.service !== undefined) { const persisted = db.prepare( 'SELECT generation FROM persisted_sessions WHERE id = ?', ).get(sessionId) as { generation: number } | undefined @@ -850,8 +868,8 @@ function resolveConfig(config: Config): ResolvedConfig { if (typeof resolved.path !== 'string' || resolved.path.trim().length === 0) { throw invalidConfig('path must not be blank') } - assertPositiveInteger('defaultLimit', resolved.defaultLimit) - assertPositiveInteger('maxLimit', resolved.maxLimit) + assertPageLimit('defaultLimit', resolved.defaultLimit) + assertPageLimit('maxLimit', resolved.maxLimit) assertPositiveInteger('snippetChars', resolved.snippetChars) if (resolved.defaultLimit > resolved.maxLimit) { throw invalidConfig('defaultLimit must be less than or equal to maxLimit') @@ -865,6 +883,12 @@ function assertPositiveInteger(name: string, value: number): void { if (!Number.isInteger(value) || value < 1) throw invalidConfig(`${name} must be a positive integer`) } +function assertPageLimit(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 1 || value > SQLITE_MAX_PAGE_LIMIT) { + throw invalidConfig(`${name} must be an integer between 1 and ${SQLITE_MAX_PAGE_LIMIT}`) + } +} + function invalidConfig(detail: string): SessionQueryError { return new SessionQueryError( `session-search SQLite config: ${detail}`, diff --git a/packages/session-query/session-query-sqlite/src/query.ts b/packages/session-query/session-query-sqlite/src/query.ts index 9654f6ae70..5eb3ed8380 100644 --- a/packages/session-query/session-query-sqlite/src/query.ts +++ b/packages/session-query/session-query-sqlite/src/query.ts @@ -20,6 +20,9 @@ export const FTS_HIGHLIGHT_START = '\uFDD0' /** Collision-free marker inserted after an FTS5 match by `highlight()`. */ export const FTS_HIGHLIGHT_END = '\uFDD1' +/** Largest page size whose internal lookahead remains an exact SQLite integer binding. */ +export const SQLITE_MAX_PAGE_LIMIT = Number.MAX_SAFE_INTEGER - 1 + /** Limit defaults needed to normalize a search request. */ export interface QueryLimits { /** Page size used when the request omits one. */ @@ -232,14 +235,17 @@ export function makeSnippet(markedText: string, maxChars: number): string { const characters = Array.from(clean) if (characters.length <= maxChars) return clean if (maxChars === 1) return '…' - let start = Math.max(0, matchStart - Math.floor(maxChars / 3)) - let prefix = start > 0 ? '…' : '' + const matchedIndex = Math.min(matchStart, characters.length - 1) + let start = Math.max(0, matchedIndex - Math.floor(maxChars / 3)) + const prefix = start > 0 ? '…' : '' let suffix = '…' let contentLength = maxChars - prefix.length - suffix.length if (contentLength < 1) { - start = 0 - prefix = '' - contentLength = maxChars - 1 + start = matchedIndex + suffix = '' + contentLength = maxChars - prefix.length - suffix.length + } else if (matchedIndex >= start + contentLength) { + start = matchedIndex - contentLength + 1 } let end = Math.min(characters.length, start + contentLength) if (end === characters.length) { @@ -326,9 +332,14 @@ function materializeMetadataFilters( function normalizeLimit(value: number | undefined, limits: QueryLimits): number { const limit = value ?? limits.defaultLimit - if (!Number.isInteger(limit) || limit < 1 || limit > limits.maxLimit) { + const maxLimit = Math.min(limits.maxLimit, SQLITE_MAX_PAGE_LIMIT) + if ( + !Number.isSafeInteger(limit) + || limit < 1 + || limit > maxLimit + ) { throw new SessionQueryError( - `session-search limit must be an integer between 1 and ${limits.maxLimit}`, + `session-search limit must be an integer between 1 and ${maxLimit}`, 'SESSION_QUERY_INVALID_LIMIT', ) } diff --git a/packages/session-query/session-query-sqlite/tests/query.spec.ts b/packages/session-query/session-query-sqlite/tests/query.spec.ts index f9c0a5d19e..14e84aae75 100644 --- a/packages/session-query/session-query-sqlite/tests/query.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/query.spec.ts @@ -11,6 +11,7 @@ import { normalizeSessionRequest, quoteFtsData, requestFingerprint, + SQLITE_MAX_PAGE_LIMIT, type NormalizedEventRequest, type NormalizedSessionRequest, } from '../src/query.ts' @@ -88,6 +89,12 @@ describe('SQLite search request normalization', () => { expect(() => normalizeEventRequest({ sessionId: SessionId('s'), query: 'x', limit }, limits)) .toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT')) } + expect(() => normalizeEventRequest({ + sessionId: SessionId('s'), + query: 'x', + limit: SQLITE_MAX_PAGE_LIMIT + 1, + }, { defaultLimit: 1, maxLimit: SQLITE_MAX_PAGE_LIMIT + 1 })) + .toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT')) }) it('materializes owned filter values during normalization', () => { @@ -214,7 +221,8 @@ describe('SQLite query identity and presentation', () => { expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 1)).toBe('…') expect(makeSnippet('abcdefghij', 5)).toBe('abcd…') expect(makeSnippet(`ab${FTS_HIGHLIGHT_START}c${FTS_HIGHLIGHT_END}defghij`, 5)).toBe('…bcd…') - expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 2)).toBe('a…') + expect(makeSnippet(`ab${FTS_HIGHLIGHT_START}c${FTS_HIGHLIGHT_END}defghij`, 3)).toBe('…c…') + expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 2)).toBe('…f') expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 5)).toBe('…cdef') expect(makeSnippet(` x—${FTS_HIGHLIGHT_START}café${FTS_HIGHLIGHT_END}\n y `, 20)) .toBe('x—café y') diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index b4a69acbbd..0895b6f08e 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -387,6 +387,8 @@ describe('SQLite session search', () => { { path: '' }, { path: ':memory:', defaultLimit: 0 }, { path: ':memory:', maxLimit: 0 }, + { path: ':memory:', defaultLimit: 1e100 }, + { path: ':memory:', maxLimit: 1e100 }, { path: ':memory:', snippetChars: 0 }, { path: ':memory:', defaultLimit: 3, maxLimit: 2 }, { path: ':memory:', journalMode: 'memory' }, @@ -462,6 +464,39 @@ describe('SQLite reconciliation and source lifecycle', () => { .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) }) + it('uses the reconciled persistence binding through the query boundary', async () => { + const durable = header('post-reconcile-unmount') + TestPersistence.reset([{ meta: durable, events: [ + ...messageEvents('durable needle', 1), + { ...messageEvents('durable needle again', 2)[0]!, seq: 1 }, + ] }]) + const ctx = await liveContext({ path: ':memory:', defaultLimit: 1, maxLimit: 2 }) + const persistence = await ctx.plugin(TestPersistence) + const internals = ctx.sessionSearch as unknown as { + _reconcile(signal: AbortSignal | undefined): Promise<{ + identity: symbol + service?: SessionPersistence + }> + } + const reconcile = internals._reconcile.bind(internals) + const boundary = vi.spyOn(internals, '_reconcile').mockImplementation(async (signal) => { + const binding = await reconcile(signal) + await persistence.dispose() + return binding + }) + + const page = await ctx.sessionSearch.searchEvents({ + sessionId: durable.id, + query: 'needle', + limit: 1, + }) + expect(page.items).toMatchObject([{ sessionId: durable.id }]) + expect(page.nextCursor).toEqual(expect.any(String)) + boundary.mockRestore() + await expect(ctx.sessionSearch.searchEvents({ sessionId: durable.id, query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + }) + it('discards a stale list rejection when persistence unmounts during observation', async () => { const durable = header('racing') TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) @@ -561,6 +596,22 @@ describe('SQLite reconciliation and source lifecycle', () => { expect(TestPersistence.loads.get(added.id)).toBe(1) }) + it('fails after one retry when persistence snapshots keep changing', async () => { + const durable = header('continuous-mutation') + TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + let lists = 0 + TestPersistence.snapshotEffect = () => { + lists += 1 + TestPersistence.set({ meta: durable, events: messageEvents(`durable needle ${lists}`) }) + } + + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + expect(lists).toBe(4) + }) + it('retries if the persistence binding changes while live sessions are observed', async () => { const durable = header('live-boundary-retry') TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) From 220076e5e2e74248f56bb8b4bf23de9b792f72bc Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 17 Jul 2026 10:15:48 +0800 Subject: [PATCH 011/207] fix(session-query): preserve typed query failures (round 5) --- .../session-query-sqlite/README.md | 2 +- .../session-query-sqlite/src/index.ts | 47 +++++++++++++------ .../session-query-sqlite/tests/sqlite.spec.ts | 46 ++++++++++++++++++ 3 files changed, 79 insertions(+), 16 deletions(-) diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index dba2132f43..1a5ee97e29 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -4,7 +4,7 @@ SQLite FTS5 implementation of `ctx.sessionSearch`. The service searches the live ## Search contract -`searchSessions(request, exec?)` returns `SessionSearchHit` pages across the corpus; `searchEvents(request, exec?)` returns `SessionEventSearchHit` pages within one session. Queries are required, trimmed, whitespace-normalized literal phrases. FTS5 syntax such as quotes, `OR`, `NEAR`, and `*` is treated as data rather than executable MATCH syntax. Metadata filters are parameterized SQL predicates applied before ranking. +`searchSessions(request, exec?)` returns `SessionSearchHit` pages across the corpus; `searchEvents(request, exec?)` returns `SessionEventSearchHit` pages within one session. Queries are required, trimmed, whitespace-normalized literal phrases. FTS5 syntax such as quotes, `OR`, `NEAR`, and `*` is treated as data rather than executable MATCH syntax. Metadata filters are parameterized SQL predicates applied before ranking. A request exceeding SQLite's portable limit of 32,766 total bindings, including fixed query and pagination values, fails with `SESSION_QUERY_INVALID_FILTER` before statement preparation. Relevance is source-comparable across persistent and TEMP tables: actual FTS5 highlighted-match span count descending, then stored document code-point length ascending. Event time, session id where applicable, and seq break remaining ties. Cross-session results expose the selected event as `bestMatch`; both scopes derive whitespace-normalized plain text from FTS5 highlight positions and bound it in Unicode code points. Cursors are opaque branded values, bind to the normalized request and service instance, and fail when the relevant generation changes. A within-session cursor survives unrelated-session changes; a cross-session cursor does not. diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 4265405098..4428d7ca00 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -559,6 +559,14 @@ export class SessionSearchSqlite extends SessionSearchService { const sessionWhere = buildSessionWhere(request.sessionFilters) const eventWhere = buildEventWhere(request.eventFilters) const where = [sessionWhere.sql, eventWhere.sql].filter(Boolean).join(' AND ') + const bindings = [ + ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), + ...sessionWhere.params, + ...eventWhere.params, + request.limit + 1, + offset, + ] + assertPortableBindingCount(bindings) return this._requireDb().prepare(` ${selected.sql}, filtered AS ( @@ -575,13 +583,7 @@ export class SessionSearchSqlite extends SessionSearchService { WHERE event_rank = 1 ORDER BY match_count DESC, document_length ASC, time DESC, session_id ASC, seq DESC LIMIT ? OFFSET ? - `).all( - ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), - ...sessionWhere.params, - ...eventWhere.params, - request.limit + 1, - offset, - ) as unknown as SearchRow[] + `).all(...bindings) as unknown as SearchRow[] } private _queryEvents( @@ -592,19 +594,21 @@ export class SessionSearchSqlite extends SessionSearchService { const selected = selectedDocumentsSql() const eventWhere = buildEventWhere(request.filters) const where = ['session_id = ?', eventWhere.sql].filter(Boolean).join(' AND ') + const bindings = [ + ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), + request.sessionId, + ...eventWhere.params, + request.limit + 1, + offset, + ] + assertPortableBindingCount(bindings) return this._requireDb().prepare(` ${selected.sql} SELECT * FROM matched WHERE ${where} ORDER BY match_count DESC, document_length ASC, time DESC, seq DESC LIMIT ? OFFSET ? - `).all( - ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), - request.sessionId, - ...eventWhere.params, - request.limit + 1, - offset, - ) as unknown as SearchRow[] + `).all(...bindings) as unknown as SearchRow[] } private _targetGeneration(sessionId: SessionId, persistenceBinding: PersistenceBinding): string { @@ -728,6 +732,19 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar ] } +// SQLite builds may raise this ceiling; supported modern versions share 32,766 +// as the portable host-parameter limit. +const SQLITE_PORTABLE_VARIABLE_LIMIT = 32_766 + +function assertPortableBindingCount(bindings: readonly (string | number)[]): void { + if (bindings.length > SQLITE_PORTABLE_VARIABLE_LIMIT) { + throw new SessionQueryError( + `session-search request requires ${bindings.length} SQLite bindings; reduce filters to stay within the portable ${SQLITE_PORTABLE_VARIABLE_LIMIT}-variable limit`, + 'SESSION_QUERY_INVALID_FILTER', + ) + } +} + function observeLive(session: Session): ObservedSession { return observeSession(session.header, session.events) } @@ -834,7 +851,7 @@ function decodeCursor( || decoded.instance !== instance || decoded.scope !== scope || decoded.fingerprint !== fingerprint - || !Number.isInteger(decoded.offset) + || !Number.isSafeInteger(decoded.offset) || decoded.offset === undefined || decoded.offset < 0 ) { diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 0895b6f08e..4b875931c4 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -53,6 +53,16 @@ function expectCode(code: SessionQueryErrorCode): Error { return expect.objectContaining({ code }) as Error } +function replaceCursorOffset( + cursor: ReturnType, + offset: number, +): ReturnType { + const payload = JSON.parse( + Buffer.from(cursor, 'base64url').toString('utf8'), + ) as Record + return SessionSearchCursor(Buffer.from(JSON.stringify({ ...payload, offset }), 'utf8').toString('base64url')) +} + class TestPersistence extends SessionPersistence { static entries = new Map() static revisions = new Map() @@ -276,6 +286,14 @@ describe('SQLite session search', () => { expect(sessionPage.nextCursor).toEqual(expect.any(String)) if (eventPage.nextCursor === undefined || sessionPage.nextCursor === undefined) throw new Error('expected cursors') + const unsafeOffsetCursor = replaceCursorOffset(eventPage.nextCursor, 1e100) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: target.id, + query: 'needle', + limit: 1, + cursor: unsafeOffsetCursor, + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR')) + const eventKeys = eventPage.items.map(item => `${item.sessionId}:${item.seq}`) let eventCursor: ReturnType | undefined = eventPage.nextCursor while (eventCursor !== undefined) { @@ -399,6 +417,34 @@ describe('SQLite session search', () => { .toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) } }) + + it('rejects aggregate filter bindings above SQLite\'s portable variable limit', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('binding-limit'), { seed: messageEvents('needle') }) + // Each clause is below the ceiling; combined with its sibling and fixed + // query bindings, the complete statement is not portable. + const halfPortableLimit = 16_383 + const ids = Array.from( + { length: halfPortableLimit }, + (_, index) => SessionId(`binding-${index}`), + ) + const types = Array.from({ length: halfPortableLimit }, () => 'user/message' as const) + const surfaces = Array.from({ length: halfPortableLimit }, () => 'current' as const) + + await expect(ctx.sessionSearch.searchSessions({ + query: 'needle', + sessionFilters: [{ kind: 'id', values: ids }], + eventFilters: [{ kind: 'type', values: types }], + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: session.id, + query: 'needle', + filters: [ + { kind: 'type', values: types }, + { kind: 'surface', values: surfaces }, + ], + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) }) describe('SQLite reconciliation and source lifecycle', () => { From f401528941e39c4b96957a9d910af5ced7ab08de Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 17 Jul 2026 10:29:49 +0800 Subject: [PATCH 012/207] fix(session-query): preflight SQLite bindings (round 6) --- .../session-query-sqlite/src/index.ts | 21 +++--------- .../session-query-sqlite/src/query.ts | 33 ++++++++++++++++--- .../session-query-sqlite/tests/sqlite.spec.ts | 13 ++++++++ 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 4428d7ca00..05b5edace4 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -40,6 +40,7 @@ import { type NormalizedSessionRequest, FTS_HIGHLIGHT_END, FTS_HIGHLIGHT_START, + assertPortableBindingCount, buildEventWhere, buildSessionWhere, makeSnippet, @@ -64,8 +65,7 @@ export const SESSION_QUERY_SQLITE_MAX_LIMIT = 100 /** Default maximum snippet length in Unicode code points. */ export const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240 -// A serialized search tolerates one transient source change; repeated churn -// fails instead of monopolizing the operation queue. +// One transient source change gets a retry; repeated churn fails rather than monopolizing the queue. const STABLE_OBSERVATION_ATTEMPTS = 2 /** SQLite session-search configuration. */ @@ -566,7 +566,7 @@ export class SessionSearchSqlite extends SessionSearchService { request.limit + 1, offset, ] - assertPortableBindingCount(bindings) + assertPortableBindingCount(bindings.length) return this._requireDb().prepare(` ${selected.sql}, filtered AS ( @@ -601,7 +601,7 @@ export class SessionSearchSqlite extends SessionSearchService { request.limit + 1, offset, ] - assertPortableBindingCount(bindings) + assertPortableBindingCount(bindings.length) return this._requireDb().prepare(` ${selected.sql} SELECT * FROM matched @@ -732,19 +732,6 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar ] } -// SQLite builds may raise this ceiling; supported modern versions share 32,766 -// as the portable host-parameter limit. -const SQLITE_PORTABLE_VARIABLE_LIMIT = 32_766 - -function assertPortableBindingCount(bindings: readonly (string | number)[]): void { - if (bindings.length > SQLITE_PORTABLE_VARIABLE_LIMIT) { - throw new SessionQueryError( - `session-search request requires ${bindings.length} SQLite bindings; reduce filters to stay within the portable ${SQLITE_PORTABLE_VARIABLE_LIMIT}-variable limit`, - 'SESSION_QUERY_INVALID_FILTER', - ) - } -} - function observeLive(session: Session): ObservedSession { return observeSession(session.header, session.events) } diff --git a/packages/session-query/session-query-sqlite/src/query.ts b/packages/session-query/session-query-sqlite/src/query.ts index 5eb3ed8380..409c20f028 100644 --- a/packages/session-query/session-query-sqlite/src/query.ts +++ b/packages/session-query/session-query-sqlite/src/query.ts @@ -23,6 +23,22 @@ export const FTS_HIGHLIGHT_END = '\uFDD1' /** Largest page size whose internal lookahead remains an exact SQLite integer binding. */ export const SQLITE_MAX_PAGE_LIMIT = Number.MAX_SAFE_INTEGER - 1 +/** Portable host-parameter ceiling shared by predicate and statement builders. */ +export const SQLITE_PORTABLE_VARIABLE_LIMIT = 32_766 + +/** + * Reject prospective SQLite binding growth beyond the portable ceiling. + * @param count - binding count at the current construction boundary. + */ +export function assertPortableBindingCount(count: number): void { + if (count > SQLITE_PORTABLE_VARIABLE_LIMIT) { + throw new SessionQueryError( + `session-search request exceeds SQLite's portable ${SQLITE_PORTABLE_VARIABLE_LIMIT}-variable limit; reduce filter values`, + 'SESSION_QUERY_INVALID_FILTER', + ) + } +} + /** Limit defaults needed to normalize a search request. */ export interface QueryLimits { /** Page size used when the request omits one. */ @@ -356,8 +372,7 @@ function addList( clauses.push('0') return } - clauses.push(`${column} IN (${values.map(() => '?').join(', ')})`) - params.push(...values) + clauses.push(`${column} IN (${appendListBindings(params, values)})`) } function addNullableList( @@ -373,8 +388,7 @@ function addNullableList( const concrete = values.filter((value): value is string => value !== null) const parts: string[] = [] if (concrete.length > 0) { - parts.push(`${column} IN (${concrete.map(() => '?').join(', ')})`) - params.push(...concrete) + parts.push(`${column} IN (${appendListBindings(params, concrete)})`) } if (values.includes(null)) parts.push(`${column} IS NULL`) clauses.push(`(${parts.join(' OR ')})`) @@ -387,15 +401,26 @@ function addRange( range: { from?: number; to?: number }, ): void { if (range.from !== undefined) { + assertPortableBindingCount(params.length + 1) clauses.push(`CAST(${column} AS INTEGER) >= ?`) params.push(range.from) } if (range.to !== undefined) { + assertPortableBindingCount(params.length + 1) clauses.push(`CAST(${column} AS INTEGER) <= ?`) params.push(range.to) } } +function appendListBindings( + params: Array, + values: readonly (string | number)[], +): string { + assertPortableBindingCount(params.length + values.length) + for (const value of values) params.push(value) + return values.map(() => '?').join(', ') +} + function canonicalFilters(filters: readonly (SessionResultFilter | SessionEventMetadataFilter)[]): unknown[] { return filters.map((filter) => { if ('values' in filter) { diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 4b875931c4..8db1df5666 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -445,6 +445,19 @@ describe('SQLite session search', () => { ], })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) }) + + it('rejects one 125,000-value filter list with a typed error', async () => { + const ctx = await liveContext() + const ids = Array.from( + { length: 125_000 }, + (_, index) => SessionId(`oversized-binding-${index}`), + ) + + await expect(ctx.sessionSearch.searchSessions({ + query: 'needle', + sessionFilters: [{ kind: 'id', values: ids }], + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) }) describe('SQLite reconciliation and source lifecycle', () => { From e8abfd6482b6d7050e161915de5cb98486e3e14a Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 17 Jul 2026 11:13:03 +0800 Subject: [PATCH 013/207] fix(session-query): guard FTS predicate planning (round 7) --- docs/config-catalog.md | 2 +- .../session-query-sqlite/README.md | 2 +- .../session-query-sqlite/src/index.ts | 3 + .../session-query-sqlite/src/query.ts | 24 +++++++- .../session-query-sqlite/tests/query.spec.ts | 42 ++++++++++++-- .../session-query-sqlite/tests/sqlite.spec.ts | 55 +++++++++++++++++++ 6 files changed, 119 insertions(+), 9 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6d141185ca..de1a316cc6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -666,7 +666,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-query/session-query-sqlite/src/index.ts:72`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:73`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-skill` diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 1a5ee97e29..1beb479d5e 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -4,7 +4,7 @@ SQLite FTS5 implementation of `ctx.sessionSearch`. The service searches the live ## Search contract -`searchSessions(request, exec?)` returns `SessionSearchHit` pages across the corpus; `searchEvents(request, exec?)` returns `SessionEventSearchHit` pages within one session. Queries are required, trimmed, whitespace-normalized literal phrases. FTS5 syntax such as quotes, `OR`, `NEAR`, and `*` is treated as data rather than executable MATCH syntax. Metadata filters are parameterized SQL predicates applied before ranking. A request exceeding SQLite's portable limit of 32,766 total bindings, including fixed query and pagination values, fails with `SESSION_QUERY_INVALID_FILTER` before statement preparation. +`searchSessions(request, exec?)` returns `SessionSearchHit` pages across the corpus; `searchEvents(request, exec?)` returns `SessionEventSearchHit` pages within one session. Queries are required, trimmed, whitespace-normalized literal phrases. FTS5 syntax such as quotes, `OR`, `NEAR`, and `*` is treated as data rather than executable MATCH syntax. Metadata filters are parameterized SQL predicates applied before ranking. To keep SQLite FTS5 MATCH in a supported outer-predicate context, cross-session requests may compile at most 14 combined session and event filter predicates; within-session requests may compile at most 13 filter predicates because the fixed target-session predicate consumes one slot. Each range endpoint compiles as one predicate. A request exceeding either predicate budget or SQLite's portable limit of 32,766 total bindings, including fixed query and pagination values, fails with `SESSION_QUERY_INVALID_FILTER` before statement preparation. Relevance is source-comparable across persistent and TEMP tables: actual FTS5 highlighted-match span count descending, then stored document code-point length ascending. Event time, session id where applicable, and seq break remaining ties. Cross-session results expose the selected event as `bestMatch`; both scopes derive whitespace-normalized plain text from FTS5 highlight positions and bound it in Unicode code points. Cursors are opaque branded values, bind to the normalized request and service instance, and fail when the relevant generation changes. A within-session cursor survives unrelated-session changes; a cross-session cursor does not. diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 05b5edace4..2bccb6f269 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -40,6 +40,7 @@ import { type NormalizedSessionRequest, FTS_HIGHLIGHT_END, FTS_HIGHLIGHT_START, + assertFts5OuterPredicateCount, assertPortableBindingCount, buildEventWhere, buildSessionWhere, @@ -558,6 +559,7 @@ export class SessionSearchSqlite extends SessionSearchService { const selected = selectedDocumentsSql() const sessionWhere = buildSessionWhere(request.sessionFilters) const eventWhere = buildEventWhere(request.eventFilters) + assertFts5OuterPredicateCount(sessionWhere.predicateCount + eventWhere.predicateCount) const where = [sessionWhere.sql, eventWhere.sql].filter(Boolean).join(' AND ') const bindings = [ ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), @@ -593,6 +595,7 @@ export class SessionSearchSqlite extends SessionSearchService { ): SearchRow[] { const selected = selectedDocumentsSql() const eventWhere = buildEventWhere(request.filters) + assertFts5OuterPredicateCount(1 + eventWhere.predicateCount) const where = ['session_id = ?', eventWhere.sql].filter(Boolean).join(' AND ') const bindings = [ ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), diff --git a/packages/session-query/session-query-sqlite/src/query.ts b/packages/session-query/session-query-sqlite/src/query.ts index 409c20f028..5a67653911 100644 --- a/packages/session-query/session-query-sqlite/src/query.ts +++ b/packages/session-query/session-query-sqlite/src/query.ts @@ -26,6 +26,9 @@ export const SQLITE_MAX_PAGE_LIMIT = Number.MAX_SAFE_INTEGER - 1 /** Portable host-parameter ceiling shared by predicate and statement builders. */ export const SQLITE_PORTABLE_VARIABLE_LIMIT = 32_766 +/** Supported outer-predicate budget that keeps SQLite FTS5 MATCH usable. */ +export const SQLITE_FTS5_OUTER_PREDICATE_LIMIT = 14 + /** * Reject prospective SQLite binding growth beyond the portable ceiling. * @param count - binding count at the current construction boundary. @@ -39,6 +42,19 @@ export function assertPortableBindingCount(count: number): void { } } +/** + * Reject compiled outer predicates beyond the supported FTS5 planner budget. + * @param count - predicate count including fixed statement predicates. + */ +export function assertFts5OuterPredicateCount(count: number): void { + if (count > SQLITE_FTS5_OUTER_PREDICATE_LIMIT) { + throw new SessionQueryError( + `session-search request exceeds the supported SQLite FTS5 outer-predicate budget of ${SQLITE_FTS5_OUTER_PREDICATE_LIMIT}; reduce filters`, + 'SESSION_QUERY_INVALID_FILTER', + ) + } +} + /** Limit defaults needed to normalize a search request. */ export interface QueryLimits { /** Page size used when the request omits one. */ @@ -71,6 +87,8 @@ export interface SqlWhere { sql: string /** Bindings in placeholder order. */ params: Array + /** Number of compiled predicates in `sql`. */ + predicateCount: number } /** @@ -163,7 +181,8 @@ export function buildSessionWhere(filters: readonly SessionResultFilter[]): SqlW unknownFilter(filter) } } - return { sql: clauses.join(' AND '), params } + assertFts5OuterPredicateCount(clauses.length) + return { sql: clauses.join(' AND '), params, predicateCount: clauses.length } } /** @@ -192,7 +211,8 @@ export function buildEventWhere(filters: readonly SessionEventMetadataFilter[]): unknownFilter(filter) } } - return { sql: clauses.join(' AND '), params } + assertFts5OuterPredicateCount(clauses.length) + return { sql: clauses.join(' AND '), params, predicateCount: clauses.length } } /** diff --git a/packages/session-query/session-query-sqlite/tests/query.spec.ts b/packages/session-query/session-query-sqlite/tests/query.spec.ts index 14e84aae75..b40489d429 100644 --- a/packages/session-query/session-query-sqlite/tests/query.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/query.spec.ts @@ -11,6 +11,7 @@ import { normalizeSessionRequest, quoteFtsData, requestFingerprint, + SQLITE_FTS5_OUTER_PREDICATE_LIMIT, SQLITE_MAX_PAGE_LIMIT, type NormalizedEventRequest, type NormalizedSessionRequest, @@ -111,24 +112,36 @@ describe('SQLite search request normalization', () => { describe('SQLite search predicate compilation', () => { it('compiles all logical-session clauses including empty and nullable values', () => { - expect(buildSessionWhere([])).toEqual({ sql: '', params: [] }) - expect(buildSessionWhere([{ kind: 'id', values: [] }])).toEqual({ sql: '0', params: [] }) + expect(buildSessionWhere([])).toEqual({ sql: '', params: [], predicateCount: 0 }) + expect(buildSessionWhere([{ kind: 'id', values: [] }])).toEqual({ + sql: '0', + params: [], + predicateCount: 1, + }) expect(buildSessionWhere([{ kind: 'id', values: [SessionId('a'), SessionId('b')] }])).toEqual({ sql: 'session_id IN (?, ?)', params: [SessionId('a'), SessionId('b')], + predicateCount: 1, + }) + expect(buildSessionWhere([{ kind: 'cwd', values: [] }])).toEqual({ + sql: '0', + params: [], + predicateCount: 1, }) - expect(buildSessionWhere([{ kind: 'cwd', values: [] }])).toEqual({ sql: '0', params: [] }) expect(buildSessionWhere([{ kind: 'cwd', values: [null] }])).toEqual({ sql: '(cwd IS NULL)', params: [], + predicateCount: 1, }) expect(buildSessionWhere([{ kind: 'cwd', values: ['/a'] }])).toEqual({ sql: '(cwd IN (?))', params: ['/a'], + predicateCount: 1, }) expect(buildSessionWhere([{ kind: 'parent', values: [SessionId('p'), null] }])).toEqual({ sql: '(parent_session IN (?) OR parent_session IS NULL)', params: [SessionId('p')], + predicateCount: 1, }) expect(buildSessionWhere([ { kind: 'created-at', from: 1, to: 2 }, @@ -138,8 +151,13 @@ describe('SQLite search predicate compilation', () => { ])).toEqual({ sql: 'CAST(created_at AS INTEGER) >= ? AND CAST(created_at AS INTEGER) <= ? AND 0 AND live = 1', params: [1, 2], + predicateCount: 4, + }) + expect(buildSessionWhere([{ kind: 'created-at' }])).toEqual({ + sql: '', + params: [], + predicateCount: 0, }) - expect(buildSessionWhere([{ kind: 'created-at' }])).toEqual({ sql: '', params: [] }) }) it('compiles every event clause and empty lists', () => { @@ -151,11 +169,25 @@ describe('SQLite search predicate compilation', () => { ])).toEqual({ sql: 'CAST(seq AS INTEGER) >= ? AND CAST(time AS INTEGER) <= ? AND type IN (?) AND surface IN (?, ?)', params: [1, 9, 'user/message', 'current', 'log-only'], + predicateCount: 4, }) expect(buildEventWhere([ { kind: 'type', values: [] }, { kind: 'surface', values: [] }, - ])).toEqual({ sql: '0 AND 0', params: [] }) + ])).toEqual({ sql: '0 AND 0', params: [], predicateCount: 2 }) + }) + + it('rejects predicate builders above the supported FTS5 outer budget', () => { + const filters = Array.from( + { length: SQLITE_FTS5_OUTER_PREDICATE_LIMIT }, + () => ({ kind: 'id' as const, values: [SessionId('safe')] }), + ) + + expect(buildSessionWhere(filters).predicateCount).toBe(SQLITE_FTS5_OUTER_PREDICATE_LIMIT) + expect(() => buildSessionWhere([ + ...filters, + { kind: 'id', values: [SessionId('over')] }, + ])).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) }) it('rejects runtime-unknown filter discriminants in both SQL builders', () => { diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 8db1df5666..043bf3cf6b 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -210,6 +210,61 @@ describe('SQLite session search', () => { }) }) + it('searches at the supported FTS5 outer-predicate boundary in both scopes', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('predicate-boundary'), { + seed: messageEvents('needle'), + meta: { cwd: '/work' }, + }) + const sessionFilters = Array.from( + { length: 14 }, + () => ({ kind: 'cwd' as const, values: ['/work', null] }), + ) + const eventFilters = Array.from( + { length: 13 }, + () => ({ kind: 'type' as const, values: ['user/message' as const] }), + ) + + await expect(ctx.sessionSearch.searchSessions({ query: 'needle', sessionFilters })) + .resolves.toMatchObject({ items: [{ header: { id: session.id } }] }) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: session.id, + query: 'needle', + filters: eventFilters, + })).resolves.toMatchObject({ items: [{ sessionId: session.id, seq: 0 }] }) + }) + + it('rejects unsupported FTS5 outer-predicate counts with typed errors', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('predicate-limit'), { seed: messageEvents('needle') }) + const sessionFilters = Array.from( + { length: 1_100 }, + () => ({ kind: 'id' as const, values: [session.id] }), + ) + const eventFilters = Array.from( + { length: 1_100 }, + () => ({ kind: 'type' as const, values: ['user/message' as const] }), + ) + + await expect(ctx.sessionSearch.searchSessions({ query: 'needle', sessionFilters })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: session.id, + query: 'needle', + filters: eventFilters, + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchSessions({ + query: 'needle', + sessionFilters: sessionFilters.slice(0, 7), + eventFilters: eventFilters.slice(0, 8), + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: session.id, + query: 'needle', + filters: eventFilters.slice(0, 14), + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) + it('uses literal phrase tokens, stable ties, and bounded Unicode snippets', async () => { const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 10, snippetChars: 5 }) ctx.sessions.create(SessionId('a'), { seed: messageEvents('😀😀 alpha beta BRAID 😀😀', 10), meta: { createdAt: 1 } }) From 73e3f658c609a5b43e2acf292315a0260d87b36a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:40:10 +0800 Subject: [PATCH 014/207] fix(persistence): bind JSONL identity before mutation JSONL discovered a log by the requested session id but later routed repair and append from the parsed header. A log selected for session A could therefore declare session B and redirect mutation to B. Validate the requested id and exact header-derived cwd-bucket path before returning a stored prefix, reject duplicate ids across buckets, and repeat the id/cwd guards in the coordinator before repair or state publication. Collapse the redundant loadLive hook into loadStored while retaining the existing bucket layout and one-live-writer topology, avoiding flat-layout churn and a locator generic that SQLite and test backends do not need. --- ...18-shared-persistence-write-coordinator.md | 9 +-- ...026-07-20-jsonl-storage-identity.i18n.yaml | 6 ++ .../2026-07-20-jsonl-storage-identity.md | 29 +++++++ .../2026-07-20-jsonl-storage-identity.zh.md | 29 +++++++ .../session-persistence-jsonl/README.md | 7 +- .../session-persistence-jsonl/src/index.ts | 71 ++++++++++-------- .../tests/jsonl.spec.ts | 75 ++++++++++++++++--- .../session-persistence-sqlite/src/index.ts | 5 -- .../session-persistence/README.md | 5 +- .../session-persistence/src/coordinator.ts | 59 ++++++++------- .../tests/persistence.spec.ts | 48 +++++++++--- 11 files changed, 245 insertions(+), 98 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 7c73cf24a4..8f26aea91b 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -16,11 +16,10 @@ The coordinator retires each live session from its `session/disposed` notificati ### The hook interface (`PersistenceBackend`) -Six methods (five required + an optional lifecycle hook) — the only seam between the coordinator and storage: +Five required members plus an optional lifecycle hook form the only boundary between the coordinator and storage: - `name` — backend label for the dispose-failure `AggregateError`. -- `loadStored(id)` — read a stored prefix by id, scanning ANY storage scope (every JSONL cwd bucket; SQLite's id is globally unique). Used by resume/load and, via `!== undefined`, the create-collision probe. -- `loadLive(id, cwd)` — read a stored prefix SCOPED to `cwd`. **Deliberately distinct from `loadStored`**: HMR live-adoption must only adopt a persisted log at the SAME cwd as the live session; a same-id log at a different cwd is a collision, not a resume. Collapsing the two reintroduces a cross-cwd adoption bug. SQLite ignores `cwd`. +- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL cwd bucket; SQLite's id is globally unique). Resume/load, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication. - `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook). - `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`). - `list()` — list all stored metadata. @@ -37,8 +36,8 @@ The shared `runPersistenceContract` (public-API contract) keeps running for ever ## Alternatives considered - **A base class the backends extend** — rejected for composition: a backend exposes only the hooks, cannot reach the coordinator's private orchestration state, and a third-party backend may still implement the abstract service directly without the coordinator at all. -- **A wider hook surface** — each candidate hook folded away: there is no separate `materialize` hook (the materialize-write must commit atomically with the first event batch inside `appendBatch`), no separate create-collision probe (it is `loadStored(id) !== undefined`), and no coordinator pass-through for `list()` (listing needs none of the orchestration). +- **A wider hook surface** — each candidate hook folds away: there is no scope-specific live lookup because `loadStored` plus the coordinator's cwd check preserves the collision boundary, no storage-locator generic because validated JSONL metadata reproduces its path while SQLite is already id-bound, no separate `materialize` hook because the first batch must commit atomically with materialization, no separate create-collision probe because it is `loadStored(id) !== undefined`, and no coordinator pass-through for `list()` because listing needs none of the orchestration. ## Consequences -The coordinator adds one indirection, an opaque torn marker, and detached session-retirement tasks, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves uncommitted buffers, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: collision checks reuse `loadStored`, materialization stays atomic inside `appendBatch`, and listing bypasses the coordinator. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle. +The coordinator adds one indirection, an opaque torn marker, and detached session-retirement tasks, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves uncommitted buffers, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, and collision checks reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml new file mode 100644 index 0000000000..2feb8bdf82 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-20-jsonl-storage-identity.md: c22377834244e5749993952a5fe8018b89130c1c +2026-07-20-jsonl-storage-identity.zh.md: 8b9a291772ba7e079c06e0d9e3ab9f285ac1ad7e diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md new file mode 100644 index 0000000000..c223778342 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md @@ -0,0 +1,29 @@ +# Agent Note: Bind JSONL session identity before mutation + +Status: implemented + +English | [中文](2026-07-20-jsonl-storage-identity.zh.md) + +## Problem + +JSONL lookup selects a physical log from the requested session id across cwd buckets, while the parsed `SessionHeader` supplies the metadata used by later repair and append operations. Without binding those two facts, a log selected for session A can declare session B's id or cwd and redirect a repair or later append to B's path. The bucket scan also needs a defined result when the same encoded id exists in more than one bucket. SQLite does not share this ambiguity because its primary-key query binds metadata and events to the requested id. + +## Decision + +`loadStored(id)` is the coordinator's single stored-prefix lookup. The JSONL backend scans every cwd bucket, requires at most one matching encoded filename, parses that file, then validates both `header.id === id` and `selectedPath === logPath(root, header.cwd, header.id)` before returning metadata. `list()` applies the same path validation and rejects duplicate ids across buckets. + +The coordinator independently asserts the returned id and compares the stored cwd with a live session's cwd before repair, state publication, or suffix persistence. It keeps a detached copy of validated metadata; JSONL append and repair derive their path from that copy. The `PersistenceBackend` interface therefore needs neither a scope-specific live lookup nor a storage-locator type. + +The backend supports one live writer per session; another backend instance or process must not mutate that session until the owner finishes disposal and all writes stop. + +## Alternatives considered + +**Flatten storage by session id.** A flat namespace makes duplicate publication collide on one path, but path validation and duplicate rejection close the identity defect without changing the project-grouped cwd layout or its consumers. + +**Carry an opaque storage locator through the coordinator.** A locator binds JSONL mutations directly to a selected path, but JSONL can reproduce that path from metadata it has already validated. Adding another generic and argument to SQLite, test backends, append, and repair makes every implementation carry a concept only the file backend needs. + +**Coordinate multiple live writers.** A dedicated coordination service, process-global registry, or cross-process lock would define a new deployment topology rather than repair identity validation. The supported topology has one live writer; no-overwrite hard-link publication still arbitrates an initial same-id creation race. + +## Consequences + +Mismatched, misplaced, and duplicate JSONL logs fail before repair or coordinator state mutation. The cwd-bucket format stays unchanged and needs no migration. Lookup remains proportional to the number of buckets, and one-live-writer ownership remains an explicit limitation. Coordinator and JSONL tests pin rejection before repair, unchanged bytes for both affected logs, path validation during listing, duplicate-id rejection, and cwd collision handling. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md new file mode 100644 index 0000000000..8b9a291772 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 在变更前绑定 JSONL 会话身份 + +Status: implemented + +[English](2026-07-20-jsonl-storage-identity.md) | 中文 + +## 问题 + +JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物理日志,而解析得到的 `SessionHeader` 会提供后续修复和追加操作使用的元数据。如果这两个事实没有绑定,为会话 A 选中的日志就能声明会话 B 的 id 或 cwd,并将修复或后续追加重定向到 B 的路径。当同一个编码后 id 出现在多个分桶目录中时,分桶扫描也必须给出确定的结果。SQLite 不存在这种歧义,因为主键查询会将元数据和事件绑定到请求的 id。 + +## 决策 + +`loadStored(id)` 是协调器唯一的已存前缀查找操作。JSONL 后端扫描所有 cwd 分桶目录,要求匹配编码文件名的日志至多有一个,解析该文件,然后在返回元数据前同时验证 `header.id === id` 和 `selectedPath === logPath(root, header.cwd, header.id)`。`list()` 执行相同的路径验证,并拒绝跨分桶目录重复的 id。 + +协调器会独立断言返回的 id,并在修复、发布状态或持久化后缀之前比较已存 cwd 和活动会话的 cwd。协调器保留一份已验证元数据的独立副本;JSONL 的追加和修复操作根据该副本派生路径。因此,`PersistenceBackend` 接口既不需要限定范围的活动会话查找,也不需要存储定位器类型。 + +后端对每个会话只支持一个活动写入方;在所有者完成资源释放且所有写入停止之前,另一个后端实例或进程不得变更该会话。 + +## 考虑过的替代方案 + +**按会话 id 扁平化存储。** 扁平命名空间会让重复发布在同一路径上冲突,但路径验证和重复项拒绝无需改变按项目分组的 cwd 布局及其消费方,也能消除身份缺陷。 + +**通过协调器传递不透明存储定位器。** 定位器可以将 JSONL 变更直接绑定到选定路径,但 JSONL 可以根据已经验证的元数据重新得到该路径。为 SQLite、测试后端、追加和修复操作增加一个泛型和参数,会让每个实现都承担只有文件后端需要的概念。 + +**协调多个活动写入方。** 专用协调服务、进程级全局注册表或跨进程锁会定义新的部署拓扑,而不是修复身份验证。受支持的拓扑只有一个活动写入方;禁止覆盖的硬链接发布仍会裁决初始的同 id 创建竞争。 + +## 后果 + +JSONL 日志的身份不匹配、位置错误和重复会在修复或协调器状态变更前失败。cwd 分桶格式保持不变,无需迁移。查找开销仍与分桶目录数量成正比,单一活动写入方的所有权仍是明确限制。协调器和 JSONL 测试固定了修复前拒绝、两个受影响日志的字节均保持不变、列出时的路径验证、重复 id 拒绝以及 cwd 冲突处理。 diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index cf733b85d4..a97fbfd206 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -11,7 +11,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ``` - The first `.jsonl` line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength? }`; every subsequent line is one `SessionEvent` JSON, **verbatim including `assistant/chunk`** so `seq` stays contiguous (`events[i].seq === i`). -- Session ids are unvalidated branded strings, so they are percent-encoded to a single safe path segment before use (no traversal, no collision). +- Session ids are unvalidated branded strings, so they are injectively encoded as one safe path segment before use (no traversal, no collision). ## Config @@ -23,6 +23,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ## Durability and crash semantics +- **Bound storage identity.** Lookup requires one matching encoded filename across the cwd buckets, then verifies that the header id equals the requested id and that the header's id/cwd derive the selected path. Listing applies the same path check and rejects duplicate ids. Identity failures occur before repair or append. - **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory when the host supports it. A created-but-never-appended session leaves nothing on disk and is absent from `list`. - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. - **Crash recovery — preserve valid tail work.** `load` keeps the contiguous valid prefix of an interrupted final turn. It truncates from the first unparsable or sequence-gapped uncommitted record, then appends the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md); the same defect at or before the last committed `turn/end` rejects. @@ -30,7 +31,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ## Write path -The plugin buffers frozen session events and drains them on flush or disposal. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. Operations for one session are serialized; disposal waits for initialization and the final drain so no write lands after teardown. +The plugin buffers frozen session events and drains them on flush or disposal. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. The owning backend instance serializes operations for one session; disposal waits for initialization and the final drain so no write lands after teardown. ## Model Experience @@ -52,6 +53,6 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr - **Only the current `SESSION_FORMAT_VERSION` (v0) loads** — the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 and non-current logs are rejected; there is no migration. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). -- **Single-process assumption** — per-session serialization and the write cursor live in this process; two processes appending to the same `root` are not coordinated. +- **One live writer per session** — append and repair are coordinated only inside the owning backend instance. Another backend instance or process must not write the same session until that owner reaches quiescent disposal; initial same-id publication remains collision-safe through the no-overwrite hard link. - **Initial materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; a filesystem that cannot create hard links cannot host this backend. - **Windows cannot `fsync` directory handles through Node** — the backend tolerates only Windows `EPERM` from directory `fsync`; file-content `fsync` remains mandatory, but a crash can lose a newly published directory entry on a host without an equivalent directory-sync primitive. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 4e52cb0b9e..84db4ac541 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -9,7 +9,7 @@ import { Context } from 'cordis' import z from 'schemastery' import { open, mkdir, readFile, readdir, link, rm, truncate } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { SessionPersistence, PersistenceCoordinator, @@ -97,28 +97,19 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /** Read a stored prefix by id across all cwd buckets when cwd is unknown. */ async loadStored(id: SessionId): Promise | undefined> { - const file = await this.findLog(id) - if (file === undefined) return undefined - return this.readPrefix(file.path) - } - - /** - * Read a stored prefix within one cwd for HMR adoption. `undefined` names the - * no-cwd bucket rather than an unknown cwd, so this never scans other buckets. - */ - async loadLive(id: SessionId, cwd: string | undefined): Promise | undefined> { - const path = logPath(this.root, cwd, id) - if (!await this.exists(path)) return undefined - return this.readPrefix(path) + const path = await this.findLog(id) + if (path === undefined) return undefined + return this.readPrefix(path, id) } /** * Read a stored prefix and convert torn-tail state to the byte offset the * coordinator can round-trip without knowing the file format. */ - private async readPrefix(path: string): Promise> { + private async readPrefix(path: string, expectedId: SessionId): Promise> { const buffer = await readFile(path) const { meta, events, committedBytes } = scanLog(buffer) + this.assertStoredIdentity(path, meta, expectedId) return { meta, events, @@ -145,16 +136,23 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi if (closers.length > 0) await this.appendLines(meta, closers) } - /** List all stored sessions' metadata (header line only — no full-log parse). */ + /** List valid unique stored sessions' metadata (header line only — no full-log parse). */ async list(): Promise { const metas: SessionHeader[] = [] + const ids = new Set() for (const dir of await this.listCwdDirs()) { for (const name of await this.listJsonl(dir)) { + const path = join(dir, name) // Read only headers so listing scales with session count, not log size. - const first = await this.readFirstLine(`${dir}/${name}`) + const first = await this.readFirstLine(path) if (first === undefined) continue // empty/half-written file const meta = parseHeaderMeta(first) if (meta === undefined) continue // not a session header + this.assertStoredIdentity(path, meta) + if (ids.has(meta.id)) { + throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple cwd buckets`) + } + ids.add(meta.id) metas.push(meta) } } @@ -292,28 +290,41 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - /** - * Find a session by id across cwd buckets for resume. Cwd-scoped HMR adoption - * bypasses this scan so a no-cwd session cannot claim another bucket. - */ - private async findLog(id: SessionId): Promise<{ path: string; cwd: string | undefined } | undefined> { + /** Find the unique physical log for an id across every cwd bucket. */ + private async findLog(id: SessionId): Promise { const target = encodeSegment(id) + '.jsonl' + const matches: string[] = [] for (const dir of await this.listCwdDirs()) { - const path = `${dir}/${target}` - if (await this.exists(path)) { - // Recover the cwd from the header so the caller has the session's bucket. - const { meta } = scanLog(await readFile(path)) - return { path, cwd: meta.cwd } - } + const path = join(dir, target) + if (await this.exists(path)) matches.push(path) + } + if (matches.length > 1) { + throw new Error(`duplicate JSONL session id "${id}" appears in multiple cwd buckets`) + } + return matches[0] + } + + /** Reject metadata that does not identify the selected physical log. */ + private assertStoredIdentity(path: string, meta: SessionHeader, expectedId?: SessionId): void { + if (expectedId !== undefined && meta.id !== expectedId) { + throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${meta.id}"`) + } + let expectedPath: string + try { + expectedPath = logPath(this.root, meta.cwd, meta.id) + } catch (error) { + throw new Error(`corrupt session log "${path}": header id cannot name a storage path`, { cause: error }) + } + if (path !== expectedPath) { + throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd belong at "${expectedPath}"`) } - return undefined } /** The cwd-bucket directories under the root (absolute paths). */ private async listCwdDirs(): Promise { try { const entries = await readdir(this.root, { withFileTypes: true }) - return entries.filter(e => e.isDirectory()).map(e => `${this.root}/${e.name}`) + return entries.filter(e => e.isDirectory()).map(e => join(this.root, e.name)) } catch (error) { // Only an absent root means no sessions; rethrow every other I/O failure. if (isENOENT(error)) return [] diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index e7dc469132..ab62bce938 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -21,6 +21,15 @@ function mutableHeader(header: SessionHeader): MutableSessionHeader { return header } +/** Rewrite only a stored header while preserving every event byte below it. */ +async function rewriteHeader(path: string, update: (header: Record) => void): Promise { + const lines = (await readFile(path, 'utf8')).split('\n') + const header = JSON.parse(lines[0] as string) as Record + update(header) + lines[0] = JSON.stringify(header) + await writeFile(path, lines.join('\n')) +} + async function expectParallelFlushError(promise: Promise, message: RegExp): Promise { try { await promise @@ -393,6 +402,31 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) }) + it('rejects a mismatched header before repairing either session log', async () => { + const a = meta('identity-a', '/same') + const b = meta('identity-b', '/same') + await ctx.sessionPersistence.create(a) + await ctx.sessionPersistence.append(a.id, [{ + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }]) + await ctx.sessionPersistence.create(b) + await ctx.sessionPersistence.append(b.id, oneTurnLog()) + + const aPath = logPath(root, a.cwd, a.id) + const bPath = logPath(root, b.cwd, b.id) + await rewriteHeader(aPath, (header) => { header.id = b.id }) + const beforeA = await readFile(aPath) + const beforeB = await readFile(bPath) + + await expect(ctx.sessionPersistence.load(a.id)) + .rejects.toThrow(/requested id "identity-a" does not match header id "identity-b"/) + expect(await readFile(aPath)).toEqual(beforeA) + expect(await readFile(bPath)).toEqual(beforeB) + }) + it('rejects a re-append of an already-stored seq', async () => { const m = meta('reappend') await ctx.sessionPersistence.create(m) @@ -599,6 +633,28 @@ describe('SessionPersistenceJsonl: edge cases', () => { expect(ids).toContain('big') }) + it('list rejects a header whose cwd does not identify its physical log', async () => { + const m = meta('misplaced', '/stored') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await rewriteHeader(logPath(root, m.cwd, m.id), (header) => { header.cwd = '/elsewhere' }) + + await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd belong at/) + }) + + it('load and list reject one id materialized in multiple cwd buckets', async () => { + const id = SessionId('duplicate') + for (const cwd of ['/a', '/b']) { + const m = meta(id, cwd) + await mkdir(sessionDir(root, cwd), { recursive: true }) + const content = [JSON.stringify(toHeaderLine(m)), ...oneTurnLog().map(event => JSON.stringify(event))].join('\n') + '\n' + await writeFile(logPath(root, cwd, id), content) + } + + await expect(ctx.sessionPersistence.load(id)).rejects.toThrow(/appears in multiple cwd buckets/) + await expect(ctx.sessionPersistence.list()).rejects.toThrow(/appears in multiple cwd buckets/) + }) + it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => { // Session A materializes a log under id "reuse". const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => { @@ -619,18 +675,16 @@ describe('SessionPersistenceJsonl: edge cases', () => { await expect(ctx.sessions.flush(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/) }) - it('a NO-CWD live session does NOT cross-cwd-adopt a same-id log from a real cwd bucket (loadLive is scope-exact)', async () => { + it('a no-cwd live session cannot adopt a same-id log from another cwd', async () => { // Backend 1: materialize a log under id "x" in the cwd "/w" bucket, then // dispose the WHOLE backend (so backend 2 mounts with an EMPTY states map — - // the HMR/reload path where onCreated goes through loadLive, not a tracked - // collision). + // the HMR/reload path with no tracked collision state). await ctx.sessionPersistence.create(meta('x', '/w')) await ctx.sessionPersistence.append(SessionId('x'), oneTurnLog()) await ctx.fiber.dispose() - // Backend 2 creates a no-cwd session whose id exists only in `/w`. Exact `loadLive(id, - // undefined)` must not adopt across buckets; the any-cwd collision check then rejects instead - // of grafting no-cwd events onto a log with mismatched cwd. + // Backend 2 creates a no-cwd session whose id exists only in `/w`. The + // stored cwd check rejects instead of grafting no-cwd events onto that log. const ctx2 = new Context() await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root }) @@ -638,7 +692,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.plugin(Object.assign((inner: Context) => { b = inner.sessions.create(SessionId('x')) // no cwd }, { inject: ['sessions'] })) - await expect(ctx2.sessions.flush(b)).rejects.toThrow(/already has a persisted log on disk/) + await expect(ctx2.sessions.flush(b)).rejects.toThrow(/different cwd|id collision/) // The "/w" log is untouched — no no-cwd events were grafted onto it, and no // `_no-cwd` log for "x" was created. @@ -706,9 +760,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.fiber.dispose() }) - it('loadLive surfaces a non-ENOENT lookup error (ENOTDIR) instead of reporting absent', async () => { - // A non-ENOENT per-id open error must surface rather than become "not found" and permit false - // live adoption. Making the cwd bucket a regular file forces ENOTDIR for its child log path. + it('materialization surfaces a cwd-bucket storage fault', async () => { const cwd = '/x' const ctx2 = new Context() await ctx2.plugin(SessionStore) @@ -717,8 +769,9 @@ describe('SessionPersistenceJsonl: edge cases', () => { let s!: Session await ctx2.plugin(Object.assign((inner: Context) => { s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } }) + appendClosedTurn(s) }, { inject: ['sessions'] })) - await expect(ctx2.sessions.flush(s)).rejects.toThrow(/ENOTDIR/) + await expect(ctx2.sessions.flush(s)).rejects.toThrow(/EEXIST|ENOTDIR/) await ctx2.fiber.dispose() }) diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 4661b41309..c7d7770642 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -144,11 +144,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.readPrefix(id) } - /** Read a stored prefix; `cwd` is ignored (the id is globally unique in SQLite). */ - loadLive(id: SessionId, _cwd: string | undefined): Promise | undefined> { - return this.readPrefix(id) - } - /** * Read a session's row + ordered events into a {@link StoredPrefix}. The * torn-tail marker is the seq from which a never-committed tail must be deleted diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index ca04cd1ab8..7ed713a3d6 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -34,14 +34,13 @@ The `PersistenceBackend` hooks (the only seam between the coordinato | Hook | Role | |---|---| | `name` | Backend label for the dispose-failure `AggregateError`. | -| `loadStored(id)` | Read a stored prefix by id, scanning ANY storage scope. Used by resume/load and, via `!== undefined`, the create-collision probe. Returns an opaque `tornMarker` iff a torn tail must be truncated. | -| `loadLive(id, cwd)` | Read a stored prefix SCOPED to `cwd` (HMR live-adoption must only adopt a log at the SAME cwd; a same-id log elsewhere is a collision, not a resume). A globally-unique-id backend ignores `cwd`. | +| `loadStored(id)` | Read a stored prefix by id across every storage scope. Used by resume/load, live adoption, and the create-collision probe. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. | | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | | `list()` | List all stored metadata. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | -The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). The public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). +The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). The public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). ## Testing backends diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index beb5fca483..11e003147c 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -35,22 +35,14 @@ export interface PersistenceBackend { readonly name: string /** - * Read a stored prefix by id, scanning ANY storage scope (for JSONL: every - * cwd bucket). Returns `undefined` if no stored artifact exists. Used by - * resume/load, and — via `!== undefined` — by the create-collision probe. - * The returned `tornMarker` is present iff there is a torn tail to truncate. + * Read a stored prefix by id, scanning every backend storage scope. Returns + * `undefined` if no stored artifact exists. Returned metadata must identify + * `id` before repair or state publication. Used by resume/load, live adoption, + * and — via `!== undefined` — the create-collision probe. The returned + * `tornMarker` is present iff there is a torn tail to truncate. */ loadStored(id: SessionId): Promise | undefined> - /** - * Read a stored prefix SCOPED to `cwd`. Deliberately distinct from - * {@link loadStored}: HMR live-adoption must only adopt a persisted log at the - * SAME cwd as the live session (a same-id log at a different cwd is a - * collision, not a resume) — conflating the two reintroduces a cross-cwd - * adoption bug. For a globally-unique-id backend (SQLite) `cwd` is ignored. - */ - loadLive(id: SessionId, cwd: string | undefined): Promise | undefined> - /** * Durably append a CONTIGUOUS batch, lazily materializing the session first * when `!isMaterialized`. The materialize-write and the first event batch MUST @@ -259,6 +251,7 @@ export class PersistenceCoordinator { const stored = await this.backend.loadStored(id) if (stored === undefined) throw new Error(`session "${id}" not found`) const { meta, events, tornMarker } = stored + this.assertStoredId(id, meta) this.assertVersion(meta) assertSupportedEvents(events, id) @@ -317,6 +310,13 @@ export class PersistenceCoordinator { } } + /** Reject backend metadata that is not bound to the requested session id. */ + private assertStoredId(id: SessionId, meta: SessionHeader): void { + if (meta.id !== id) { + throw new Error(`stored session identity mismatch: requested "${id}", header contains "${meta.id}"`) + } + } + // --- write path (session/event → flush drain) --- private installWritePath(): void { @@ -439,6 +439,7 @@ export class PersistenceCoordinator { const stored = await this.backend.loadStored(id) /* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */ if (stored === undefined) return false + this.assertStoredId(id, stored.meta) return seedCoversPrefix(seed, stored.events.slice(0, cursor)) } @@ -448,9 +449,10 @@ export class PersistenceCoordinator { * Cases, by whether this backend tracks the id and whether an artifact exists: * 1. Already tracked → no-op (or claim ownerless state if the seed matches, * or reclaim a truly-abandoned id, else reject as a collision). - * 2. Not tracked, an artifact EXISTS at this cwd and is a seq-aligned PREFIX - * of the live events → ADOPT it (HMR/reload), persisting any live suffix. - * 3. Not tracked, an artifact EXISTS but is NOT a prefix → REJECT (collision). + * 2. Not tracked, an artifact EXISTS at the same cwd and is a seq-aligned + * PREFIX of the live events → ADOPT it, persisting any live suffix. + * 3. Not tracked, an artifact EXISTS at another cwd or is NOT a prefix → + * REJECT (collision). * 4. Not tracked and NO artifact → a genuinely new session: register meta * (lazy) and persist its seed once. */ @@ -464,14 +466,11 @@ export class PersistenceCoordinator { if (tracked.owner === undefined) { // Ownerless state from the public create()/load() API. The FIRST live // session claims it — but ONLY if BOTH the cwd scope and the seed match. - // The cwd guard mirrors case-2's cwd-scoped loadLive(): a same-id - // ownerless artifact at a DIFFERENT cwd is a collision, not a claim - // (claiming it would append the live cwd's events under the stored - // header's cwd, the exact cross-cwd corruption the loadLive scope - // prevents). The seed guard then ensures the live events reproduce the - // persisted prefix (else a fresh, unrelated session reusing the id would - // have its seq 0..cursor-1 events filtered as already-written and - // grafted on). + // A same-id ownerless artifact at a different cwd is a collision, not a + // claim: accepting it would append this live session's events through + // the stored header's cwd. The seed guard then ensures the live events + // reproduce the persisted prefix; otherwise a fresh session reusing the + // id could have its leading events filtered as already written. if (tracked.meta.cwd !== session.header.cwd) { throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) } @@ -495,11 +494,9 @@ export class PersistenceCoordinator { } } - // case 2/3: an artifact at THIS cwd is adopted as a live prefix (or rejected - // as a collision inside adoptLivePrefix). cwd-scoped (loadLive), never - // any-scope: a same-id artifact at a different cwd is a collision, not a - // resume. - const live = await this.backend.loadLive(id, session.header.cwd) + // case 2/3: resolve the id once across storage, then let adoption reject a + // cwd mismatch before repair or state publication. + const live = await this.backend.loadStored(id) if (live !== undefined) { // Do NOT route through loadCore(): that crash-repairs open turns as // interrupted, which is wrong for HMR while the live Session is still the @@ -528,6 +525,10 @@ export class PersistenceCoordinator { */ private async adoptLivePrefix(session: Session, seed: readonly SessionEvent[], stored: StoredPrefix): Promise { const { meta, events, tornMarker } = stored + this.assertStoredId(session.header.id, meta) + if (meta.cwd !== session.header.cwd) { + throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) + } this.assertVersion(meta) assertSupportedEvents(events, session.header.id) if (!seedCoversPrefix(seed, events)) { diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index e083ac3543..21d878a6a7 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -63,7 +63,7 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend super(ctx) // Assign the store BEFORE constructing the coordinator: the coordinator's // constructor installs the write path and synchronously seeds existing live - // sessions (onCreated → loadLive → this.store), so store must exist first. + // sessions through loadStored(), so store must exist first. this.store = config?.store ?? new Map() this.coordinator = new PersistenceCoordinator(this.ctx, this) } @@ -88,18 +88,13 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend // --- PersistenceBackend hooks (the Map storage primitives) --- - // A Map-backed store has no torn tails, so `tornMarker` is never set. Ids are - // globally unique, so loadStored and loadLive are identical (cwd is ignored). + // A Map-backed store has no torn tails, so `tornMarker` is never set. async loadStored(id: SessionId): Promise | undefined> { const entry = this.store.get(id) if (!entry) return undefined return { meta: structuredClone(entry.meta), events: structuredClone(entry.events) } } - loadLive(id: SessionId, _cwd: string | undefined): Promise | undefined> { - return this.loadStored(id) - } - async appendBatch(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise { // Defense-in-depth: the coordinator already validates serializability, but a // durable store must reject non-JSON data at its own boundary too. @@ -137,6 +132,7 @@ class ControlledBackend implements PersistenceBackend { readonly lifecycle: string[] = [] appendAttempts = 0 loadAttempts = 0 + repairAttempts = 0 beforeAppend?: (attempt: number) => Promise beforeLoadStored?: (attempt: number) => Promise @@ -147,10 +143,6 @@ class ControlledBackend implements PersistenceBackend { return { meta: structuredClone(entry.meta), events: structuredClone(entry.events) } } - loadLive(id: SessionId, _cwd: string | undefined): Promise | undefined> { - return this.loadStored(id) - } - async appendBatch(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise { const attempt = ++this.appendAttempts await this.beforeAppend?.(attempt) @@ -162,7 +154,9 @@ class ControlledBackend implements PersistenceBackend { } } - async commitRepair(_m: SessionHeader, _tornMarker: undefined, _closers: readonly SessionEvent[]): Promise {} + async commitRepair(_m: SessionHeader, _tornMarker: undefined, _closers: readonly SessionEvent[]): Promise { + this.repairAttempts += 1 + } async list(): Promise { return [...this.store.values()].map(entry => structuredClone(entry.meta)) @@ -194,6 +188,36 @@ runCoordinatorContract('memory', async (): Promise => { } }) +describe('PersistenceCoordinator stored identity', () => { + it('rejects a mismatched backend header before repair or state publication', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const requested = SessionId('requested') + backend.store.set(requested, { + meta: meta('different'), + events: [{ + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }], + }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + try { + await expect(coordinator.load(requested)).rejects.toThrow(/stored session identity mismatch/) + expect(backend.repairAttempts).toBe(0) + expect((coordinator as unknown as CoordinatorInternals).states.size).toBe(0) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) +}) + describe('PersistenceCoordinator retirement', () => { it('a retiring unmaterialized owner without buffered events releases its id', async () => { const ctx = new Context() From c9d3d5d557afb3f5376c50f13d44e66a451ca630 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:41:40 +0800 Subject: [PATCH 015/207] fix(jsonl): reject unusable roots at plugin load A configured root that already exists as a file or unreadable directory cannot host cwd buckets, but the backend previously mounted and deferred that deterministic configuration error until a later list or write. Probe the resolved root while the plugin loads, surface every error except ENOENT, and keep an absent root valid for lazy first materialization. Document the timing contract, regenerate the config catalog, and pin the non-directory case at the load boundary. --- .../2026-07-20-jsonl-storage-identity.i18n.yaml | 4 ++-- .../bug-fix/2026-07-20-jsonl-storage-identity.md | 4 ++-- .../2026-07-20-jsonl-storage-identity.zh.md | 4 ++-- docs/config-catalog.md | 6 ++++-- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/src/index.ts | 16 +++++++++++++++- .../tests/jsonl.spec.ts | 7 ++----- 7 files changed, 28 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml index 2feb8bdf82..f907cf276b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-20-jsonl-storage-identity.md: c22377834244e5749993952a5fe8018b89130c1c -2026-07-20-jsonl-storage-identity.zh.md: 8b9a291772ba7e079c06e0d9e3ab9f285ac1ad7e +2026-07-20-jsonl-storage-identity.md: 1ada16791f411a54fbcf9271c7d7963223bbe683 +2026-07-20-jsonl-storage-identity.zh.md: 8027c51dbf6c7d01463b7851d859a40890bf03e1 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md index c223778342..1ada16791f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md @@ -14,7 +14,7 @@ JSONL lookup selects a physical log from the requested session id across cwd buc The coordinator independently asserts the returned id and compares the stored cwd with a live session's cwd before repair, state publication, or suffix persistence. It keeps a detached copy of validated metadata; JSONL append and repair derive their path from that copy. The `PersistenceBackend` interface therefore needs neither a scope-specific live lookup nor a storage-locator type. -The backend supports one live writer per session; another backend instance or process must not mutate that session until the owner finishes disposal and all writes stop. +An existing configured JSONL root must be a readable directory when the plugin loads. An absent root remains valid and is created on first materialization. The backend supports one live writer per session; another backend instance or process must not mutate that session until the owner finishes disposal and all writes stop. ## Alternatives considered @@ -26,4 +26,4 @@ The backend supports one live writer per session; another backend instance or pr ## Consequences -Mismatched, misplaced, and duplicate JSONL logs fail before repair or coordinator state mutation. The cwd-bucket format stays unchanged and needs no migration. Lookup remains proportional to the number of buckets, and one-live-writer ownership remains an explicit limitation. Coordinator and JSONL tests pin rejection before repair, unchanged bytes for both affected logs, path validation during listing, duplicate-id rejection, and cwd collision handling. +Mismatched, misplaced, and duplicate JSONL logs fail before repair or coordinator state mutation. The cwd-bucket format stays unchanged and needs no migration. Lookup remains proportional to the number of buckets, and one-live-writer ownership remains an explicit limitation. Coordinator and JSONL tests pin rejection before repair, unchanged bytes for both affected logs, path validation during listing, duplicate-id rejection, cwd collision handling, and load-time root validation. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md index 8b9a291772..8027c51dbf 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md @@ -14,7 +14,7 @@ JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物 协调器会独立断言返回的 id,并在修复、发布状态或持久化后缀之前比较已存 cwd 和活动会话的 cwd。协调器保留一份已验证元数据的独立副本;JSONL 的追加和修复操作根据该副本派生路径。因此,`PersistenceBackend` 接口既不需要限定范围的活动会话查找,也不需要存储定位器类型。 -后端对每个会话只支持一个活动写入方;在所有者完成资源释放且所有写入停止之前,另一个后端实例或进程不得变更该会话。 +如果配置的 JSONL 根目录已存在,插件加载时该路径必须是可读目录。根目录不存在仍然是有效配置,首次物化时会创建该目录。后端对每个会话只支持一个活动写入方;在所有者完成资源释放且所有写入停止之前,另一个后端实例或进程不得变更该会话。 ## 考虑过的替代方案 @@ -26,4 +26,4 @@ JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物 ## 后果 -JSONL 日志的身份不匹配、位置错误和重复会在修复或协调器状态变更前失败。cwd 分桶格式保持不变,无需迁移。查找开销仍与分桶目录数量成正比,单一活动写入方的所有权仍是明确限制。协调器和 JSONL 测试固定了修复前拒绝、两个受影响日志的字节均保持不变、列出时的路径验证、重复 id 拒绝以及 cwd 冲突处理。 +JSONL 日志的身份不匹配、位置错误和重复会在修复或协调器状态变更前失败。cwd 分桶格式保持不变,无需迁移。查找开销仍与分桶目录数量成正比,单一活动写入方的所有权仍是明确限制。协调器和 JSONL 测试固定了修复前拒绝、两个受影响日志的字节均保持不变、列出时的路径验证、重复 id 拒绝、cwd 冲突处理以及加载时的根目录验证。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c2d31f93e3..c0ccd5f8ee 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -735,13 +735,15 @@ export interface Config { /** * Root directory for all session files. Required (no default): a default of * `process.cwd()` would scatter session files as the process's cwd changes - * (bash calls, subprocesses). Sessions group under per-cwd subdirectories. + * (bash calls, subprocesses). Sessions group under per-cwd subdirectories. An + * existing root must be a readable directory; an absent root is created on + * first materialization. */ root: string } ``` -Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:24`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) +Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:25`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index a97fbfd206..f2a6a0a9b7 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -17,7 +17,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence | Key | Type | Notes | |---|---|---| -| `root` | `string` (required) | Root directory for all session files. **No default** — a `process.cwd()` default would scatter files as the process's cwd changes (bash calls, subprocesses). | +| `root` | `string` (required) | Root directory for all session files. **No default** — a `process.cwd()` default would scatter files as the process's cwd changes (bash calls, subprocesses). An existing root must be a readable directory; an absent root is created on first materialization. | `locate(meta)` returns `{ kind: 'jsonl', path }` using the resolved absolute root and the same cwd-bucket/id encoding as materialization. It performs no filesystem I/O: the target can be returned before the file exists, and an existing file contains only the last flushed prefix. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 84db4ac541..a3fdb8b440 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -8,6 +8,7 @@ import { Context } from 'cordis' import z from 'schemastery' +import { readdirSync } from 'node:fs' import { open, mkdir, readFile, readdir, link, rm, truncate } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' @@ -25,7 +26,9 @@ export interface Config { /** * Root directory for all session files. Required (no default): a default of * `process.cwd()` would scatter session files as the process's cwd changes - * (bash calls, subprocesses). Sessions group under per-cwd subdirectories. + * (bash calls, subprocesses). Sessions group under per-cwd subdirectories. An + * existing root must be a readable directory; an absent root is created on + * first materialization. */ root: string } @@ -64,6 +67,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi super(ctx) // Resolve once so later process.cwd() changes cannot split one backend across roots. this.root = resolve(config.root) + this.assertUsableRoot() this.coordinator = new PersistenceCoordinator(this.ctx, this) } @@ -304,6 +308,16 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return matches[0] } + /** Require an existing configured root to be a readable directory. */ + private assertUsableRoot(): void { + try { + readdirSync(this.root) + } catch (error) { + if (isENOENT(error)) return + throw error + } + } + /** Reject metadata that does not identify the selected physical log. */ private assertStoredIdentity(path: string, meta: SessionHeader, expectedId?: SessionId): void { if (expectedId !== undefined && meta.id !== expectedId) { diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index ab62bce938..d68a48a72d 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -748,15 +748,12 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.fiber.dispose() }) - it('list surfaces a non-ENOENT root error (ENOTDIR) instead of reporting no sessions', async () => { - // A durable backend must not collapse a storage fault to "no sessions". Making the root a - // regular file forces ENOTDIR from `readdir`, which must propagate. + it('plugin load rejects an existing root that is not a directory', async () => { const filePath = join(root, 'not-a-dir') await writeFile(filePath, 'x') const ctx2 = new Context() await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root: filePath }) - await expect(ctx2.sessionPersistence.list()).rejects.toThrow(/ENOTDIR/) + await expect(ctx2.plugin(SessionPersistenceJsonl, { root: filePath })).rejects.toThrow(/ENOTDIR/) await ctx2.fiber.dispose() }) From f4c9e53a2a466abff7eeb463dfe643538d9c2d24 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:42:03 +0800 Subject: [PATCH 016/207] docs(persistence): state backend ownership precisely The shared coordinator serializes operations within one backend instance; it does not coordinate multiple instances writing the same on-disk session. Remove prose that implied unsupported shared-writer semantics. Also update the older seam-simplification note to describe the surviving loadStored existence probe instead of the removed loadLive hook, so implemented documentation matches the current contract. --- .../simplification/2026-06-20-prune-dead-seam-methods.md | 2 +- docs/core-data-structures/persistence.md | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.md b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.md index 782ffe891e..f74de250ef 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.md +++ b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.md @@ -12,7 +12,7 @@ A capability seam ([interface / implementation / consumer](../architecture/2026- The abstract service declared its operations beyond create/append: `load`, `list`, `has`, `delete`. Production consumers of `ctx.sessionPersistence` use only two: the agent-loop resume path calls `load()` ([packages/core/agent-loop/src/index.ts:176](../../../../packages/core/agent-loop/src/index.ts)), and the ACP bridge calls `list()` for `session/list` ([packages/ui/acp/src/index.ts:494](../../../../packages/ui/acp/src/index.ts)). Grepping every `sessionPersistence.*` / `persistence.*` use across `packages/*/src` and `examples/` finds no `has(` and no `delete(` on the service. The `.has(`/`.delete(` calls in `packages/ui/acp/src/index.ts` are on the in-memory `SessionStore` and a local `Set` of loading ids, not persistence. The only callers of `has`/`delete` were the contract suites and per-backend specs. -`has()` was not just unused — it was the most intricate branch in the shared coordinator: a tracked-vs-untracked dual-probe (`loadLive(id, cwd)` for a live-tracked session vs `loadStored(id)` for an untracked one) with a multi-line rationale. `delete()` dragged the `deleteStored` backend hook that every backend had to implement. This is the [drop-mutable-session-summary](2026-06-19-drop-mutable-session-summary.md) pattern: a contract test exercised both, but no shipping code asks "is this session persisted?" or removes one. +`has()` was not just unused: it added a tracked-vs-untracked coordinator probe and a contract branch even though `loadStored(id)` already owns durable existence checks. `delete()` dragged the `deleteStored` backend hook that every backend had to implement. This is the [drop-mutable-session-summary](2026-06-19-drop-mutable-session-summary.md) pattern: a contract test exercised both, but no shipping code asks "is this session persisted?" or removes one. ## Decision diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index a80b9bc896..c88df10294 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -97,5 +97,3 @@ Both implement the same abstract `SessionPersistence` (locate/create/append/load - **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path. - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. - -Multiple backends sharing one on-disk session coordinate writes through the [shared persistence write-coordinator](../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). From 7a9177d624ac06830a1d242c4a969ed0a97acd64 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:46:51 +0800 Subject: [PATCH 017/207] test(jsonl): pin corruption and storage-fault rejection Identity validation must reject a header id that cannot derive a path, and only ENOENT may mean that storage is absent. Other root or per-path failures must remain visible instead of becoming an empty list or false miss. Exercise those branches with narrow storage-mechanics cases, preserving per-file 100% coverage without restoring the flat-layout or multi-writer tests removed from the replacement design. --- .../tests/jsonl.spec.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index d68a48a72d..8589fcd2f9 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -642,6 +642,16 @@ describe('SessionPersistenceJsonl: edge cases', () => { await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd belong at/) }) + it('list rejects a session header whose id cannot name a storage path', async () => { + const bucket = sessionDir(root, undefined) + await mkdir(bucket, { recursive: true }) + await writeFile(join(bucket, 'invalid-id.jsonl'), JSON.stringify({ + type: 'session', version: 0, id: '', createdAt: 1, + }) + '\n') + + await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header id cannot name a storage path/) + }) + it('load and list reject one id materialized in multiple cwd buckets', async () => { const id = SessionId('duplicate') for (const cwd of ['/a', '/b']) { @@ -757,6 +767,21 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.fiber.dispose() }) + it('list surfaces a root that becomes unusable after plugin load', async () => { + await rm(root, { recursive: true }) + await writeFile(root, 'not a directory') + + await expect(ctx.sessionPersistence.list()).rejects.toThrow(/ENOTDIR/) + }) + + it('per-id lookup surfaces non-ENOENT storage errors', async () => { + const blocker = join(root, 'not-a-directory') + await writeFile(blocker, 'x') + const backend = ctx.sessionPersistence as unknown as { exists(path: string): Promise } + + await expect(backend.exists(join(blocker, 'child.jsonl'))).rejects.toThrow(/ENOTDIR/) + }) + it('materialization surfaces a cwd-bucket storage fault', async () => { const cwd = '/x' const ctx2 = new Context() From 8500974fd466b2faadeda3c95cf9574e1a934a74 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:11:55 +0800 Subject: [PATCH 018/207] feat: unify JSON value schema DSL --- .../2026-06-11-custom-schema-dsl.md | 4 +- .../2026-06-11-runtime-arg-validation.md | 6 +- ...20-unified-json-value-schema-dsl.i18n.yaml | 6 + ...026-07-20-unified-json-value-schema-dsl.md | 32 + ...-07-20-unified-json-value-schema-dsl.zh.md | 32 + .../feature/2026-07-05-dynamic-workflows.md | 4 +- ...6-07-08-self-referential-cordis-toolset.md | 4 +- ...2026-06-20-core-data-structures-catalog.md | 2 +- .../2026-06-11-property-based-testing.md | 2 +- ...prune-unimplemented-subagent-vocabulary.md | 2 +- docs/config-catalog.md | 2 +- docs/cookbook/adding-a-tool.i18n.yaml | 4 +- docs/cookbook/adding-a-tool.md | 2 +- docs/cookbook/adding-a-tool.zh.md | 2 +- docs/cordis-catalog/events.md | 10 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 4 +- docs/core-data-structures/subagent.md | 4 +- docs/core-data-structures/tools.md | 136 +++-- docs/event-producer-consumer.md | 10 +- docs/tool-catalog.md | 10 +- docs/user/develop/basic/tool.i18n.yaml | 4 +- docs/user/develop/basic/tool.md | 22 +- docs/user/develop/basic/tool.zh.md | 22 +- .../system-prompt.expected.md | 52 +- .../tool-schemas.expected.json | 8 +- .../both-mode-turn/system-prompt.expected.md | 44 +- .../both-mode-turn/tool-schemas.expected.json | 6 +- .../code-mode-turn/system-prompt.expected.md | 44 +- .../system-prompt.expected.md | 44 +- .../tool-schemas.expected.json | 12 +- .../tool-schemas.expected.json | 12 +- .../skill-load/tool-schemas.expected.json | 6 +- .../text-turn/tool-schemas.expected.json | 6 +- .../tool-schemas.expected.json | 6 +- packages/bash/tool-bash/src/index.ts | 2 +- packages/bash/tool-bash/tests/tools.spec.ts | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 34 +- packages/cordis/tool-cordis/src/guard.ts | 238 ++++++-- packages/cordis/tool-cordis/src/index.ts | 6 +- .../cordis/tool-cordis/tests/mount.spec.ts | 146 ++++- packages/core/tools/README.md | 8 +- packages/core/tools/src/index.ts | 39 +- packages/core/tools/src/json-schema.ts | 495 ++++++++------- packages/core/tools/src/schema.ts | 573 ++++++++++-------- packages/core/tools/src/ts-types.ts | 72 ++- packages/core/tools/tests/json-schema.spec.ts | 482 ++++++++------- packages/core/tools/tests/properties.spec.ts | 72 ++- packages/core/tools/tests/schema.spec.ts | 138 +++++ packages/core/tools/tests/tools.spec.ts | 96 +-- packages/core/tools/tests/ts-types.spec.ts | 59 +- .../subagent-inprocess/src/structured.ts | 8 +- .../tests/structured.spec.ts | 16 +- packages/subagent/subagent/src/index.ts | 4 +- packages/subagent/subagent/src/types.ts | 6 +- packages/tasks/tool-tasks/src/index.ts | 2 +- packages/todo/tool-todo/src/index.ts | 3 +- packages/ui/tool-ask-user/src/index.ts | 2 + packages/workflow/tool-workflow/src/index.ts | 3 + .../workflow-workerthread/src/runtime.ts | 14 +- .../workflow-workerthread/src/types.ts | 4 +- scripts/type-equiv.manifest.json | 14 +- 62 files changed, 1929 insertions(+), 1179 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md create mode 100644 .agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md create mode 100644 packages/core/tools/tests/schema.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md index bf8c02140a..41b72f1551 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md +++ b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md @@ -8,7 +8,7 @@ Tool parameters must reach the model as standard JSON Schema while giving tool a ## Decision -A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required: true` booleans), type-level `InferArgs` mapping a spec to the argument type (required keys non-optional, others genuinely optional via `?`), a runtime `schemaSpecToJsonSchema()` converter, and `defineTool()` tying them together. Raw JSON-Schema `ToolDefinition`s remain accepted by `ToolRegistry.register()` — that's how MCP-sourced tools arrive. +This decision is superseded by the [unified JSON-value schema DSL](2026-07-20-unified-json-value-schema-dsl.md), which retains the small authoring surface while making parameters and typed values share one vocabulary. `ParameterSchemaSpec` keeps per-property `required: true`; `InferArgs` maps required keys to non-optional properties; `parameterSchemaSpecToJsonSchema()` compiles the implicit open object root; and `defineTool()` ties inference, compilation, and validation together. Raw JSON-Schema `ToolDefinition`s remain accepted by `ToolRegistry.register()` for MCP and other external tools. ## Alternatives considered @@ -17,5 +17,5 @@ A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required ## Consequences - First-party tool authors get zero-cast typed args; the type gymnastics cost stays inside the core package (sanctioned by the AGENTS.md type-safety policy). -- The DSL is deliberately small (string/number/boolean/object/array, enum, default, nested properties/items). Gaps vs full JSON Schema (unions, formats, constraints) are accepted until real tools demand them. +- The owning unified note defines the current nodes, literal constraints, unions, JSON-value boundary, and object-openness rules. - The `InferArgs` mapping is regression-tested at the type level after an early optionality bug. diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md index 454f12d0af..94b0c6af60 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md @@ -4,13 +4,13 @@ Status: implemented ## Problem -`defineTool` ([the custom schema DSL](2026-06-11-custom-schema-dsl.md)) gives tool authors a typed `execute(args)` via the `InferArgs` mapping. But that type is a compile-time claim about a value that arrives at runtime as model-generated JSON: nothing forced the model to honor the schema, so a malformed call — missing a required key, a string where a number was declared, an enum value outside the set — reached `execute` typed-in-name-only. The tool body then either crashed on the bad shape (a generic stack trace the model can't act on) or, worse, silently misbehaved. Meanwhile the converter already encodes the exact structure a validator would need to walk. +`defineTool` ([the unified schema DSL](2026-07-20-unified-json-value-schema-dsl.md)) gives tool authors a typed `execute(args)` via the `InferArgs` mapping. But that type is a compile-time claim about a value that arrives at runtime as model-generated JSON: nothing forced the model to honor the schema, so a malformed call — missing a required key, a string where a number was declared, or a literal outside the declared set — reached `execute` typed-in-name-only. The tool body then either crashed on the bad shape or silently misbehaved. ## Decision -`validateArgs(spec, args): string[]` interprets a `SchemaSpec` over a runtime value, returning human-readable violations (empty = valid), and is total (never throws). `defineTool` runs it before the typed body; on violations it throws `ToolArgsError` (`code: 'INVALID_ARGS'`, message listing the violations), which the registry's existing execute-waterfall catch turns into an `isError` result the model reads and self-corrects from. +`validateArgs(spec, args): string[]` compiles a `ParameterSchemaSpec` and delegates to the shared `validateJsonSchemaValue()` walker, returning human-readable violations for a well-formed declaration. `defineTool` snapshots the compiled parameter schema at definition time and runs that validation before the typed body; violations throw `ToolArgsError` (`INVALID_ARGS`), which the registry returns as an error result the model can correct. -The validator mirrors `schemaSpecToJsonSchema` semantics exactly — same structure walked, same rules: top level must be a non-array object; required keys come only from `required: true`; extra keys are allowed (no `additionalProperties: false`); `default` is not applied; an `object`/`array` prop without `properties`/`items` only type-checks; `enum` is membership. Raw-registered (MCP) tools are not touched — they validate their own input. +The validator and compiler therefore share exact semantics: the implicit parameter root is an open object; required keys come only from `required: true`; defaults remain annotations; explicit nested objects honor their declared openness; arrays recurse through `items`; scalar literal constraints are type-correct; and `oneOf` accepts exactly one matching branch. Raw-registered tools own their input validation. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml new file mode 100644 index 0000000000..f434c81118 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-20-unified-json-value-schema-dsl.md: ab7bb268407ac26230283172ebb291de80412bf2 +2026-07-20-unified-json-value-schema-dsl.zh.md: 479699fc2d58666b86861f0ea1db907ae4957dc5 diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md new file mode 100644 index 0000000000..ab7bb26840 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md @@ -0,0 +1,32 @@ +# Agent Note: Unified JSON-value schema DSL + +Status: implemented + +English | [中文](2026-07-20-unified-json-value-schema-dsl.zh.md) + +## Problem + +Tool parameters used a small author DSL while subagent/workflow structured output used a separate raw JSON Schema subset and validator. The two vocabularies disagreed about roots, scalar constraints, and validation, so a typed canonical tool-output contract would either duplicate both paths again or accept schemas that some projection could not enforce. + +## Decision + +`dsh-tools` owns one JSON-value schema vocabulary with two representations. `ValueSchemaSpec` is the author form for any JSON root; `ParameterSchemaSpec` is its implicit object-property-map form with per-property `required: true`. `JsonSchemaNode` is the raw wire form. Both support string, finite number, integer, boolean, null, array, object, type-correct scalar `enum`/`const`, and exact-one `oneOf`; `{ type: 'json' }` is author-only sugar for an annotation-only unconstrained raw node. + +An explicit author object must declare `additionalProperties: true | false`. The implicit parameter root and raw JSON Schema preserve the standard open default. `InferValue` and `InferArgs

` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. `assertSupportedJsonSchema()` rejects unsupported or misplaced keywords, and `validateJsonSchemaValue()` enforces the accepted subset against the lossless `JsonValue` boundary: no `undefined`, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values. + +Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent and workflow caller-defined structured outputs use `assertObjectJsonSchema()` and `ObjectJsonSchema`; tool outputs may use any root. Dynamic Cordis registrations rebuild realm-foreign schemas into host-owned JSON, preserve raw-wrapper openness, and require direct-DSL object openness before calling the same compiler. + +## Alternatives considered + +- **Keep separate parameter and structured-output schema systems:** rejected because every added output construct would require parallel inference, compilation, validation, and code-generation changes with no useful ownership boundary. +- **Adopt full JSON Schema or Ajv:** rejected because the harness must fail on every construct it cannot project into its generated SDK and validators; accepting a larger language would make enforcement and model guidance dishonest. +- **Make every object implicitly open or closed:** rejected because either choice hides a consequential author decision. Only the legacy-shaped implicit parameter root and external raw schema retain an intentional default. +- **Define `oneOf` as first-match:** rejected because branch ordering would change validation semantics and allow overlapping branches to hide ambiguous values. + +## Consequences + +- Parameter validation, output validation, schema-to-TypeScript generation, subagent/workflow guards, and dynamic registration share one enforced vocabulary. +- Output declarations can infer object, array, scalar, or null roots; subagent/workflow structured outputs remain object-rooted at their existing seams. +- Explicit object openness and type-correct literal constraints make malformed declarations fail during authoring or registration rather than during a later model call. +- Raw tools may still register broader JSON Schema directly, but unified code generation treats unsupported schemas as unknown instead of pretending to enforce them. +- Runtime and compile-time tests cover every root, exact-one overlap/no-match behavior, raw open defaults, explicit openness, lossy JSON values, and inference. diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md new file mode 100644 index 0000000000..479699fc2d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md @@ -0,0 +1,32 @@ +# Agent Note:统一 JSON 值 schema DSL + +Status: implemented + +[English](2026-07-20-unified-json-value-schema-dsl.md) | 中文 + +## 问题 + +工具参数使用一套精简的作者侧 schema DSL,subagent/工作流的结构化输出则使用另一套原始 JSON Schema 子集和校验器。两套词汇在根类型、标量约束和校验方式上并不一致;如果继续沿用这种划分,类型化的规范工具输出契约要么还需重复实现两条路径,要么只能接受部分投影无法强制执行的 schema。 + +## 决策 + +`dsh-tools` 以两种表示形式统一管理一套 JSON 值 schema 词汇。`ValueSchemaSpec` 是可描述任意 JSON 根类型的作者侧形式;`ParameterSchemaSpec` 是其隐式对象属性映射形式,每个属性可标记 `required: true`。`JsonSchemaNode` 是原始协议表示。两种形式都支持字符串、有限数值、整数、布尔值、null、数组、对象、类型正确的标量 `enum`/`const`,以及要求恰好匹配一个分支的 `oneOf`;`{ type: 'json' }` 仅是作者侧语法糖,会编译为仅含注解、不施加约束的原始节点。 + +显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue` 和 `InferArgs

` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。 + +对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。 + +## 备选方案 + +- **保留两套独立的参数与结构化输出 schema 系统:**不予采纳。每新增一种输出结构,都必须分别修改类型推导、编译、校验和代码生成,而这种重复并未形成有意义的职责边界。 +- **采用完整 JSON Schema 或 Ajv:**不予采纳。harness 必须拒绝所有无法投影到生成 SDK 和校验器中的结构;如果接受更大的语言子集,强制执行能力和模型指引就会与事实不符。 +- **让所有对象默认开放或默认封闭:**不予采纳。这两种选择都会隐藏一项影响重大的作者决策。只有保持旧有形态的隐式参数根对象和外部原始 schema 才有意保留默认值。 +- **把 `oneOf` 定义为首个匹配分支:**不予采纳。这样一来,分支顺序会改变校验语义,重叠分支也会掩盖值的歧义。 + +## 影响 + +- 参数校验、输出校验、schema 到 TypeScript 的代码生成、subagent/工作流门禁和动态注册共用一套强制执行的词汇。 +- 输出声明可以推导对象、数组、标量或 null 根类型;subagent/工作流的结构化输出仍在其现有服务边界保持对象根限制。 +- 显式的对象开放方式和类型正确的字面量约束会让格式错误的声明在编写或注册阶段快速失败,而不是拖到后续模型调用时才失败。 +- 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。 +- 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值和类型推导。 diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md index 599c895ae2..fcd0b66062 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md @@ -44,7 +44,7 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai An output schema makes a schema-valid committed capture mandatory for successful child completion. The scoped runtime presents the capture tool and instruction, commits only a successful final outcome—including the enclosing `run_code` outcome for an SDK call—denies later side effects after capture becomes pending, and stops the child without another model step after commit. A validation failure remains a retryable tool error; clean completion without a committed capture settles as an error. -`StructuredOutputSchema` is the raw enforceable JSON-Schema subset in `dsh-tools` (single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`), and unsupported keywords fail loudly because that wire data becomes the capture tool's parameters verbatim. The [agent-scope runtime-design Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) owns the assembly, commit, guard, and terminal-stop correctness algorithms. +`ObjectJsonSchema` is the object-rooted consumer view of the unified enforceable raw JSON Schema subset in `dsh-tools`; unsupported keywords fail loudly because that wire data becomes the capture tool's parameters verbatim. The [unified JSON-value schema Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.md) owns the vocabulary and validation semantics, while the [agent-scope runtime-design Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) owns the assembly, commit, guard, and terminal-stop algorithms. ## Testing @@ -68,7 +68,7 @@ Worker-side logic runs through an in-process `MessageChannel` so V8 coverage mea - **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool. - **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`. - **Meta embedded in the script as `export const meta = {...}`** (CC's exact format): keeps scripts self-contained and CC scripts drop-in, but obtaining meta requires evaluating model-written text on the host. Even an empty timed vm context cannot bound script-controlled getters when the host reads the resulting object. A JSON parameter removes the scanner, evaluation, and host-spin hole; the cost is that a CC script's meta header must move into the parameter (the body stays drop-in). -- **`SchemaSpec` as the outputSchema type**: the author-facing DSL cannot express what arrives as data and cannot be validated against without conversion loss. +- **`ValueSchemaSpec` as the `outputSchema` wire type**: the author form now has equivalent vocabulary, but a workflow supplies realm-foreign raw JSON Schema data; pretending that runtime data is a trusted author declaration would skip the raw-schema assertion boundary. - **A schema-object library (zod, or the repo's schemastery) for the structured-output subset**: the schema is wire data — plain JSON that crosses the vm realm boundary in `agent({schema})` and lands verbatim in the forced tool's parameters — exactly where live schema objects cannot sit; consuming raw JSON Schema at runtime would need a third-party converter on top (zod core only emits JSON Schema, not the reverse), and it would put a second schema language beside schemastery's config role. - **ajv for value validation**: it validates FULL JSON Schema, so the subset gate — the module's actual point, since every accepted keyword must be one the harness enforces — would remain hand-written regardless; it compiles validators through `new Function`; and it would be dsh-tools' first runtime dependency, all to replace the ~70-line value walker while the path-qualified, every-violation error reporting stays custom either way. - **Provider JSON mode instead of the capture tool:** it guarantees valid JSON, not schema conformance, and its interaction with tool calling is unclear. The capture tool preserves in-turn validation retries. Provider-side strict tool schemas can later narrow the accepted subset without changing this design. diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 144bdd018f..2038b2b719 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -32,7 +32,7 @@ Sandbox globals are deliberately small: a tagged write-through `console` (`[cord Mount code crosses the vm boundary through three controls. Dual-realm `instanceof` recognizes both host and vm objects. `harness.defineTool` normalizes results into host-realm JSON and validates the `ToolExecuteReturn` shape before logging. The mounted plugin receives a whitelist context façade, not a raw or pass-through `Context`; framework plumbing and context-valued returns are rejected. Service reads require a declared `inject`, preserving Cordis activation and unload semantics. `ctx.tools.get` exposes only the schema view, so mounted code cannot bypass `ToolRegistry.execute` by calling a definition directly. -The boundary normalizes unambiguous JSON-Schema forms into `SchemaSpec`, including object wrappers, `integer`, and optional fields. Invalid vocabulary fails with the accepted alternatives. Parse, TypeScript, missing-return, Node-API, and duplicate-tool errors include the relevant source line or corrective contract without narrating implementation internals. +The boundary normalizes unambiguous JSON-Schema forms into `ParameterSchemaSpec`, preserving `integer`, raw object openness, and required arrays. Direct DSL object nodes must declare `additionalProperties`; invalid vocabulary fails with the accepted alternatives. Parse, TypeScript, missing-return, Node-API, and duplicate-tool errors include the relevant source line or corrective contract without narrating implementation internals. ### The dynamic group and mount lifecycle @@ -60,7 +60,7 @@ Model-visible ⟺ logged holds with no new session event type: a mount or unmoun | Dimension | Structured per-capability tools | Single `cordis_mount` | |---|---|---| -| Schema correctness | `parameters` is still a model-written JSON object needing SchemaSpec validation, merely one step earlier | The same validation runs at the sandbox boundary, with the same instructive errors | +| Schema correctness | `parameters` is still model-written JSON needing unified-schema validation, merely one step earlier | The same validation runs at the sandbox boundary, with the same instructive errors | | The code field | An `execute` body is still model-written JS in a vm; the realm and service-call correctness problems are unchanged | One sandbox, one normalization path, one guarded registration | | Capability coverage | Tools only; listeners, services, `inject` relations each need another structured tool — a surface that grows without bound | One vocabulary (a cordis plugin) covers every effect, present and future | | Cross-mount composition | Not expressible in a tool-registration payload | Native `provide`/`inject`, ordinary cordis semantics | diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md index 21b86b1812..99ab46ef68 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -19,7 +19,7 @@ The scoping line was not picked top-down; it was discovered by testing candidate The rule that settled the remaining cases: ***the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.*** Worked through: - A data structure is **core** if it flows through the agent-loop spine — the loop holds, derives, streams, or logs it on every turn regardless of which plugins load (`Message`, `StreamChunk`, `SessionEvent`, the `Agent` handle) — **or** it is the single headline type a plugin author writes against a pipeline (`ToolDefinition`). -- `ToolDefinition` is core (it is what every tool author writes) **even though the loop never holds one** — authoring-importance overrides the strict flows-through-spine rule for this one headline type. But its typing machinery — the `SchemaSpec`/`InferArgs` DSL — is a sub-page detail (you write a `ToolDefinition`; the type-level machinery that types it you do not). That is the spine-vs-seam line made sharp. +- `ToolDefinition` is core (it is what every tool author writes) **even though the loop never holds one** — authoring-importance overrides the strict flows-through-spine rule for this one headline type. But its typing machinery — `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, and `InferArgs` — is a sub-page detail. That is the spine-vs-seam line made sharp. - `ToolSchema` is core (it is a field of `GenerateOptions`, the model request that flows through every step) even though it is conceptually part of the tool pipeline — *flows through the spine* wins over *conceptual home* when they conflict. - The tool-presentation vocabulary (`ToolCallView`/`ToolResultView`, …), the `SessionPersistence` durability seam, and bash vocabulary are sub-pages. diff --git a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.md b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.md index 06350753cd..5109aa1c80 100644 --- a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.md +++ b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.md @@ -14,7 +14,7 @@ Adopt `fast-check` (a root devDependency) with one `tests/properties.spec.ts` pe - **dsh-llm / BlockAssembler:** arbitrary chunk streams (valid + malformed: duplicate indices, stragglers, missing block-start). Invariants: `blocks()` count ≤ distinct indices seen; re-assembly idempotent (`blocks()` is stable across repeated calls and `message().content` mirrors it); `blocks()` never throws and yields only valid content-block tags; `finish` reflects the last `finish` chunk, defaulting to `{kind:'stop'}` when none arrives. - **dsh-session:** arbitrary event logs. Invariants: `deriveMessages` deterministic; replay-from-seed identical; seq strictly monotonic; non-message events never affect derived history; derived content is decoupled from the log. -- **dsh-tools:** arbitrary `SchemaSpec`. Invariants: JSON Schema `required` equals the `required:true` keys at every level; conversion total; **and the composition with [runtime arg validation](../architecture/2026-06-11-runtime-arg-validation.md)** — generated args satisfying a spec pass `validateArgs`, and targeted corruptions (dropped required key, non-object top level) are rejected. This closes the validator/`InferArgs` drift risk. +- **dsh-tools:** arbitrary `ParameterSchemaSpec`. Invariants: JSON Schema `required` equals the `required:true` keys at every level; conversion is total for valid declarations; **and the composition with [runtime arg validation](../architecture/2026-06-11-runtime-arg-validation.md)** — generated args satisfying a spec pass `validateArgs`, and targeted corruptions (dropped required key, non-object top level) are rejected. Focused cases cover every value root, exact-one overlap/no-match, explicit openness, raw defaults, and lossy JSON. This closes the compiler/validator/`InferArgs` drift risk. - **dsh-agent-loop:** arbitrary send schedules against a never-exhausting adapter, driven through the `agent/status` settle signal (no wall-clock sleeps). Invariants: no message lost; turn numbers strictly increase; status transitions stay on the legal machine. ## Consequences diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md index 582673f185..1f07492c9c 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md @@ -9,7 +9,7 @@ The [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-sea - **`outputSchema`/`structured` and `toolFilter`** (`SubagentCapabilities`, `SubagentStartRequest`, `SubagentResult` in `packages/subagent/subagent/src/types.ts`): at the decision point, every real provider declared `outputSchema: false, toolFilter: false` (`packages/subagent/subagent-spawn/src/index.ts`, `packages/subagent/subagent-fork/src/index.ts`, `packages/subagent/subagent-acp/src/index.ts`); the sole production `ctx.subagents.start` caller (`packages/subagent/tool-subagent/src/index.ts`) built `{ prompt, parent, signal?, agentOptions? }` and structurally could not set either; `structured` appeared only in the scripted test fixture. The service's capability check carried two assert rows whose only exercisers were the rejection tests. - **`SubagentRun.sendMessage` / `SubagentRun.resume`** (same file): implemented by NO provider — not even the mock; the spawn spec asserts their *absence*. -The only reason `dsh-subagent` depends on `dsh-tools` at all is `outputSchema`'s `SchemaSpec` type. Three subsequent subagent workstreams (per-session snapshot replay, the fork seed boundary, the ACP backend) landed around this surface without growing a single consumer. +The only reason `dsh-subagent` depended on `dsh-tools` at the decision point was `outputSchema`'s schema type (now `ObjectJsonSchema`). Three subsequent subagent workstreams (per-session snapshot replay, the fork seed boundary, the ACP backend) landed around this surface without growing a single consumer. ## Proposal diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5bc5657d2a..44e27bb572 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1303,7 +1303,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:397`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 070cc45f93..288ee96446 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -adding-a-tool.md: a45315dc0ec92ab28963c2aca32dffcf5f778dcd -adding-a-tool.zh.md: f574957ddd0e42cedc93ddc0f3270110a8f110c5 +adding-a-tool.md: 94cb4fcfa9a0155fd57ef18f9a855a264c2eff84 +adding-a-tool.zh.md: 637dc3381765e57c2d0420164ac13fde7fb590fe diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index a45315dc0e..94cb4fcfa9 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -35,7 +35,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w ## Rules of the execute() contract -- **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../../.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input. +- **Args are validated for you.** `defineTool` validates model-generated `arguments` against the unified `ParameterSchemaSpec` before `execute` runs (types, required keys, literal constraints, exact-one unions, and nested values — [runtime arg validation](../../.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args match `InferArgs`. Explicit object nodes declare `additionalProperties: true | false`; the implicit parameter root stays open. You still hand-check constraints the DSL does not express, such as non-empty strings, positive numbers, or cross-field rules. Raw JSON-Schema tools registered directly own their input validation. - **Registration borrows your readonly definition.** A typed same-process contribution is not a serialization boundary; do not mutate its schema or replace callbacks after registration. `schemas()` materializes only the explicit model-facing projection. To hot-swap a tool, dispose its owning effect and register the replacement; mutable state inside the callback's closure remains ordinary plugin state. - **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. An around-dispatch wrapper may add, replace, or remove only `exec.signal` to impose cancellation or a deadline. - **Throwing or returning non-JSON data means `isError`.** The registry catches throws and materializes the final result before observers run. A malformed or non-JSON result becomes `{ isError: true }`, preventing a live success that cannot be logged. Throw for infrastructure failures; report domain failures in result text when the model must interpret them. diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index f574957ddd..637dc33817 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -35,7 +35,7 @@ export function apply(ctx: Context) { ## execute() 契约的规则 -- **参数已为你校验。** `defineTool` 在 `execute` 运行前,会根据 `SchemaSpec` 校验模型生成的 `arguments`(类型、必填键、枚举成员、嵌套对象/数组——见[运行时参数校验](../../.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md)),因此 `execute` 内部的 args 已匹配 `InferArgs`。你仍需手动检查 DSL 无法表达的值约束(非空字符串、正数、跨字段规则),对这些情况抛出描述性 Error。直接注册的原始 JSON-Schema 工具(MCP)不由 harness 校验,它们自行校验输入。 +- **参数已为你校验。** `defineTool` 在 `execute` 运行前,会根据统一的 `ParameterSchemaSpec` 校验模型生成的 `arguments`(类型、必填键、字面量约束、恰好匹配一个分支的联合以及嵌套值——见[运行时参数校验](../../.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md)),因此 `execute` 内的 args 会匹配 `InferArgs`。显式对象节点必须声明 `additionalProperties: true | false`;隐式参数根对象保持开放。你仍需手动检查 schema DSL 无法表达的约束,例如非空字符串、正数或跨字段规则。直接注册的原始 JSON Schema 工具自行负责输入校验。 - **注册借用你的只读定义。** 类型化的同进程贡献不是序列化边界;注册后不要修改其 schema 或替换回调。`schemas()` 只物化显式的模型可见投影。如需热替换工具,请 dispose 其所属副作用并注册替代品;回调闭包内的可变状态仍是普通的插件状态。 - **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token` 以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。around-dispatch 包装器只能添加、替换或移除 `exec.signal`,以施加取消或截止时间。 - **抛出异常或返回非 JSON 数据意味着 `isError`。** 注册表捕获异常,并在观察者运行前物化最终结果。格式错误或非 JSON 的结果变为 `{ isError: true }`,防止出现无法记录的活跃成功。基础设施故障请抛异常;当模型需要解读领域失败时,请在结果文本中报告。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index a213c9e11a..cde1c4340b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -760,7 +760,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:116`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:131`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -780,7 +780,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:89`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:104`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -800,7 +800,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:98`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -819,7 +819,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:80`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:95`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -838,7 +838,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:121`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5fa14917e1..48feaa92a6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1361,7 +1361,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:438`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:453`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index dfdc258cb0..460fdc7a48 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -11,7 +11,7 @@ Precisely, a data structure is **core** if either: 1. it flows through the agent-loop spine — the loop holds it, derives it, streams it, or logs it on every turn (a `Message`, a `StreamChunk`, a `SessionEvent`, the `Agent` handle itself), independent of which plugins are present; **or** 2. it is the single headline type a plugin author writes against a pipeline — `ToolDefinition` (what every tool *is*). -Everything else is documented on a **sub-page**, not here. The rule that draws the line: *the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.* So `ToolDefinition` is core, but the `SchemaSpec`/`InferArgs` DSL that types it, the `ToolCallView`/`ToolResultView` render-intent vocabulary that renders it, and the `SessionPersistence` seam that stores the event log are not — they live on the sub-pages below. +Everything else is documented on a **sub-page**, not here. The rule that draws the line: *the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.* So `ToolDefinition` is core, but the `ValueSchemaSpec`/`ParameterSchemaSpec` inference machinery that types it, the `ToolCallView`/`ToolResultView` render-intent vocabulary that renders it, and the `SessionPersistence` seam that stores the event log are not — they live on the sub-pages below. | Sub-page | Owns | |---|---| @@ -490,4 +490,4 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional UI presenters. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed args), but it is the contract the registry holds and the loop dispatches through. -Its full fields, the `defineTool`/`SchemaSpec`/`InferArgs` typed schema DSL, the `ToolExecution`/`ToolExecutionResult` waterfall shapes, and the tool-presentation UI vocabulary are on **[tools.md](tools.md)**. +Its full fields, the `defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` typed schema DSL, the `ToolExecution`/`ToolExecutionResult` waterfall shapes, and the tool-presentation UI vocabulary are on **[tools.md](tools.md)**. diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index bd4324e697..1b048b94d3 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -63,11 +63,11 @@ interface SubagentStartRequest { /** Per-child agent options (model and plugin-defined extension fields). */ readonly agentOptions?: AgentOptions /** - * Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects + * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects * unsupported schemas or providers without the capability. Data must be plain host-realm JSON; * a successful child returns the matching value as {@link SubagentResult.structured}. */ - readonly outputSchema?: StructuredOutputSchema + readonly outputSchema?: ObjectJsonSchema /** * Optional absolute delegation-depth cap for the child being started: its * computed depth must be less than or equal to this non-negative safe diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 9e3d698440..20a74e0190 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -57,65 +57,65 @@ interface ToolDefinition extends ToolSchema { `execute` receives `args: unknown` — a raw `ToolDefinition` validates its own input. First-party tools don't write that by hand; they use `defineTool`, which validates and narrows for them. -## The typed schema DSL +## The unified JSON-value schema DSL -Plugin authors write per-property specs with a boolean `required: true`, and a type-level helper maps the spec to the `execute` argument type — zero casts. The DSL is *machinery that types* `ToolDefinition`; it is intentionally a sub-page detail, not core. +Plugin authors use one vocabulary for typed parameters and typed output values. `ValueSchemaSpec` supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum` and `const` values must match their node type. An explicit object node always declares `additionalProperties: true | false`. Parameter definitions remain an implicit open object property map, with `required: true` attached to each required property. Source: [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) ```ts type-equiv -/** One schema-spec property entry. */ -interface SchemaProp { - type: SchemaType - /** Per-property required flag (NOT the JSON Schema top-level required array). */ - required?: true - /** Human-readable description, surfaced in the JSON Schema as well. */ - description?: string - /** Enum of allowed values (strings only). */ - enum?: string[] - /** - * Model-visible JSON Schema default annotation. Validation does not apply it; - * dynamic tool mounts may supply it even though first-party definitions do not. - */ - default?: unknown - /** Nested properties for type: 'object'. */ - properties?: SchemaSpec - /** Items schema for type: 'array'. */ - items?: SchemaProp -} +/** One author-facing schema for any lossless JSON value root. */ +type ValueSchemaSpec = + | StringValueSchemaSpec + | NumberValueSchemaSpec + | IntegerValueSchemaSpec + | BooleanValueSchemaSpec + | NullValueSchemaSpec + | ArrayValueSchemaSpec + | ObjectValueSchemaSpec + | JsonValueSchemaSpec + | OneOfValueSchemaSpec +``` + +```ts type-equiv +/** One implicit parameter-root property, optionally required. */ +type ParameterPropertySpec = ValueSchemaSpec & { required?: true } ``` ```ts type-equiv /** - * The author-facing parameter schema: a shallow map of property name to - * {@link SchemaProp}. Required-ness is a per-property boolean (`required: - * true`), not a separate array. + * Tool parameter schema. The map itself is an implicit open object root; + * requiredness remains a per-property `required: true` annotation. */ -type SchemaSpec = Record +type ParameterSchemaSpec = Record ``` -`SchemaType` is the primitive union `'string' | 'number' | 'boolean' | 'object' | 'array'`. `InferArgs` maps a `SchemaSpec` to the TS argument type — `required: true` props become required keys, everything else genuinely optional: +`{ type: 'json' }` infers `JsonValue` and compiles to an annotation-only unconstrained raw schema. Output roots can be objects, arrays, scalars, or null. `InferValue` honors literal constraints and object openness; `InferArgs

` turns per-property requiredness into required and optional keys: ```ts type-equiv /** - * Infer the TS argument type for a complete {@link SchemaSpec}. - * - * Properties marked `required: true` are required keys; all others are - * genuinely optional keys (`?`), so callers may omit them entirely. - * - * Example: - * ```ts - * type Args = InferArgs<{ path: { type: 'string'; required: true }; limit: { type: 'number' } }> - * // → { path: string; limit?: number } - * ``` + * Infer the TypeScript value accepted by an author-facing value schema. + * Output schemas may therefore infer object, array, scalar, or null roots. */ -type InferArgs = Simplify< - & { [K in RequiredKeys]: InferPropValue } - & { [K in Exclude>]?: InferPropValue } -> +type InferValue = + S extends StringValueSchemaSpec ? InferScalar : + S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar : + S extends BooleanValueSchemaSpec ? InferScalar : + S extends NullValueSchemaSpec ? null : + S extends ArrayValueSchemaSpec + ? S extends { items: infer I extends ValueSchemaSpec } ? InferValue[] : JsonValue[] + : S extends ObjectValueSchemaSpec ? InferObject : + S extends JsonValueSchemaSpec ? JsonValue : + S extends OneOfValueSchemaSpec ? InferValue : + never ``` -`defineTool({ name, description, parameters, execute, … })` ties it together: `parameters` is a `SchemaSpec`, `execute(args, exec)` gets `args: InferArgs`, and the helper converts the spec to JSON Schema (`schemaSpecToJsonSchema`) for the wire and validates model-generated args (`validateArgs`) before the typed body runs. A mismatch throws `ToolArgsError` (`code: 'INVALID_ARGS'`), which the registry turns into an `isError` result so the model can self-correct. Why a custom DSL and not schemastery: tool parameters need JSON Schema (the LLM wire format), not validation/transformation — the lightweight DSL gives the best authoring DX with the smallest surface. +```ts type-equiv +/** Infer the TypeScript argument object for an implicit parameter schema. */ +type InferArgs = InferProperties +``` + +`defineTool({ name, description, parameters, execute, … })` ties parameter inference to `parameterSchemaSpecToJsonSchema()` and `validateArgs()`. `valueSchemaSpecToJsonSchema()` compiles value/output declarations through the same enforced raw subset. A parameter mismatch throws `ToolArgsError` (`INVALID_ARGS`), which the registry returns through the normal tool-error path. Raw JSON Schema remains open by default; unsupported keywords reject instead of being accepted without enforcement. Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input and validates only semantic requirements such as a positive finite `timeoutMs`; `schemas()` materializes the explicit model-facing projection at the model boundary so execution and presentation share one resolved definition without leaking callbacks onto the wire. @@ -288,55 +288,57 @@ Call `next()` for the default or return a decision to short-circuit. Pre-policy Post-policy may replace content; a block becomes an `isError` result containing its corrective feedback. `tools/result` receives the frozen execution and result after normalization; observers cannot transform them, and observer failures are contained. Unknown and throwing tools both become structured errors (`ToolNotFoundError` maps to `UNKNOWN_TOOL`), so the call fails without ending the turn. -## The structured-output schema subset +## The enforced raw JSON Schema subset -The vocabulary a caller uses to demand a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`, [subagent.md](subagent.md#the-start-request)) or a workflow `agent()` call. It is deliberately NOT full JSON Schema: the schema travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated client-side by `validateStructuredValue` — so every accepted keyword must be one the validator actually enforces, and `assertSupportedOutputSchema` rejects anything else loud (`OutputSchemaError`, listing every violation). Both walkers reason over own enumerable properties only (JSON carries nothing else) and reject non-plain objects (`Date`, `Map`) that would serialize lossily. +Raw schemas from subagents, workflows, MCP, and dynamic registrations use the wire-level counterpart of the author DSL. `assertSupportedJsonSchema()` accepts any JSON root, `validateJsonSchemaValue()` enforces it, and `JsonSchemaError` reports every unsupported or malformed schema path. The empty annotation-only node means unconstrained lossless JSON. `oneOf` requires at least two branches and a value must match exactly one. Consumers that still require an object root call `assertObjectJsonSchema()` and carry `ObjectJsonSchema`; this is how subagent/workflow caller-defined structured output remains object-rooted without restricting the shared vocabulary. ```ts type-equiv -/** The scalar values `enum`/`const` may carry (finite numbers only). */ -type StructuredScalar = string | number | boolean | null +/** Scalar JSON values supported by `enum` and `const`. */ +type JsonSchemaScalar = string | number | boolean | null ``` ```ts type-equiv -/** The `type` keywords the subset accepts. */ -type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' +/** Single-type keywords accepted by the enforced subset. */ +type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' ``` ```ts type-equiv /** - * One node of the structured-output schema subset. Recursive via `properties` - * and `items`; see the module doc for the exact keyword semantics. + * One raw JSON Schema node in the enforced subset. The optional fields express + * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid + * combinations before a caller treats the node as trusted. */ -interface StructuredSchemaNode { - type: StructuredSchemaType +interface JsonSchemaNode { + /** Omit with no constraints for any JSON value, or use `oneOf`. */ + type?: JsonSchemaType + /** Exactly one branch must validate; at least two branches are required. */ + oneOf?: JsonSchemaNode[] /** Nested property schemas (`type: 'object'` only). */ - properties?: Record + properties?: Record /** Required property names; each must appear in `properties`. */ required?: string[] - /** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */ + /** `false` rejects undeclared keys; absent/`true` follows JSON Schema's open default. */ additionalProperties?: boolean - /** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */ - items?: StructuredSchemaNode - /** Allowed values (scalar types only). */ - enum?: StructuredScalar[] - /** The single allowed value (scalar types only). */ - const?: StructuredScalar + /** Item schema (`type: 'array'` only); absent accepts any JSON item. */ + items?: JsonSchemaNode + /** Allowed values for a scalar node. */ + enum?: JsonSchemaScalar[] + /** The single allowed value for a scalar node. */ + const?: JsonSchemaScalar /** Annotation, ignored for validation. */ description?: string /** Annotation, ignored for validation. */ title?: string - /** Annotation, ignored for validation (must still be JSON data). */ - default?: unknown - /** Annotation, ignored for validation (must still be JSON data). */ - examples?: unknown + /** Annotation, ignored for validation but required to be lossless JSON. */ + default?: JsonValue + /** Annotation, ignored for validation but required to be lossless JSON. */ + examples?: JsonValue } ``` -A schema is an object-rooted node (`enum`/`const` are scalar-only; `description`/`title`/`default`/`examples` are annotations, allowed and ignored but still required to be JSON data — they ride the wire): - ```ts type-equiv -/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */ -type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } +/** A consumer-constrained object-rooted schema. */ +type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } ``` ## Tool-presentation UI vocabulary diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 99acf07d24..6185522793 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -41,11 +41,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:80`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:131`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:104`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:95`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:121`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 6196a71521..eaf24e38a7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -45,6 +45,7 @@ Ask the user a concise question when you need confirmation, a choice, or missing "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -63,6 +64,7 @@ Ask the user a concise question when you need confirmation, a choice, or missing "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", "items": { "type": "object", + "additionalProperties": true, "properties": { "label": { "type": "string", @@ -201,7 +203,7 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor ### `cordis_mount` -Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. +Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. ```json { @@ -659,6 +661,7 @@ Record and update a structured task list for the current work. Send the ENTIRE l "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -720,6 +723,7 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -738,6 +742,7 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -769,7 +774,8 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim }, "args": { "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true } }, "required": [ diff --git a/docs/user/develop/basic/tool.i18n.yaml b/docs/user/develop/basic/tool.i18n.yaml index d2f4343cf1..b741ccf65f 100644 --- a/docs/user/develop/basic/tool.i18n.yaml +++ b/docs/user/develop/basic/tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -tool.md: 416733bcb584fa5303a8b3ba5e6e904302e7f992 -tool.zh.md: fce9a7d9b973853c8b4fb9ae2c034e749d8da999 +tool.md: 17adbfc5f7eb804856dfe39b4d2b4a65374b6414 +tool.zh.md: 8857e16ca836dfa5b158a68581bd3c715dcb1ac5 diff --git a/docs/user/develop/basic/tool.md b/docs/user/develop/basic/tool.md index 416733bcb5..17adbfc5f7 100644 --- a/docs/user/develop/basic/tool.md +++ b/docs/user/develop/basic/tool.md @@ -37,10 +37,11 @@ export function apply(ctx: Context) { ```ts export const parameters = { path: { type: 'string', required: true }, - limit: { type: 'number' }, + limit: { type: 'integer' }, recursive: { type: 'boolean' }, + parent: { type: 'null' }, } -// Inferred type: { path: string; limit?: number; recursive?: boolean } +// Inferred type: { path: string; limit?: number; recursive?: boolean; parent?: null } ``` ### Enums @@ -49,7 +50,7 @@ export const parameters = { export const parameters = { mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] }, } -// Inferred type: { mode: string } (enum values are validated at runtime) +// Inferred type: { mode: 'read' | 'write' | 'append' } ``` ### Nested objects @@ -58,13 +59,14 @@ export const parameters = { export const parameters = { options: { type: 'object', + additionalProperties: true, properties: { timeout: { type: 'number' }, retries: { type: 'number' }, }, }, } -// Inferred type: { options?: { timeout?: number; retries?: number } } +// The declared fields are inferred; additional JSON-valued keys are allowed. ``` ### Arrays @@ -83,12 +85,16 @@ export const parameters = { | Field | Type | Meaning | |------|------|------| -| `type` | `'string' \| 'number' \| 'boolean' \| 'object' \| 'array'` | Value type | +| `type` | `'string' \| 'number' \| 'integer' \| 'boolean' \| 'null' \| 'object' \| 'array' \| 'json'` | Value type; `json` accepts any lossless JSON value | | `required` | `true` | Marks the property required and affects inference | | `description` | `string` | Description sent to the model | -| `enum` | `string[]` | Allowed string values | -| `properties` | `SchemaSpec` | Nested properties for an object | -| `items` | `SchemaProp` | Element schema for an array | +| `enum` / `const` | matching scalar values | Allowed literal values, checked at author and runtime boundaries | +| `properties` | `ParameterSchemaSpec` | Nested properties for an object | +| `additionalProperties` | `true \| false` | Required on every explicit object node | +| `items` | `ValueSchemaSpec` | Element schema for an array | +| `oneOf` | at least two `ValueSchemaSpec` branches | Requires exactly one matching branch; used instead of `type` | + +The outer `parameters` map is an implicit open object. Explicit nested objects choose their openness; raw JSON Schema registered without `defineTool` keeps JSON Schema's open-by-default behavior. ## The execute function diff --git a/docs/user/develop/basic/tool.zh.md b/docs/user/develop/basic/tool.zh.md index fce9a7d9b9..8857e16ca8 100644 --- a/docs/user/develop/basic/tool.zh.md +++ b/docs/user/develop/basic/tool.zh.md @@ -37,10 +37,11 @@ export function apply(ctx: Context) { ```ts export const parameters = { path: { type: 'string', required: true }, - limit: { type: 'number' }, + limit: { type: 'integer' }, recursive: { type: 'boolean' }, + parent: { type: 'null' }, } -// Inferred type: { path: string; limit?: number; recursive?: boolean } +// Inferred type: { path: string; limit?: number; recursive?: boolean; parent?: null } ``` ### 枚举 @@ -49,7 +50,7 @@ export const parameters = { export const parameters = { mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] }, } -// Inferred type: { mode: string } (enum values are validated at runtime) +// Inferred type: { mode: 'read' | 'write' | 'append' } ``` ### 嵌套对象 @@ -58,13 +59,14 @@ export const parameters = { export const parameters = { options: { type: 'object', + additionalProperties: true, properties: { timeout: { type: 'number' }, retries: { type: 'number' }, }, }, } -// Inferred type: { options?: { timeout?: number; retries?: number } } +// The declared fields are inferred; additional JSON-valued keys are allowed. ``` ### 数组 @@ -83,12 +85,16 @@ export const parameters = { | 字段 | 类型 | 说明 | |------|------|------| -| `type` | `'string' \| 'number' \| 'boolean' \| 'object' \| 'array'` | 值类型 | +| `type` | `'string' \| 'number' \| 'integer' \| 'boolean' \| 'null' \| 'object' \| 'array' \| 'json'` | 值类型;`json` 接受任意无损 JSON 值 | | `required` | `true` | 标记为必填(影响类型推导) | | `description` | `string` | 发送给模型的描述 | -| `enum` | `string[]` | 允许的枚举值 | -| `properties` | `SchemaSpec` | 嵌套属性(type 为 object 时) | -| `items` | `SchemaProp` | 数组元素 schema(type 为 array 时) | +| `enum` / `const` | 匹配类型的标量值 | 允许的字面量值,在编写和运行时边界校验 | +| `properties` | `ParameterSchemaSpec` | 对象的嵌套属性 | +| `additionalProperties` | `true \| false` | 每个显式对象节点都必须声明 | +| `items` | `ValueSchemaSpec` | 数组的元素 schema | +| `oneOf` | 至少两个 `ValueSchemaSpec` 分支 | 要求恰好匹配一个分支;代替 `type` 使用 | + +外层 `parameters` 映射是一个隐式的开放对象。显式嵌套对象需自行选择是否开放;不通过 `defineTool` 注册的原始 JSON Schema 保持 JSON Schema 的默认开放语义。 ## execute 函数 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index e5773c1319..3e111b8ba2 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -36,6 +36,8 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only The available tools: ```ts +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + declare const tools: { /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { @@ -53,31 +55,31 @@ declare const tools: { sandbox_permissions?: "workspace-write" | "danger-full-access"; /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; - }): Promise; + } & Record): Promise; /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */ cordis_inspect(args: { /** Limit the report to one section. Omit for all sections. */ what?: "services" | "plugins" | "tools" | "dynamic" | "api" | "events"; /** Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events". */ name?: string; - }): Promise; - /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ + } & Record): Promise; + /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ cordis_mount(args: { /** Body of an async JS function; must `return` the plugin to mount. */ code: string; - }): Promise; + } & Record): Promise; /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */ cordis_unmount(args: { /** The dynamic mount id returned by cordis_mount (e.g. "dyn-1"). */ id: string; - }): Promise; + } & Record): Promise; /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ create_goal(args: { /** The concrete completion objective inferred from the direct human request. */ objective: string; /** Optional positive safe-integer limit on automatic continuation rounds. */ max_goal_rounds?: number; - }): Promise; + } & Record): Promise; /** Edit an existing UTF-8 text file by replacing literal text. */ edit(args: { /** Path to edit, resolved by the filesystem backend. */ @@ -92,16 +94,16 @@ declare const tools: { sandbox_permissions?: "workspace-write" | "danger-full-access"; /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ justification?: string; - }): Promise; + } & Record): Promise; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ - get_goal(args: Record): Promise; + get_goal(args: Record): Promise; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph(args: { /** The immutable completion objective for every fresh Ralph round. */ objective: string; /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ maxRounds?: number; - }): Promise; + } & Record): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { /** Path to read, resolved by the filesystem backend. */ @@ -110,12 +112,12 @@ declare const tools: { offset?: number; /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; - }): Promise; + } & Record): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ name: string; - }): Promise; + } & Record): Promise; /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ subagent(args: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -124,7 +126,7 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ subagent_fork(args: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -133,16 +135,16 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ task_kill(args: { /** Task id returned by the tool that started the background work. */ task_id: string; /** Optional short reason, recorded in the log and forwarded to the task. */ reason?: string; - }): Promise; + } & Record): Promise; /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list(args: Record): Promise; + task_list(args: Record): Promise; /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ task_output(args: { /** Task id returned by the tool that started the background work. */ @@ -151,7 +153,7 @@ declare const tools: { wait?: boolean; /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ timeout_ms?: number; - }): Promise; + } & Record): Promise; /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ todo_write(args: { /** The COMPLETE task list, replacing any previous list. */ @@ -160,8 +162,8 @@ declare const tools: { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - })[]; - }): Promise; + } & Record)[]; + } & Record): Promise; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal(args: { /** Exact id returned by get_goal. */ @@ -176,7 +178,7 @@ declare const tools: { max_goal_rounds?: number; /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; - }): Promise; + } & Record): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ @@ -190,7 +192,7 @@ declare const tools: { /** Optional guidance on when this workflow applies. */ whenToUse?: string; /** Optional phase declarations matched by phase() calls. */ - phases?: { + phases?: ({ /** The phase title phase() calls match by exact string. */ title: string; /** Optional one-line description of the phase. */ @@ -199,11 +201,11 @@ declare const tools: { provider?: string; /** Optional model override this phase is expected to use. */ model?: string; - }[]; - }; + } & Record)[]; + } & Record; /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ - args?: Record; - }): Promise; + args?: Record; + } & Record): Promise; /** Create or fully replace a UTF-8 text file. */ write(args: { /** Path to write, resolved by the filesystem backend. */ @@ -214,6 +216,6 @@ declare const tools: { sandbox_permissions?: "workspace-write" | "danger-full-access"; /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ justification?: string; - }): Promise; + } & Record): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 71597bc9c3..95cdd43b83 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -72,7 +72,7 @@ }, { "name": "cordis_mount", - "description": "Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.", + "description": "Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.", "parameters": { "type": "object", "properties": { @@ -361,6 +361,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -446,6 +447,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -464,6 +466,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -495,7 +498,8 @@ }, "args": { "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index ac1114d0a3..2ec278e294 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -36,6 +36,8 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only The available tools: ```ts +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + declare const tools: { /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { @@ -53,14 +55,14 @@ declare const tools: { sandbox_permissions?: "workspace-write" | "danger-full-access"; /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; - }): Promise; + } & Record): Promise; /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ create_goal(args: { /** The concrete completion objective inferred from the direct human request. */ objective: string; /** Optional positive safe-integer limit on automatic continuation rounds. */ max_goal_rounds?: number; - }): Promise; + } & Record): Promise; /** Edit an existing UTF-8 text file by replacing literal text. */ edit(args: { /** Path to edit, resolved by the filesystem backend. */ @@ -75,16 +77,16 @@ declare const tools: { sandbox_permissions?: "workspace-write" | "danger-full-access"; /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ justification?: string; - }): Promise; + } & Record): Promise; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ - get_goal(args: Record): Promise; + get_goal(args: Record): Promise; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph(args: { /** The immutable completion objective for every fresh Ralph round. */ objective: string; /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ maxRounds?: number; - }): Promise; + } & Record): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { /** Path to read, resolved by the filesystem backend. */ @@ -93,12 +95,12 @@ declare const tools: { offset?: number; /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; - }): Promise; + } & Record): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ name: string; - }): Promise; + } & Record): Promise; /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ subagent(args: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -107,7 +109,7 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ subagent_fork(args: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -116,16 +118,16 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ task_kill(args: { /** Task id returned by the tool that started the background work. */ task_id: string; /** Optional short reason, recorded in the log and forwarded to the task. */ reason?: string; - }): Promise; + } & Record): Promise; /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list(args: Record): Promise; + task_list(args: Record): Promise; /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ task_output(args: { /** Task id returned by the tool that started the background work. */ @@ -134,7 +136,7 @@ declare const tools: { wait?: boolean; /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ timeout_ms?: number; - }): Promise; + } & Record): Promise; /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ todo_write(args: { /** The COMPLETE task list, replacing any previous list. */ @@ -143,8 +145,8 @@ declare const tools: { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - })[]; - }): Promise; + } & Record)[]; + } & Record): Promise; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal(args: { /** Exact id returned by get_goal. */ @@ -159,7 +161,7 @@ declare const tools: { max_goal_rounds?: number; /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; - }): Promise; + } & Record): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ @@ -173,7 +175,7 @@ declare const tools: { /** Optional guidance on when this workflow applies. */ whenToUse?: string; /** Optional phase declarations matched by phase() calls. */ - phases?: { + phases?: ({ /** The phase title phase() calls match by exact string. */ title: string; /** Optional one-line description of the phase. */ @@ -182,11 +184,11 @@ declare const tools: { provider?: string; /** Optional model override this phase is expected to use. */ model?: string; - }[]; - }; + } & Record)[]; + } & Record; /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ - args?: Record; - }): Promise; + args?: Record; + } & Record): Promise; /** Create or fully replace a UTF-8 text file. */ write(args: { /** Path to write, resolved by the filesystem backend. */ @@ -197,6 +199,6 @@ declare const tools: { sandbox_permissions?: "workspace-write" | "danger-full-access"; /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ justification?: string; - }): Promise; + } & Record): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index ab52ec415d..8406669edc 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -304,6 +304,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -389,6 +390,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -407,6 +409,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -438,7 +441,8 @@ }, "args": { "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index ac1114d0a3..2ec278e294 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -36,6 +36,8 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only The available tools: ```ts +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + declare const tools: { /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { @@ -53,14 +55,14 @@ declare const tools: { sandbox_permissions?: "workspace-write" | "danger-full-access"; /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; - }): Promise; + } & Record): Promise; /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ create_goal(args: { /** The concrete completion objective inferred from the direct human request. */ objective: string; /** Optional positive safe-integer limit on automatic continuation rounds. */ max_goal_rounds?: number; - }): Promise; + } & Record): Promise; /** Edit an existing UTF-8 text file by replacing literal text. */ edit(args: { /** Path to edit, resolved by the filesystem backend. */ @@ -75,16 +77,16 @@ declare const tools: { sandbox_permissions?: "workspace-write" | "danger-full-access"; /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ justification?: string; - }): Promise; + } & Record): Promise; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ - get_goal(args: Record): Promise; + get_goal(args: Record): Promise; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph(args: { /** The immutable completion objective for every fresh Ralph round. */ objective: string; /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ maxRounds?: number; - }): Promise; + } & Record): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { /** Path to read, resolved by the filesystem backend. */ @@ -93,12 +95,12 @@ declare const tools: { offset?: number; /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; - }): Promise; + } & Record): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ name: string; - }): Promise; + } & Record): Promise; /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ subagent(args: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -107,7 +109,7 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ subagent_fork(args: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -116,16 +118,16 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ task_kill(args: { /** Task id returned by the tool that started the background work. */ task_id: string; /** Optional short reason, recorded in the log and forwarded to the task. */ reason?: string; - }): Promise; + } & Record): Promise; /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list(args: Record): Promise; + task_list(args: Record): Promise; /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ task_output(args: { /** Task id returned by the tool that started the background work. */ @@ -134,7 +136,7 @@ declare const tools: { wait?: boolean; /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ timeout_ms?: number; - }): Promise; + } & Record): Promise; /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ todo_write(args: { /** The COMPLETE task list, replacing any previous list. */ @@ -143,8 +145,8 @@ declare const tools: { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - })[]; - }): Promise; + } & Record)[]; + } & Record): Promise; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal(args: { /** Exact id returned by get_goal. */ @@ -159,7 +161,7 @@ declare const tools: { max_goal_rounds?: number; /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; - }): Promise; + } & Record): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ @@ -173,7 +175,7 @@ declare const tools: { /** Optional guidance on when this workflow applies. */ whenToUse?: string; /** Optional phase declarations matched by phase() calls. */ - phases?: { + phases?: ({ /** The phase title phase() calls match by exact string. */ title: string; /** Optional one-line description of the phase. */ @@ -182,11 +184,11 @@ declare const tools: { provider?: string; /** Optional model override this phase is expected to use. */ model?: string; - }[]; - }; + } & Record)[]; + } & Record; /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ - args?: Record; - }): Promise; + args?: Record; + } & Record): Promise; /** Create or fully replace a UTF-8 text file. */ write(args: { /** Path to write, resolved by the filesystem backend. */ @@ -197,6 +199,6 @@ declare const tools: { sandbox_permissions?: "workspace-write" | "danger-full-access"; /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ justification?: string; - }): Promise; + } & Record): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index ac1114d0a3..2ec278e294 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -36,6 +36,8 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only The available tools: ```ts +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + declare const tools: { /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { @@ -53,14 +55,14 @@ declare const tools: { sandbox_permissions?: "workspace-write" | "danger-full-access"; /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; - }): Promise; + } & Record): Promise; /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ create_goal(args: { /** The concrete completion objective inferred from the direct human request. */ objective: string; /** Optional positive safe-integer limit on automatic continuation rounds. */ max_goal_rounds?: number; - }): Promise; + } & Record): Promise; /** Edit an existing UTF-8 text file by replacing literal text. */ edit(args: { /** Path to edit, resolved by the filesystem backend. */ @@ -75,16 +77,16 @@ declare const tools: { sandbox_permissions?: "workspace-write" | "danger-full-access"; /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ justification?: string; - }): Promise; + } & Record): Promise; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ - get_goal(args: Record): Promise; + get_goal(args: Record): Promise; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph(args: { /** The immutable completion objective for every fresh Ralph round. */ objective: string; /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ maxRounds?: number; - }): Promise; + } & Record): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { /** Path to read, resolved by the filesystem backend. */ @@ -93,12 +95,12 @@ declare const tools: { offset?: number; /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; - }): Promise; + } & Record): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ name: string; - }): Promise; + } & Record): Promise; /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ subagent(args: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -107,7 +109,7 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ subagent_fork(args: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -116,16 +118,16 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ task_kill(args: { /** Task id returned by the tool that started the background work. */ task_id: string; /** Optional short reason, recorded in the log and forwarded to the task. */ reason?: string; - }): Promise; + } & Record): Promise; /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list(args: Record): Promise; + task_list(args: Record): Promise; /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ task_output(args: { /** Task id returned by the tool that started the background work. */ @@ -134,7 +136,7 @@ declare const tools: { wait?: boolean; /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ timeout_ms?: number; - }): Promise; + } & Record): Promise; /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ todo_write(args: { /** The COMPLETE task list, replacing any previous list. */ @@ -143,8 +145,8 @@ declare const tools: { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - })[]; - }): Promise; + } & Record)[]; + } & Record): Promise; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal(args: { /** Exact id returned by get_goal. */ @@ -159,7 +161,7 @@ declare const tools: { max_goal_rounds?: number; /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; - }): Promise; + } & Record): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ @@ -173,7 +175,7 @@ declare const tools: { /** Optional guidance on when this workflow applies. */ whenToUse?: string; /** Optional phase declarations matched by phase() calls. */ - phases?: { + phases?: ({ /** The phase title phase() calls match by exact string. */ title: string; /** Optional one-line description of the phase. */ @@ -182,11 +184,11 @@ declare const tools: { provider?: string; /** Optional model override this phase is expected to use. */ model?: string; - }[]; - }; + } & Record)[]; + } & Record; /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ - args?: Record; - }): Promise; + args?: Record; + } & Record): Promise; /** Create or fully replace a UTF-8 text file. */ write(args: { /** Path to write, resolved by the filesystem backend. */ @@ -197,6 +199,6 @@ declare const tools: { sandbox_permissions?: "workspace-write" | "danger-full-access"; /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ justification?: string; - }): Promise; + } & Record): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json index 1bfe74b704..4ee311eb65 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json @@ -288,6 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -373,6 +374,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -391,6 +393,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -422,7 +425,8 @@ }, "args": { "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true } }, "required": [ @@ -755,6 +759,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -840,6 +845,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -858,6 +864,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -889,7 +896,8 @@ }, "args": { "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json index 1bfe74b704..4ee311eb65 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json @@ -288,6 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -373,6 +374,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -391,6 +393,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -422,7 +425,8 @@ }, "args": { "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true } }, "required": [ @@ -755,6 +759,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -840,6 +845,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -858,6 +864,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -889,7 +896,8 @@ }, "args": { "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index 52b3c1812e..de3254fd2e 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -288,6 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -373,6 +374,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -391,6 +393,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -422,7 +425,8 @@ }, "args": { "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index 52b3c1812e..de3254fd2e 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -288,6 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -373,6 +374,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -391,6 +393,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -422,7 +425,8 @@ }, "args": { "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index 52b3c1812e..de3254fd2e 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -288,6 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -373,6 +374,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -391,6 +393,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -422,7 +425,8 @@ }, "args": { "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true } }, "required": [ diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 0feec1729a..6098056fb9 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -206,7 +206,7 @@ export class BashEnvRegistry extends Service { } } -/** Parsed tool args; execute validates value constraints absent from SchemaSpec. */ +/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ interface BashToolArgs { command: string description: string diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 04c18264b1..b2a78ff9fe 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -287,7 +287,7 @@ describe('bash tool', () => { }) // Type and required-key violations are rejected by the harness - // (defineTool validates against the SchemaSpec — the arg-validation Agent Note) before execute. + // (defineTool validates against the ParameterSchemaSpec — the arg-validation Agent Note) before execute. it.each([ [{}, /missing required property "command"/], [{ command: 42, description: 'd' }, /"command" must be a string/], @@ -303,7 +303,7 @@ describe('bash tool', () => { expect(text(result)).toMatch(pattern) }) - // Value constraints the SchemaSpec can't express stay in the tool body. + // Value constraints the ParameterSchemaSpec can't express stay in the tool body. it.each([ [{ command: ' ', description: 'd' }, /invalid command/], [{ command: 'x', description: ' ' }, /invalid description/], diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 20ea51c087..23ffa88923 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1336,6 +1336,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'InjectOptions', declaration: 'export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n}', }, + { + name: 'JsonSchemaNode', + declaration: 'export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n}', + }, + { + name: 'JsonSchemaScalar', + declaration: 'export type JsonSchemaScalar = string | number | boolean | null;', + }, + { + name: 'JsonSchemaType', + declaration: 'export type JsonSchemaType = \'object\' | \'array\' | \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\';', + }, { name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', @@ -1368,6 +1380,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'MessageSourceMap', declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}', }, + { + name: 'ObjectJsonSchema', + declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', + }, { name: 'PresetOption', declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}', @@ -1544,22 +1560,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'StreamChunk', declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};', }, - { - name: 'StructuredOutputSchema', - declaration: 'export type StructuredOutputSchema = StructuredSchemaNode & {\n type: \'object\';\n};', - }, - { - name: 'StructuredScalar', - declaration: 'export type StructuredScalar = string | number | boolean | null;', - }, - { - name: 'StructuredSchemaNode', - declaration: 'export interface StructuredSchemaNode {\n type: StructuredSchemaType;\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: StructuredSchemaNode;\n enum?: StructuredScalar[];\n const?: StructuredScalar;\n description?: string;\n title?: string;\n default?: unknown;\n examples?: unknown;\n}', - }, - { - name: 'StructuredSchemaType', - declaration: 'export type StructuredSchemaType = \'object\' | \'array\' | \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\';', - }, { name: 'SubagentCapabilities', declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', @@ -1578,7 +1578,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentStartRequest', - declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: StructuredOutputSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}', + declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}', }, { name: 'SubagentStopReason', diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index 2198533376..c55fd34b78 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -1,5 +1,5 @@ /** - * The registration boundary between sandboxed mount code and the real runtime: SchemaSpec + * The registration boundary between sandboxed mount code and the real runtime: ParameterSchemaSpec * normalization + validation with teaching errors, the marker-guarded `harness.defineTool` / * `harness.registerTool` pair, the SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives * in place of the real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox @@ -15,12 +15,13 @@ import { Context } from 'cordis' import type { Plugin } from 'cordis' import { scopeOf } from '@deepseek-ai/dsh-scope' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { assertSupportedJsonSchema, defineTool } from '@deepseek-ai/dsh-tools' import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools' const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool') -const SCHEMA_TYPES = new Set(['string', 'number', 'boolean', 'object', 'array']) -const VALID_TYPES = '\'string\' | \'number\' | \'boolean\' | \'object\' | \'array\'' +const SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array', 'json']) +const VALID_TYPES = '\'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\'' +const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const type DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true } type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown } @@ -29,70 +30,196 @@ function isPlainRecord(value: unknown): value is Record { return Object.prototype.toString.call(value) === '[object Object]' } +/** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */ +function cloneJson(value: unknown, path: string, seen = new Set()): unknown { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value + if (typeof value === 'number') { + if (Number.isFinite(value) && !Object.is(value, -0)) return value + throw new Error(`harness.defineTool ${path} must be lossless JSON data`) + } + if (typeof value !== 'object') throw new Error(`harness.defineTool ${path} must be lossless JSON data`) + if (seen.has(value)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`) + seen.add(value) + try { + if (Array.isArray(value)) { + const output: unknown[] = [] + for (let index = 0; index < value.length; index++) { + if (!Object.hasOwn(value, index)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`) + output.push(cloneJson(value[index], `${path}[${index}]`, seen)) + } + return output + } + if (!isPlainRecord(value)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`) + const output: Record = {} + for (const [key, entry] of Object.entries(value)) output[key] = cloneJson(entry, `${path}.${key}`, seen) + return output + } finally { + seen.delete(value) + } +} + +/** Copy and realm-materialize the shared annotation vocabulary. */ +function copyAnnotations(value: Record, output: Record, path: string): void { + if (Object.hasOwn(value, 'description')) output.description = value.description + if (Object.hasOwn(value, 'title')) output.title = value.title + if (Object.hasOwn(value, 'default')) output.default = cloneJson(value.default, `${path}.default`) + if (Object.hasOwn(value, 'examples')) output.examples = cloneJson(value.examples, `${path}.examples`) +} + +/** Reject sandbox schema keys that the unified DSL would otherwise ignore. */ +function assertSchemaKeys(value: Record, path: string, allowed: readonly string[]): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) throw new Error(`harness.defineTool ${path}.${key} is not supported by the unified schema DSL`) + } +} + /** * Normalize a sandbox-provided `parameters` value into a fresh host-realm - * SchemaSpec. Accepts the DSL directly, or the JSON-Schema-style - * `{ type: 'object', properties, required: […] }` wrapper models write by - * prior — the wrapper unwraps and its `required` array becomes per-property - * flags (see the module doc). + * ParameterSchemaSpec. A raw JSON-Schema object wrapper retains its open root + * default, while the direct DSL is already an implicit open property map. */ -function normalizeSchemaSpec(value: unknown, path = 'parameters'): Record { +function normalizeParameterSchemaSpec(value: unknown, path = 'parameters'): { + spec: Record + rootAnnotations?: Record +} { if (!isPlainRecord(value)) { - throw new Error(`harness.defineTool ${path} must be a SchemaSpec object`) + throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec object`) } - let entries = value - const requiredNames = new Set() - if (value.type === 'object' && isPlainRecord(value.properties)) { - if (Array.isArray(value.required)) { - for (const name of value.required) requiredNames.add(name) + if (value.type === 'object') { + assertSchemaKeys(value, path, ['type', 'properties', 'required', 'additionalProperties', ...ANNOTATION_KEYS]) + if (!isPlainRecord(value.properties)) { + throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`) + } + if (Object.hasOwn(value, 'additionalProperties') && value.additionalProperties !== true) { + throw new Error(`harness.defineTool ${path}.additionalProperties must be true or omitted because the implicit parameter root is open`) + } + if (Object.hasOwn(value, 'required') && value.required === undefined) { + throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`) + } + const required = normalizeRequiredNames(value.required, value.properties, `${path}.required`) + const rootAnnotations: Record = {} + copyAnnotations(value, rootAnnotations, path) + return { + spec: normalizePropertyMap(value.properties, path, required, true), + ...(Object.keys(rootAnnotations).length === 0 ? {} : { rootAnnotations }), } - entries = value.properties } + return { spec: normalizePropertyMap(value, path, new Set(), false) } +} + +/** Validate raw required names and return their lookup set. */ +function normalizeRequiredNames(value: unknown, properties: Record, path: string): Set { + if (value === undefined) return new Set() + if (!Array.isArray(value) || value.some(name => typeof name !== 'string')) { + throw new Error(`harness.defineTool ${path} must be an array of declared property names`) + } + const names = new Set(value as string[]) + for (const name of names) { + if (!Object.hasOwn(properties, name)) throw new Error(`harness.defineTool ${path} names undeclared property ${JSON.stringify(name)}`) + } + return names +} + +/** Normalize one implicit property map. */ +function normalizePropertyMap( + entries: Record, + path: string, + requiredNames: ReadonlySet, + raw: boolean, +): Record { const spec: Record = {} for (const [key, prop] of Object.entries(entries)) { - spec[key] = normalizeSchemaProp(prop, `${path}.${key}`, requiredNames.has(key)) + spec[key] = normalizeValueSchema(prop, `${path}.${key}`, requiredNames.has(key), raw, true) } return spec } -/** Normalize one property: `integer` → `number`, `required: false` → absent, nested wrappers unwrapped recursively. */ -function normalizeSchemaProp(value: unknown, path: string, forceRequired = false): Record { +/** Normalize one property or nested value schema into the host realm. */ +function normalizeValueSchema( + value: unknown, + path: string, + forceRequired = false, + raw = false, + parameterProperty = false, +): Record { if (!isPlainRecord(value)) { - throw new Error(`harness.defineTool ${path} must be a SchemaSpec property object`) + throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`) } - const type = value.type === 'integer' ? 'number' : value.type - if (!SCHEMA_TYPES.has(type)) { + const requiredKey = parameterProperty && !raw ? ['required'] : [] + if (parameterProperty && raw && Object.hasOwn(value, 'required') && value.type !== 'object') { + throw new Error(`harness.defineTool ${path}.required belongs to the containing raw object schema`) + } + if (parameterProperty && !raw && Object.hasOwn(value, 'required') && value.required !== true) { + throw new Error(`harness.defineTool ${path}.required must be true when present`) + } + const prop: Record = {} + if (forceRequired || value.required === true) prop.required = true + copyAnnotations(value, prop, path) + + if (Object.hasOwn(value, 'oneOf')) { + assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS]) + if (!Array.isArray(value.oneOf)) throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`) + prop.oneOf = value.oneOf.map((branch, index) => normalizeValueSchema(branch, `${path}.oneOf[${index}]`, false, raw)) + return prop + } + + if (raw && !Object.hasOwn(value, 'type')) { + assertSchemaKeys(value, path, ANNOTATION_KEYS) + prop.type = 'json' + return prop + } + if (!SCHEMA_TYPES.has(value.type) || raw && value.type === 'json') { throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`) } - // On an object property a JSON-Schema-style `required` ARRAY names required - // children (handled by the nested unwrap below); everywhere else `required` - // must be a boolean, and `false` means optional. - const nestedRequiredArray = type === 'object' && Array.isArray(value.required) - if (value.required !== undefined && typeof value.required !== 'boolean' && !nestedRequiredArray) { - throw new Error(`harness.defineTool ${path}.required must be a boolean when present`) - } - const prop: Record = { type } - if (forceRequired || value.required === true) prop.required = true - if (typeof value.description === 'string') prop.description = value.description - if (Array.isArray(value.enum)) prop.enum = [...value.enum as unknown[]] - if (value.default !== undefined) prop.default = value.default - if (value.properties !== undefined) { - if (type !== 'object') { - throw new Error(`harness.defineTool ${path}.properties is only valid for type "object"`) + const type = value.type + prop.type = type + + switch (type) { + case 'object': { + assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(raw ? ['required'] : []), ...ANNOTATION_KEYS]) + if (!raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) { + throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`) + } + if (raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') { + throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`) + } + if (raw && Object.hasOwn(value, 'required') && value.required === undefined) { + throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`) + } + prop.additionalProperties = raw ? value.additionalProperties ?? true : value.additionalProperties + if (Object.hasOwn(value, 'properties')) { + if (!isPlainRecord(value.properties)) throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`) + const nestedRequired = raw ? normalizeRequiredNames(value.required, value.properties, `${path}.required`) : new Set() + prop.properties = normalizePropertyMap(value.properties, `${path}.properties`, nestedRequired, raw) + } else if (raw && value.required !== undefined) { + normalizeRequiredNames(value.required, {}, `${path}.required`) + } + return prop } - // Re-wrap so the nested unwrap applies a nested `required` array too. - prop.properties = normalizeSchemaSpec( - { type: 'object', properties: value.properties, required: value.required }, - `${path}.properties`, - ) + case 'array': + assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS]) + if (Object.hasOwn(value, 'items')) prop.items = normalizeValueSchema(value.items, `${path}.items`, false, raw) + return prop + case 'string': + case 'number': + case 'integer': + case 'boolean': + case 'null': + assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS]) + if (Object.hasOwn(value, 'enum')) { + prop.enum = Array.isArray(value.enum) + ? value.enum.map((entry, index) => cloneJson(entry, `${path}.enum[${index}]`)) + : value.enum + } + if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `${path}.const`) + return prop + case 'json': + assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS]) + return prop + /* v8 ignore next 2 -- SCHEMA_TYPES narrows this closed switch before dispatch. */ + default: + throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES}`) } - if (value.items !== undefined) { - if (type !== 'array') { - throw new Error(`harness.defineTool ${path}.items is only valid for type "array"`) - } - prop.items = normalizeSchemaProp(value.items, `${path}.items`) - } - return prop } function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition { @@ -160,19 +287,22 @@ function assertExecuteReturn(value: unknown): ToolExecuteReturn { /** * The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized - * into a fresh host-realm SchemaSpec (JSON-Schema wrapper unwrapped, `integer` mapped, - * `required: false` dropped) and the tool's `execute` return normalized into the host realm + * into a fresh host-realm ParameterSchemaSpec (raw object wrappers unwrapped, + * required arrays mapped, and explicit DSL object openness enforced) and the tool's `execute` return normalized into the host realm * via a JSON round-trip. Non-JSON or wrong-shape output fails that call instead of poisoning * the session log. - * @param options - the standard `defineTool` options; `parameters` may be the SchemaSpec DSL or a JSON-Schema-style wrapper. + * @param options - the standard `defineTool` options; `parameters` may be the ParameterSchemaSpec DSL or a JSON-Schema-style wrapper. * @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts. */ export function sandboxDefineTool(options: Parameters[0]): ToolDefinition { - const parameters = normalizeSchemaSpec((options as { parameters?: unknown }).parameters) - const tool = defineTool({ ...options, parameters } as Parameters[0]) + const normalized = normalizeParameterSchemaSpec((options as { parameters?: unknown }).parameters) + const tool = defineTool({ ...options, parameters: normalized.spec } as Parameters[0]) + const parameters = { ...tool.parameters, ...normalized.rootAnnotations } + assertSupportedJsonSchema(parameters) const execute = tool.execute.bind(tool) return markDynamicTool({ ...tool, + parameters, async execute(args, exec) { // JSON.stringify yields NO JSON for an undefined (or function/symbol) // return despite its string-typed signature — route that into diff --git a/packages/cordis/tool-cordis/src/index.ts b/packages/cordis/tool-cordis/src/index.ts index 4fc5bd4328..9ac29aeb07 100644 --- a/packages/cordis/tool-cordis/src/index.ts +++ b/packages/cordis/tool-cordis/src/index.ts @@ -121,9 +121,9 @@ export function apply(ctx: Context, config: Config): void { + '`harness.registerTool(ctx, harness.defineTool({ name, description, parameters: ' + '{ text: { type: \'string\', required: true } }, async execute(args) { … } }))` ' + 'to give yourself a new tool — it becomes callable on your NEXT step. ' - + 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'boolean\'|\'object\'|\'array\', ' - + 'required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style ' - + '{ type: \'object\', properties, required: […] } wrapper and type \'integer\' are also accepted and normalized. A ' + + 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'integer\'|\'boolean\'|\'null\'|\'object\'|\'array\'|\'json\', ' + + 'required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and ' + + 'oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: \'object\', properties, required?: […] } wrapper is also accepted with open-by-default objects. A ' + 'tool\'s `execute` MUST return an ARRAY of content blocks, e.g. `return ' + '[{ type: \'text\', text: someString }]` — never a bare string. ' + 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and ' diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index 2ddee8dbca..42af6fc64e 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -156,11 +156,14 @@ describe('cordis_mount', () => { description: 'written in the JSON-Schema dialect', parameters: { type: 'object', + title: 'Raw parameters', + default: { text: 'default' }, + examples: [{ text: 'example' }], properties: { text: { type: 'string', description: 'the text' }, count: { type: 'integer', default: 1 }, mode: { type: 'string', enum: ['fast', 'slow'] }, - extra: { type: 'string', required: false }, + extra: { type: 'string' }, }, required: ['text'], }, @@ -173,14 +176,19 @@ describe('cordis_mount', () => { expect(result.isError).toBe(false) // The registered schema is canonical JSON Schema derived from the DSL: - // the required array survived, integer became number, extra is optional. + // the required array survived, integer stayed integer, extra is optional. const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')! const parameters = schema.parameters as { properties: Record required?: string[] } expect(parameters.required).toEqual(['text']) - expect(parameters.properties.count!.type).toBe('number') + expect(parameters).toMatchObject({ + title: 'Raw parameters', + default: { text: 'default' }, + examples: [{ text: 'example' }], + }) + expect(parameters.properties.count!.type).toBe('integer') expect(parameters.properties.count!.default).toBe(1) expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow']) // Arg validation enforces the normalized spec: text required, extra not. @@ -202,7 +210,10 @@ describe('cordis_mount', () => { name: 'nested_json_schema_tool', description: 'nested dialect', parameters: { - cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] }, + type: 'object', + properties: { + cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] }, + }, }, async execute(args) { return [{ type: 'text', text: args.cfg.label }] }, })) @@ -217,14 +228,123 @@ describe('cordis_mount', () => { expect(text(await call(ctx, 'nested_json_schema_tool', { cfg: { label: 'hi' } }))).toBe('hi') }) + it('normalizes every unified DSL node and lossless annotation shape across the sandbox realm', async () => { + const ctx = await setup() + const result = await call(ctx, 'cordis_mount', { + code: ` + return { + name: 'unified-schema', + inject: ['tools'], + apply(ctx) { + harness.registerTool(ctx, harness.defineTool({ + name: 'unified_schema_tool', + description: 'all unified nodes', + parameters: { + any: { + type: 'json', + title: 'Any JSON', + default: { nested: [1, 'x', null] }, + examples: [{ ok: true }], + }, + choice: { + oneOf: [{ type: 'string', const: 'x' }, { type: 'null' }], + required: true, + }, + flags: { type: 'array' }, + closed: { type: 'object', additionalProperties: false }, + count: { type: 'number', enum: [1, 2], const: 1 }, + }, + async execute(args) { return [{ type: 'text', text: String(args.choice) }] }, + })) + }, + } + `, + }) + expect(result.isError).toBe(false) + const schema = ctx.tools.schemas().find(s => s.name === 'unified_schema_tool')! + expect(schema.parameters).toMatchObject({ + properties: { + any: { title: 'Any JSON', default: { nested: [1, 'x', null] }, examples: [{ ok: true }] }, + choice: { oneOf: [{ type: 'string', const: 'x' }, { type: 'null' }] }, + flags: { type: 'array' }, + closed: { type: 'object', additionalProperties: false }, + count: { type: 'number', enum: [1, 2], const: 1 }, + }, + required: ['choice'], + }) + }) + + it('normalizes unconstrained and closed nested nodes from a raw JSON Schema wrapper', async () => { + const ctx = await setup() + const result = await call(ctx, 'cordis_mount', { + code: ` + return { + name: 'raw-unified-schema', + inject: ['tools'], + apply(ctx) { + harness.registerTool(ctx, harness.defineTool({ + name: 'raw_unified_schema_tool', + description: 'raw unified nodes', + parameters: { + type: 'object', + additionalProperties: true, + properties: { + any: { description: 'unconstrained' }, + cfg: { + type: 'object', + additionalProperties: false, + properties: { label: { type: 'string' } }, + required: ['label'], + }, + choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] }, + }, + }, + async execute() { return [] }, + })) + }, + } + `, + }) + expect(result.isError).toBe(false) + expect(ctx.tools.schemas().find(s => s.name === 'raw_unified_schema_tool')!.parameters).toMatchObject({ + properties: { + any: {}, + cfg: { additionalProperties: false, required: ['label'] }, + choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] }, + }, + }) + }) + it.each([ - ['parameters: 42', 'must be a SchemaSpec object'], - ['parameters: { text: 42 }', 'parameters.text must be a SchemaSpec property object'], - ['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'boolean\' | \'object\' | \'array\' (got "str")'], - ['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be a boolean when present'], - ['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is only valid for type "object"'], - ['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is only valid for type "array"'], - ])('rejects a malformed SchemaSpec (%s) with a teaching error', async (parameters, message) => { + ['parameters: 42', 'must be a ParameterSchemaSpec object'], + ['parameters: { text: 42 }', 'parameters.text must be a ParameterSchemaSpec property object'], + ['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\' (got "str")'], + ['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be true when present'], + ['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is not supported by the unified schema DSL'], + ['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is not supported by the unified schema DSL'], + ['parameters: { text: { type: \'object\', properties: {} } }', 'parameters.text.additionalProperties must be explicitly true or false'], + ['parameters: { text: { type: \'object\', additionalProperties: \'no\' } }', 'parameters.text.additionalProperties must be explicitly true or false'], + ['parameters: { type: \'object\' }', 'parameters.properties must be an object of schemas'], + ['parameters: { type: \'object\', properties: {}, additionalProperties: false }', 'parameters.additionalProperties must be true or omitted'], + ['parameters: { type: \'object\', properties: {}, required: \'text\' }', 'parameters.required must be an array of declared property names'], + ['parameters: { type: \'object\', properties: {}, required: undefined }', 'parameters.required must be an array of declared property names'], + ['parameters: { type: \'object\', properties: {}, required: [\'text\'] }', 'parameters.required names undeclared property "text"'], + ['parameters: { type: \'object\', properties: { text: { type: \'string\', required: true } } }', 'parameters.text.required belongs to the containing raw object schema'], + ['parameters: { type: \'object\', properties: { text: { oneOf: \'bad\' } } }', 'parameters.text.oneOf must contain at least two schemas'], + ['parameters: { type: \'object\', properties: { text: { type: \'json\' } } }', 'parameters.text must declare a valid type'], + ['parameters: { type: \'object\', properties: { cfg: { type: \'object\', additionalProperties: \'no\' } } }', 'parameters.cfg.additionalProperties must be a boolean'], + ['parameters: { type: \'object\', properties: { cfg: { type: \'object\', properties: 42 } } }', 'parameters.cfg.properties must be an object of schemas'], + ['parameters: { type: \'object\', properties: { cfg: { type: \'object\', required: [\'label\'] } } }', 'parameters.cfg.required names undeclared property "label"'], + ['parameters: { type: \'object\', properties: { cfg: { type: \'object\', required: undefined } } }', 'parameters.cfg.required must be an array of declared property names'], + ['parameters: { value: { oneOf: \'bad\' } }', 'parameters.value.oneOf must contain at least two schemas'], + ['parameters: { value: { type: \'string\', enum: \'bad\' } }', 'enum must be a non-empty array'], + ['parameters: { value: { type: \'json\', default: -0 } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: Infinity } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: () => 1 } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: (() => { const v = {}; v.self = v; return v })() } }', 'parameters.value.default.self must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: Array(2) } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: new Date(0) } }', 'parameters.value.default must be lossless JSON data'], + ])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => { const ctx = await setup() const result = await call(ctx, 'cordis_mount', { code: ` @@ -246,7 +366,7 @@ describe('cordis_mount', () => { expect(text(result)).toContain(message) }) - it('accepts a nested object/array SchemaSpec (the DSL recursion)', async () => { + it('accepts a nested object/array ParameterSchemaSpec (the DSL recursion)', async () => { const ctx = await setup() const result = await call(ctx, 'cordis_mount', { code: ` @@ -258,7 +378,7 @@ describe('cordis_mount', () => { name: 'nested_schema_tool', description: 'nested', parameters: { - item: { type: 'object', required: true, properties: { label: { type: 'string', required: true } } }, + item: { type: 'object', additionalProperties: true, required: true, properties: { label: { type: 'string', required: true } } }, tags: { type: 'array', items: { type: 'string' } }, }, async execute(args) { return [{ type: 'text', text: args.item.label }] }, diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 2cb841d0e2..5ec2d4e7e4 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -80,19 +80,19 @@ ctx.tools.register(defineTool({ })) ``` -The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format and uses the same typed spec for execute/presentation validation. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly. +The unified schema DSL uses `ParameterSchemaSpec` for the implicit open parameter object and `ValueSchemaSpec` for any JSON-value root. It supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum`/`const` values are type-correct. Every explicit DSL object declares `additionalProperties: true | false`, while the implicit parameter root and raw JSON Schema keep the standard open default. A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. Extra keys are allowed, defaults are not applied, and object or array fields without `properties` or `items` receive only a type check. Raw-registered tools own their validation. -See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details. +See `defineTool`, `validateArgs`, `ToolArgsError`, `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, `InferArgs`, `valueSchemaSpecToJsonSchema`, and `parameterSchemaSpecToJsonSchema` in the public API for details. Optional `timeoutMs` must be positive and finite; it is policy metadata, not model-visible schema. Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. Exact `true` permits concurrent dispatch/body execution; invalid input and all other outcomes remain exclusive. Opted-in bodies do not mutate parent-owned state, and shared-state races must commute or fail closed. The [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the full safety contract. -### Structured-output schema subset +### Enforced raw JSON Schema subset -`StructuredOutputSchema` is the object-rooted raw JSON Schema subset used by subagents and workflows for machine-readable results. It accepts one scalar `type`, object `properties`/`required`/boolean `additionalProperties`, array `items`, and scalar `enum`/`const`. The annotations `description`, `title`, `default`, and `examples` are ignored but must remain JSON data. Type arrays, undeclared required keys, and unsupported keywords fail through `OutputSchemaError` rather than being ignored; `validateStructuredValue()` returns path-qualified violations without throwing. +`JsonSchemaNode` is the raw counterpart shared by tool outputs, Code Mode generation, subagents, and workflows. It permits any JSON root, an annotation-only unconstrained JSON node, and exact-one `oneOf`; annotations must remain lossless JSON. `assertSupportedJsonSchema()` rejects unsupported constructs, while `validateJsonSchemaValue()` returns path-qualified violations. Subagents and workflows retain their caller-defined object-root requirement through `assertObjectJsonSchema()` and `ObjectJsonSchema`, not through a limitation in the shared vocabulary. ### Tool-owned UI presentation diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 18fa286a16..2c01639047 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -23,27 +23,42 @@ import { renderToolsSdk } from './ts-types.ts' export { defineTool, - schemaSpecToJsonSchema, + valueSchemaSpecToJsonSchema, + parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, - type SchemaSpec, - type SchemaProp, - type SchemaType, + type ValueSchemaAnnotations, + type StringValueSchemaSpec, + type NumberValueSchemaSpec, + type IntegerValueSchemaSpec, + type BooleanValueSchemaSpec, + type NullValueSchemaSpec, + type ArrayValueSchemaSpec, + type ObjectValueSchemaSpec, + type JsonValueSchemaSpec, + type OneOfValueSchemaSpec, + type ValueSchemaSpec, + type ParameterPropertySpec, + type ParameterSchemaSpec, + type ParameterJsonSchema, + type InferValue, type InferArgs, type DefineToolOptions, - type JsonSchemaObject, } from './schema.ts' export { - assertSupportedOutputSchema, - validateStructuredValue, - OutputSchemaError, - type StructuredOutputSchema, - type StructuredSchemaNode, - type StructuredSchemaType, - type StructuredScalar, + assertSupportedJsonSchema, + assertObjectJsonSchema, + validateJsonSchemaValue, + JsonSchemaError, + type JsonSchemaNode, + type ObjectJsonSchema, + type JsonSchemaType, + type JsonSchemaScalar, } from './json-schema.ts' +export type { JsonValue } from '@deepseek-ai/dsh-session' + export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts' export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts' diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts index e1a0dc43a6..82ee0c50ac 100644 --- a/packages/core/tools/src/json-schema.ts +++ b/packages/core/tools/src/json-schema.ts @@ -1,122 +1,124 @@ /** - * Structured-output JSON Schema subset for subagents and workflows. It supports - * one scalar `type`; object `properties`/`required`/boolean - * `additionalProperties`; array `items`; scalar `enum`/`const`; and JSON-valued - * annotations. Unsupported or misplaced keywords reject rather than being - * accepted without enforcement, and structured-output roots must be objects. + * Enforced JSON Schema subset shared by tool outputs, generated Code Mode + * types, subagents, and workflows. The subset accepts any JSON root, an + * annotation-only schema for unconstrained JSON, one scalar `type`, object + * `properties`/`required`/boolean `additionalProperties`, array `items`, + * type-correct scalar `enum`/`const`, and exact-one `oneOf`. + * + * Unsupported or misplaced keywords reject rather than being accepted without + * enforcement. Consumers that require an object root apply + * {@link assertObjectJsonSchema} at their own boundary. * @module dsh-tools/json-schema */ import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' +import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session' -/** The scalar values `enum`/`const` may carry (finite numbers only). */ -export type StructuredScalar = string | number | boolean | null +/** Scalar JSON values supported by `enum` and `const`. */ +export type JsonSchemaScalar = string | number | boolean | null -/** The `type` keywords the subset accepts. */ -export type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' +/** Single-type keywords accepted by the enforced subset. */ +export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' + +/** Scalar-only schema types accepted by literal constraints. */ +type JsonSchemaScalarType = Exclude /** - * One node of the structured-output schema subset. Recursive via `properties` - * and `items`; see the module doc for the exact keyword semantics. + * One raw JSON Schema node in the enforced subset. The optional fields express + * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid + * combinations before a caller treats the node as trusted. */ -export interface StructuredSchemaNode { - type: StructuredSchemaType +export interface JsonSchemaNode { + /** Omit with no constraints for any JSON value, or use `oneOf`. */ + type?: JsonSchemaType + /** Exactly one branch must validate; at least two branches are required. */ + oneOf?: JsonSchemaNode[] /** Nested property schemas (`type: 'object'` only). */ - properties?: Record + properties?: Record /** Required property names; each must appear in `properties`. */ required?: string[] - /** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */ + /** `false` rejects undeclared keys; absent/`true` follows JSON Schema's open default. */ additionalProperties?: boolean - /** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */ - items?: StructuredSchemaNode - /** Allowed values (scalar types only). */ - enum?: StructuredScalar[] - /** The single allowed value (scalar types only). */ - const?: StructuredScalar + /** Item schema (`type: 'array'` only); absent accepts any JSON item. */ + items?: JsonSchemaNode + /** Allowed values for a scalar node. */ + enum?: JsonSchemaScalar[] + /** The single allowed value for a scalar node. */ + const?: JsonSchemaScalar /** Annotation, ignored for validation. */ description?: string /** Annotation, ignored for validation. */ title?: string - /** Annotation, ignored for validation (must still be JSON data). */ - default?: unknown - /** Annotation, ignored for validation (must still be JSON data). */ - examples?: unknown + /** Annotation, ignored for validation but required to be lossless JSON. */ + default?: JsonValue + /** Annotation, ignored for validation but required to be lossless JSON. */ + examples?: JsonValue } -/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */ -export type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } +/** A consumer-constrained object-rooted schema. */ +export type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } /** - * Thrown by {@link assertSupportedOutputSchema} when a schema falls outside the - * supported subset. Extends {@link HarnessError} (`code: 'UNSUPPORTED_SCHEMA'`) - * so seam code and tool results can route on it; `violations` lists every - * offending path, not just the first. + * Thrown when a raw schema falls outside the enforced subset. `violations` + * lists every offending path instead of stopping at the first author error. */ -export class OutputSchemaError extends HarnessError { - /** The individual violation messages, in walk order. */ +export class JsonSchemaError extends HarnessError { + /** Individual schema violations in walk order. */ readonly violations: string[] constructor(violations: string[]) { - super(`unsupported output schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA') - this.name = 'OutputSchemaError' + super(`unsupported JSON schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA') + this.name = 'JsonSchemaError' this.violations = violations } } -/** The keywords the subset accepts, checked (`constraint`) or ignored (`annotation`). */ -const CONSTRAINT_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const']) +const CONSTRAINT_KEYWORDS = new Set([ + 'type', + 'oneOf', + 'properties', + 'required', + 'additionalProperties', + 'items', + 'enum', + 'const', +]) const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples']) - -const SCHEMA_TYPES: readonly StructuredSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'] +const SCHEMA_TYPES: readonly JsonSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'] /** - * Whether a value is a PLAIN JSON object — non-null, non-array, and with a - * prototype chain of at most one link (`null`-proto, or any realm's - * `Object.prototype`, whose own prototype is `null`). Realm-agnostic on - * purpose: a schema materialized in another realm carries THAT realm's - * `Object.prototype`, which an identity check would wrongly reject. Exotic - * hosts (`Date`, `Map`, class instances) have longer chains and are rejected — - * they would serialize lossily (`Date` → string, `Map` → `{}`) instead of - * failing loud. + * Test for a realm-agnostic plain JSON record without accepting arrays or + * exotic objects. + * @param value - candidate record from any JavaScript realm. + * @returns Whether the value has a plain-object prototype chain. */ -function isObjectLike(value: unknown): value is Record { +export function isPlainJsonRecord(value: unknown): value is Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false const proto: unknown = Object.getPrototypeOf(value) return proto === null || Object.getPrototypeOf(proto) === null } -/** Whether a value is a supported scalar (`enum`/`const` member): string, finite number, boolean, or null. */ -function isStructuredScalar(value: unknown): value is StructuredScalar { - return value === null || typeof value === 'string' || typeof value === 'boolean' - || (typeof value === 'number' && Number.isFinite(value)) +/** Lossless finite JSON number, excluding negative zero. */ +function isJsonNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0) } -/** - * Whether a value is JSON data (annotation payloads only): scalars, arrays, and - * object-likes of such values. Realm-agnostic on purpose (no prototype check) — - * the schema may have been materialized from another realm; structural JSON-ness - * is what the wire needs. Cycles are rejected via `seen`. - */ -function isJsonData(value: unknown, seen: Set): boolean { - if (isStructuredScalar(value)) return true - // The scalar check above already returned for null, so `object` here is a real object. - if (typeof value !== 'object') return false - if (seen.has(value)) return false - seen.add(value) - try { - if (Array.isArray(value)) return value.every(entry => isJsonData(entry, seen)) - // A non-plain object (Date, Map, class instance) is NOT JSON data even when - // it has no enumerable values — it would serialize lossily, not loudly. - if (!isObjectLike(value)) return false - return Object.values(value).every(entry => isJsonData(entry, seen)) - } finally { - seen.delete(value) +/** Whether a scalar is valid for one declared schema type. */ +function scalarMatches(type: JsonSchemaScalarType, value: unknown): value is JsonSchemaScalar { + switch (type) { + case 'string': return typeof value === 'string' + case 'number': return isJsonNumber(value) + case 'integer': return isJsonNumber(value) && Number.isInteger(value) + case 'boolean': return typeof value === 'boolean' + case 'null': return value === null + /* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */ + default: return assertNever(type, 'JsonSchemaType') } } -/** Collect subset violations for one schema node (recursive walk). */ +/** Collect every violation for one raw schema node. */ function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set): void { - if (!isObjectLike(node)) { + if (!isPlainJsonRecord(node)) { violations.push(`${path} must be a schema object`) return } @@ -125,199 +127,258 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen return } seen.add(node) - - for (const key of Object.keys(node)) { - if (CONSTRAINT_KEYWORDS.has(key)) continue - if (ANNOTATION_KEYWORDS.has(key)) { - if (!isJsonData(node[key], new Set())) violations.push(`${path}.${key} annotation must be JSON data`) - continue + try { + for (const key of Object.keys(node)) { + if (CONSTRAINT_KEYWORDS.has(key)) continue + if (ANNOTATION_KEYWORDS.has(key)) { + try { + if (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`) + } catch { + violations.push(`${path}.${key} annotation must be lossless JSON data`) + } + continue + } + violations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`) + } + if (node.description !== undefined && typeof node.description !== 'string') { + violations.push(`${path}.description must be a string`) + } + if (node.title !== undefined && typeof node.title !== 'string') { + violations.push(`${path}.title must be a string`) } - violations.push(`${path}.${key} is not a supported keyword (subset: type/properties/required/additionalProperties/items/enum/const + annotations)`) - } - if (typeof node.description !== 'undefined' && typeof node.description !== 'string') { - violations.push(`${path}.description must be a string`) - } - if (typeof node.title !== 'undefined' && typeof node.title !== 'string') { - violations.push(`${path}.title must be a string`) - } - const type = node.type - if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) { - violations.push(Array.isArray(type) - ? `${path}.type must be a single type string (type arrays are not supported)` - : `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`) + const hasType = Object.hasOwn(node, 'type') + const hasOneOf = Object.hasOwn(node, 'oneOf') + if (hasType && hasOneOf) { + violations.push(`${path} cannot declare both type and oneOf`) + return + } + if (!hasType && !hasOneOf) { + for (const key of ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const']) { + if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`) + } + return + } + + if (hasOneOf) { + const oneOf = node.oneOf + if (!Array.isArray(oneOf) || oneOf.length < 2) { + violations.push(`${path}.oneOf must be an array of at least two schemas`) + } else { + for (let index = 0; index < oneOf.length; index++) { + checkSchemaNode(oneOf[index], `${path}.oneOf[${index}]`, violations, seen) + } + } + for (const key of ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const']) { + if (Object.hasOwn(node, key)) violations.push(`${path}.${key} is not supported beside oneOf`) + } + return + } + + const type = node.type + if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) { + violations.push(Array.isArray(type) + ? `${path}.type must be a single type string (type arrays are not supported)` + : `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`) + return + } + const schemaType = type as JsonSchemaType + const allowedFor: Record = { + properties: ['object'], + required: ['object'], + additionalProperties: ['object'], + items: ['array'], + enum: ['string', 'number', 'integer', 'boolean', 'null'], + const: ['string', 'number', 'integer', 'boolean', 'null'], + } + for (const [key, types] of Object.entries(allowedFor)) { + if (Object.hasOwn(node, key) && !types.includes(schemaType)) { + violations.push(`${path}.${key} is not supported on type "${schemaType}"`) + } + } + + switch (schemaType) { + case 'object': { + const properties = node.properties + if (Object.hasOwn(node, 'properties')) { + if (!isPlainJsonRecord(properties)) { + violations.push(`${path}.properties must be an object of schemas`) + } else { + for (const [key, child] of Object.entries(properties)) { + checkSchemaNode(child, `${path}.properties.${key}`, violations, seen) + } + } + } + const required = node.required + if (Object.hasOwn(node, 'required')) { + if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) { + violations.push(`${path}.required must be an array of strings`) + } else { + const declared = isPlainJsonRecord(properties) ? properties : {} + for (const key of required as string[]) { + if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`) + } + } + } + if (Object.hasOwn(node, 'additionalProperties') && typeof node.additionalProperties !== 'boolean') { + violations.push(`${path}.additionalProperties must be a boolean`) + } + break + } + case 'array': { + if (Object.hasOwn(node, 'items')) checkSchemaNode(node.items, `${path}.items`, violations, seen) + break + } + case 'string': + case 'number': + case 'integer': + case 'boolean': + case 'null': { + const allowed = node.enum + if (Object.hasOwn(node, 'enum')) { + if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => scalarMatches(schemaType, entry))) { + violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`) + } + } + if (Object.hasOwn(node, 'const') && !scalarMatches(schemaType, node.const)) { + violations.push(`${path}.const must be a ${schemaType} value`) + } + break + } + /* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */ + default: assertNever(schemaType, 'JsonSchemaType') + } + } finally { seen.delete(node) - return } - const schemaType = type as StructuredSchemaType - - // Keywords that only make sense on one type are rejected elsewhere — an - // `items` on an object (or `properties` on a string) is a schema-author bug - // the subset surfaces rather than ignores. - const allowedFor: Record = { - properties: ['object'], - required: ['object'], - additionalProperties: ['object'], - items: ['array'], - enum: ['string', 'number', 'integer', 'boolean', 'null'], - const: ['string', 'number', 'integer', 'boolean', 'null'], - } - for (const [key, types] of Object.entries(allowedFor)) { - if (key in node && !types.includes(schemaType)) { - violations.push(`${path}.${key} is not supported on type "${schemaType}"`) - } - } - - switch (schemaType) { - case 'object': { - const properties = node.properties - if (properties !== undefined) { - if (!isObjectLike(properties)) { - violations.push(`${path}.properties must be an object of schemas`) - } else { - for (const [key, child] of Object.entries(properties)) { - checkSchemaNode(child, `${path}.properties.${key}`, violations, seen) - } - } - } - const required = node.required - if (required !== undefined) { - if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) { - violations.push(`${path}.required must be an array of strings`) - } else { - const declared = isObjectLike(properties) ? properties : {} - // The guard above proved every entry is a string. - for (const key of required as string[]) { - // Own-property check: `in` would let inherited names (`toString`) - // satisfy the declared-in-properties contract via the prototype. - if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`) - } - } - } - if (node.additionalProperties !== undefined && typeof node.additionalProperties !== 'boolean') { - violations.push(`${path}.additionalProperties must be a boolean`) - } - break - } - case 'array': { - if (node.items !== undefined) checkSchemaNode(node.items, `${path}.items`, violations, seen) - break - } - case 'string': - case 'number': - case 'integer': - case 'boolean': - case 'null': { - const allowed = node.enum - if (allowed !== undefined) { - if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => isStructuredScalar(entry))) { - violations.push(`${path}.enum must be a non-empty array of scalars`) - } - } - if ('const' in node && !isStructuredScalar(node.const)) { - violations.push(`${path}.const must be a scalar`) - } - break - } - /* v8 ignore start -- defensive: schemaType was membership-checked against SCHEMA_TYPES above, so no runtime value reaches here */ - default: - assertNever(schemaType, 'assertSupportedOutputSchema') - /* v8 ignore stop */ - } - - seen.delete(node) } /** - * Assert `schema` is a supported {@link StructuredOutputSchema} — object-rooted - * and entirely within the enforced subset. Throws {@link OutputSchemaError} - * (`UNSUPPORTED_SCHEMA`) listing EVERY violation; returns (and narrows) on - * success. Call this at the seam boundary, before any child is created. - * @param schema - the caller-supplied schema (unknown until asserted). - * @returns nothing — the assertion signature narrows `schema` to - * {@link StructuredOutputSchema} in the caller's scope on normal return. + * Assert that an arbitrary raw schema uses only the enforced subset. + * Annotation-only schemas are accepted as the standard unconstrained-JSON + * form; callers that require an object root use {@link assertObjectJsonSchema}. + * @param schema - untrusted raw JSON Schema. + * @returns Assertion that the schema belongs to the supported subset. */ -export function assertSupportedOutputSchema(schema: unknown): asserts schema is StructuredOutputSchema { +export function assertSupportedJsonSchema(schema: unknown): asserts schema is JsonSchemaNode { const violations: string[] = [] checkSchemaNode(schema, 'schema', violations, new Set()) - if (violations.length === 0 && (schema as StructuredSchemaNode).type !== 'object') { - violations.push('schema.type must be "object" (structured output is object-rooted)') - } - if (violations.length > 0) throw new OutputSchemaError(violations) + if (violations.length > 0) throw new JsonSchemaError(violations) } -/** Collect violations for one value against an (already asserted) schema node. */ -function checkValue(node: StructuredSchemaNode, value: unknown, path: string): string[] { +/** + * Assert the enforced subset plus the object-root constraint retained by + * subagent and workflow structured outputs. + * @param schema - untrusted caller-supplied schema. + * @returns Assertion that the schema belongs to the supported subset and has an object root. + */ +export function assertObjectJsonSchema(schema: unknown): asserts schema is ObjectJsonSchema { + const violations: string[] = [] + checkSchemaNode(schema, 'schema', violations, new Set()) + if (violations.length === 0 && (schema as JsonSchemaNode).type !== 'object') { + violations.push('schema.type must be "object" (structured output is object-rooted)') + } + if (violations.length > 0) throw new JsonSchemaError(violations) +} + +/** Safely test the lossless JSON boundary when a getter may throw. */ +function safelyIsJsonValue(value: unknown): boolean { + try { + return isJsonValue(value) + } catch { + return false + } +} + +/** Root-aware diagnostic path for the parameter validator's empty sentinel. */ +function diagnosticPath(path: string): string { + return path === '' ? 'arguments' : path +} + +/** Append one object property without a leading dot at an implicit root. */ +function propertyPath(path: string, key: string): string { + return path === '' ? key : `${path}.${key}` +} + +/** Collect value violations for one trusted schema node. */ +function checkValue(node: JsonSchemaNode, value: unknown, path: string): string[] { + if (node.oneOf !== undefined) { + const matches = node.oneOf.filter(branch => checkValue(branch, value, path).length === 0).length + return matches === 1 ? [] : [`"${diagnosticPath(path)}" must match exactly one oneOf branch (matched ${matches})`] + } + if (node.type === undefined) { + return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a lossless JSON value`] + } + switch (node.type) { case 'object': { - if (!isObjectLike(value)) return [`"${path}" must be an object`] + if (!isPlainJsonRecord(value)) return [`"${diagnosticPath(path)}" must be an object`] const violations: string[] = [] const properties = node.properties ?? {} - // Own-property discipline throughout: JSON carries own enumerable - // properties only, so an inherited `toString` must not satisfy - // `required`, dodge `additionalProperties: false`, or be validated as if - // the value carried it. for (const key of node.required ?? []) { - if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${path}.${key}"`) + if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${propertyPath(path, key)}"`) } for (const [key, child] of Object.entries(properties)) { if (!Object.hasOwn(value, key) || value[key] === undefined) continue - violations.push(...checkValue(child, value[key], `${path}.${key}`)) + violations.push(...checkValue(child, value[key], propertyPath(path, key))) } if (node.additionalProperties === false) { for (const key of Object.keys(value)) { - if (!Object.hasOwn(properties, key)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`) + if (!Object.hasOwn(properties, key)) violations.push(`"${propertyPath(path, key)}" is not a declared property (additionalProperties: false)`) } } - return violations + if (violations.length > 0) return violations + return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a lossless JSON object`] } case 'array': { - if (!Array.isArray(value)) return [`"${path}" must be an array`] - if (!node.items) return [] + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return [`"${diagnosticPath(path)}" must be an array`] const items = node.items - return value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`)) + const violations = items === undefined + ? [] + : value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`)) + if (violations.length > 0) return violations + return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a dense lossless JSON array`] } case 'string': { - if (typeof value !== 'string') return [`"${path}" must be a string`] + if (typeof value !== 'string') return [`"${diagnosticPath(path)}" must be a string`] break } case 'number': { - if (typeof value !== 'number' || !Number.isFinite(value)) return [`"${path}" must be a finite number`] + if (typeof value !== 'number') return [`"${diagnosticPath(path)}" must be a number`] + if (!isJsonNumber(value)) return [`"${diagnosticPath(path)}" must be a finite JSON number`] break } case 'integer': { - if (typeof value !== 'number' || !Number.isInteger(value)) return [`"${path}" must be an integer`] + if (!isJsonNumber(value) || !Number.isInteger(value)) return [`"${diagnosticPath(path)}" must be an integer`] break } case 'boolean': { - if (typeof value !== 'boolean') return [`"${path}" must be a boolean`] + if (typeof value !== 'boolean') return [`"${diagnosticPath(path)}" must be a boolean`] break } case 'null': { - if (value !== null) return [`"${path}" must be null`] + if (value !== null) return [`"${diagnosticPath(path)}" must be null`] break } - default: - return assertNever(node.type, 'validateStructuredValue') + default: return assertNever(node.type, 'JsonSchemaType') } - // Scalar constraint checks, shared by every scalar branch above. - if (node.enum && !node.enum.includes(value)) { - return [`"${path}" must be one of ${JSON.stringify(node.enum)}`] + if (node.enum !== undefined && !node.enum.includes(value)) { + return [`"${diagnosticPath(path)}" must be one of ${JSON.stringify(node.enum)}`] } - if ('const' in node && value !== node.const) { - return [`"${path}" must be ${JSON.stringify(node.const)}`] + if (Object.hasOwn(node, 'const') && value !== node.const) { + return [`"${diagnosticPath(path)}" must be ${JSON.stringify(node.const)}`] } return [] } /** - * Validate a value against an (already {@link assertSupportedOutputSchema}- - * asserted) schema. Returns human-readable, path-qualified violation messages - * — empty means valid. Total: never throws, however malformed the value. - * @param schema - the asserted schema to check against. - * @param value - the candidate value (e.g. parsed tool-call arguments). - * @returns every violation found, in walk order (empty = valid). + * Validate a candidate value against an asserted raw schema. The function is + * total for arbitrary values and returns path-qualified violations. + * @param schema - a schema accepted by {@link assertSupportedJsonSchema}. + * @param value - the candidate JSON value. + * @param path - root label used in diagnostics. + * @returns All violations in walk order; empty means valid. */ -export function validateStructuredValue(schema: StructuredOutputSchema, value: unknown): string[] { - return checkValue(schema, value, 'value') +export function validateJsonSchemaValue(schema: JsonSchemaNode, value: unknown, path = 'value'): string[] { + return checkValue(schema, value, path) } diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index f2b62669b1..98be959e4e 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -1,173 +1,314 @@ -/** Typed tool-parameter DSL with argument inference and JSON Schema output. @module dsh-tools/schema */ +/** Unified JSON-value schema DSL, inference, compilation, and typed tool helper. @module dsh-tools/schema */ -import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { JsonValue } from '@deepseek-ai/dsh-session' import type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts' +import { assertSupportedJsonSchema, isPlainJsonRecord, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts' +import type { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts' import type { ToolCallView, ToolResultView } from './presentation.ts' -// --------------------------------------------------------------------------- -// SchemaSpec — the author-facing per-property type -// --------------------------------------------------------------------------- - -/** Valid JSON Schema primitive types for tool parameters. */ -export type SchemaType = 'string' | 'number' | 'boolean' | 'object' | 'array' - -/** One schema-spec property entry. */ -export interface SchemaProp { - type: SchemaType - /** Per-property required flag (NOT the JSON Schema top-level required array). */ - required?: true - /** Human-readable description, surfaced in the JSON Schema as well. */ +/** Annotation keywords shared by every author-facing schema node. */ +export interface ValueSchemaAnnotations { + /** Human-readable description projected into JSON Schema and generated types. */ description?: string - /** Enum of allowed values (strings only). */ - enum?: string[] - /** - * Model-visible JSON Schema default annotation. Validation does not apply it; - * dynamic tool mounts may supply it even though first-party definitions do not. - */ - default?: unknown - /** Nested properties for type: 'object'. */ - properties?: SchemaSpec - /** Items schema for type: 'array'. */ - items?: SchemaProp + /** Human-readable title projected into JSON Schema. */ + title?: string + /** Non-validating default annotation; it must be lossless JSON data. */ + default?: JsonValue + /** Non-validating examples annotation; it must be lossless JSON data. */ + examples?: JsonValue +} + +/** String value schema with type-correct literal constraints. */ +export interface StringValueSchemaSpec extends ValueSchemaAnnotations { + type: 'string' + enum?: readonly string[] + const?: string +} + +/** Finite JSON-number schema with type-correct literal constraints. */ +export interface NumberValueSchemaSpec extends ValueSchemaAnnotations { + type: 'number' + enum?: readonly number[] + const?: number +} + +/** Integer schema with type-correct literal constraints. */ +export interface IntegerValueSchemaSpec extends ValueSchemaAnnotations { + type: 'integer' + enum?: readonly number[] + const?: number +} + +/** Boolean value schema with type-correct literal constraints. */ +export interface BooleanValueSchemaSpec extends ValueSchemaAnnotations { + type: 'boolean' + enum?: readonly boolean[] + const?: boolean +} + +/** Null value schema with type-correct literal constraints. */ +export interface NullValueSchemaSpec extends ValueSchemaAnnotations { + type: 'null' + enum?: readonly null[] + const?: null +} + +/** Array value schema; omitted `items` accepts any lossless JSON item. */ +export interface ArrayValueSchemaSpec extends ValueSchemaAnnotations { + type: 'array' + items?: ValueSchemaSpec } /** - * The author-facing parameter schema: a shallow map of property name to - * {@link SchemaProp}. Required-ness is a per-property boolean (`required: - * true`), not a separate array. + * Explicit object value schema. Openness is mandatory so a nested or output + * object never acquires an accidental JSON Schema default. */ -export type SchemaSpec = Record +export interface ObjectValueSchemaSpec extends ValueSchemaAnnotations { + type: 'object' + properties?: ParameterSchemaSpec + additionalProperties: boolean +} -// --------------------------------------------------------------------------- -// InferArgs — type-level mapping from SchemaSpec to TS argument type -// --------------------------------------------------------------------------- +/** Author-only unconstrained lossless JSON node. */ +export interface JsonValueSchemaSpec extends ValueSchemaAnnotations { + type: 'json' +} -/** Map a {@link SchemaType} to its TS primitive type. */ -type TypeOf = - T extends 'string' ? string : - T extends 'number' ? number : - T extends 'boolean' ? boolean : - T extends 'object' ? Record : - T extends 'array' ? unknown[] : - never +/** Exact-one union schema; at least two branches are required. */ +export interface OneOfValueSchemaSpec extends ValueSchemaAnnotations { + oneOf: readonly [ValueSchemaSpec, ValueSchemaSpec, ...ValueSchemaSpec[]] +} + +/** One author-facing schema for any lossless JSON value root. */ +export type ValueSchemaSpec = + | StringValueSchemaSpec + | NumberValueSchemaSpec + | IntegerValueSchemaSpec + | BooleanValueSchemaSpec + | NullValueSchemaSpec + | ArrayValueSchemaSpec + | ObjectValueSchemaSpec + | JsonValueSchemaSpec + | OneOfValueSchemaSpec + +/** One implicit parameter-root property, optionally required. */ +export type ParameterPropertySpec = ValueSchemaSpec & { required?: true } + +/** + * Tool parameter schema. The map itself is an implicit open object root; + * requiredness remains a per-property `required: true` annotation. + */ +export type ParameterSchemaSpec = Record + +/** Raw JSON Schema projection of the implicit parameter object. */ +export interface ParameterJsonSchema extends ObjectJsonSchema { + properties: Record +} /** Flatten an intersection into one object type for readable hovers. */ type Simplify = { [K in keyof T]: T[K] } & {} -/** Keys of `S` whose prop is marked `required: true`. */ -type RequiredKeys = - { [K in keyof S]: S[K] extends { required: true } ? K : never }[keyof S] +/** Keys of a property map marked `required: true`. */ +type RequiredKeys = { + [K in keyof S]: S[K] extends { required: true } ? K : never +}[keyof S] -/** - * The VALUE type of one {@link SchemaProp} — optionality is handled at the - * key level by {@link InferArgs}, never here. - * - `properties` on 'object' → recurse into the nested SchemaSpec - * - `items` on 'array' → recurse into the item prop (arrays of objects work) - * - otherwise → the primitive for `type` - */ -type InferPropValue

= - P extends { type: 'object'; properties: infer Sub extends SchemaSpec } ? InferArgs : - P extends { type: 'array'; items: infer Item extends SchemaProp } ? InferPropValue[] : - TypeOf +/** Infer the declared value of one parameter property without key optionality. */ +type InferProperty

= P extends ValueSchemaSpec ? InferValue

: never -/** - * Infer the TS argument type for a complete {@link SchemaSpec}. - * - * Properties marked `required: true` are required keys; all others are - * genuinely optional keys (`?`), so callers may omit them entirely. - * - * Example: - * ```ts - * type Args = InferArgs<{ path: { type: 'string'; required: true }; limit: { type: 'number' } }> - * // → { path: string; limit?: number } - * ``` - */ -export type InferArgs = Simplify< - & { [K in RequiredKeys]: InferPropValue } - & { [K in Exclude>]?: InferPropValue } +/** Infer an implicit property map into required and optional object keys. */ +type InferProperties = Simplify< + & { [K in RequiredKeys]: InferProperty } + & { [K in Exclude>]?: InferProperty } > -// --------------------------------------------------------------------------- -// Runtime conversion: SchemaSpec → JSON Schema -// --------------------------------------------------------------------------- +/** Infer an explicit object node, including its declared openness. */ +type InferObject = + S extends { properties: infer P extends ParameterSchemaSpec } + ? S['additionalProperties'] extends true + ? InferProperties

& Record + : InferProperties

+ : S['additionalProperties'] extends true + ? Record + : Record + +/** Infer a scalar node's literal constraint before its broad primitive type. */ +type InferScalar = + S extends { const: infer C } ? C : + S extends { enum: readonly (infer E)[] } ? E : + Fallback /** - * Convert a single {@link SchemaProp} to its JSON Schema `properties` entry. - * The per-property `required` flag is collected; the caller builds the - * top-level `required` array. + * Infer the TypeScript value accepted by an author-facing value schema. + * Output schemas may therefore infer object, array, scalar, or null roots. */ -function propToJsonSchema(prop: SchemaProp): { schema: Record; required: boolean } { - const result: Record = { type: prop.type } - if (prop.description) result.description = prop.description - if (prop.enum) result.enum = prop.enum - if (prop.default !== undefined) result.default = prop.default +export type InferValue = + S extends StringValueSchemaSpec ? InferScalar : + S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar : + S extends BooleanValueSchemaSpec ? InferScalar : + S extends NullValueSchemaSpec ? null : + S extends ArrayValueSchemaSpec + ? S extends { items: infer I extends ValueSchemaSpec } ? InferValue[] : JsonValue[] + : S extends ObjectValueSchemaSpec ? InferObject : + S extends JsonValueSchemaSpec ? JsonValue : + S extends OneOfValueSchemaSpec ? InferValue : + never - const required = prop.required === true +/** Infer the TypeScript argument object for an implicit parameter schema. */ +export type InferArgs = InferProperties - if (prop.type === 'object' && prop.properties) { - const nested = schemaSpecToJsonSchema(prop.properties) - result.properties = nested.properties - if (nested.required && nested.required.length > 0) { - result.required = nested.required +const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const + +/** Throw one author-schema violation through the shared schema error type. */ +function authorError(message: string): never { + throw new JsonSchemaError([message]) +} + +/** Copy own annotation fields for validation by the raw-schema boundary. */ +function copyAnnotations(source: Record, target: JsonSchemaNode): void { + if (Object.hasOwn(source, 'description')) target.description = source.description as string + if (Object.hasOwn(source, 'title')) target.title = source.title as string + if (Object.hasOwn(source, 'default')) target.default = source.default as JsonValue + if (Object.hasOwn(source, 'examples')) target.examples = source.examples as JsonValue +} + +/** Reject author-only keys outside one node's declared vocabulary. */ +function assertAuthorKeys(source: Record, path: string, allowed: readonly string[]): void { + for (const key of Object.keys(source)) { + if (!allowed.includes(key)) authorError(`${path}.${key} is not supported by the value schema DSL`) + } +} + +/** Compile one implicit property map, collecting per-property requiredness. */ +function compilePropertyMap( + input: unknown, + path: string, + seen: Set, +): { properties: Record; required?: string[] } { + if (!isPlainJsonRecord(input)) authorError(`${path} must be an object of value schemas`) + if (seen.has(input)) authorError(`${path} is circular`) + seen.add(input) + try { + const properties: Record = {} + const required: string[] = [] + for (const [key, property] of Object.entries(input)) { + if (!isPlainJsonRecord(property)) authorError(`${path}.${key} must be a value schema object`) + if (Object.hasOwn(property, 'required') && property.required !== true) { + authorError(`${path}.${key}.required must be true when present`) + } + properties[key] = compileValueSchema(property, `${path}.${key}`, seen, true) + if (property.required === true) required.push(key) } + return required.length > 0 ? { properties, required } : { properties } + } finally { + seen.delete(input) } - - if (prop.type === 'array' && prop.items) { - const { schema: itemsSchema } = propToJsonSchema(prop.items) - result.items = itemsSchema - } - - return { schema: result, required } } -/** The return type of {@link schemaSpecToJsonSchema}. */ -export interface JsonSchemaObject { - type: 'object' - properties: Record - required?: string[] +/** Compile one author node without applying any consumer root restriction. */ +function compileValueSchema( + input: unknown, + path: string, + seen: Set, + allowRequired = false, +): JsonSchemaNode { + if (!isPlainJsonRecord(input)) authorError(`${path} must be a value schema object`) + if (seen.has(input)) authorError(`${path} is circular`) + seen.add(input) + try { + const authorKeys = [...ANNOTATION_KEYS, ...(allowRequired ? ['required'] : [])] + const node: JsonSchemaNode = {} + + if (Object.hasOwn(input, 'oneOf')) { + assertAuthorKeys(input, path, [...authorKeys, 'oneOf', 'type']) + if (Object.hasOwn(input, 'type')) authorError(`${path} cannot declare both type and oneOf`) + if (!Array.isArray(input.oneOf)) authorError(`${path}.oneOf must be an array of at least two value schemas`) + node.oneOf = input.oneOf.map((branch, index) => compileValueSchema(branch, `${path}.oneOf[${index}]`, seen)) + copyAnnotations(input, node) + return node + } + + switch (input.type) { + case 'json': + assertAuthorKeys(input, path, [...authorKeys, 'type']) + copyAnnotations(input, node) + return node + case 'object': { + assertAuthorKeys(input, path, [...authorKeys, 'type', 'properties', 'additionalProperties']) + if (!Object.hasOwn(input, 'additionalProperties') || typeof input.additionalProperties !== 'boolean') { + authorError(`${path}.additionalProperties must be explicitly true or false`) + } + node.type = 'object' + copyAnnotations(input, node) + node.additionalProperties = input.additionalProperties + if (Object.hasOwn(input, 'properties')) { + const compiled = compilePropertyMap(input.properties, `${path}.properties`, seen) + node.properties = compiled.properties + if (compiled.required !== undefined) node.required = compiled.required + } + return node + } + case 'array': + assertAuthorKeys(input, path, [...authorKeys, 'type', 'items']) + node.type = 'array' + copyAnnotations(input, node) + if (Object.hasOwn(input, 'items')) node.items = compileValueSchema(input.items, `${path}.items`, seen) + return node + case 'string': + case 'number': + case 'integer': + case 'boolean': + case 'null': + assertAuthorKeys(input, path, [...authorKeys, 'type', 'enum', 'const']) + node.type = input.type + copyAnnotations(input, node) + if (Object.hasOwn(input, 'enum')) { + node.enum = Array.isArray(input.enum) + ? Array.from(input.enum as unknown[], entry => entry as JsonSchemaScalar) + : input.enum as JsonSchemaScalar[] + } + if (Object.hasOwn(input, 'const')) node.const = input.const as JsonSchemaScalar + return node + default: + return authorError(`${path}.type must be string/number/integer/boolean/null/array/object/json, or use oneOf`) + } + } finally { + seen.delete(input) + } } /** - * Convert a {@link SchemaSpec} to standard JSON Schema (`type: 'object'`, - * `properties`, `required` array). - * - * This is a plain function — no schemastery or other framework dependency. - * @param spec - the author-facing per-property schema to convert. - * @returns the wire-format JSON Schema; the top-level `required` array is - * omitted entirely when no property is marked required. + * Compile one author-facing value schema to the enforced raw JSON Schema + * subset. The author-only `json` node becomes an annotation-only schema. + * @param spec - schema for any JSON-value root. + * @returns The asserted raw schema projection. */ -export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject { - const properties: Record = {} - const required: string[] = [] +export function valueSchemaSpecToJsonSchema(spec: ValueSchemaSpec): JsonSchemaNode { + const schema = compileValueSchema(spec, 'schema', new Set()) + assertSupportedJsonSchema(schema) + return schema +} - for (const [key, prop] of Object.entries(spec)) { - const { schema, required: isRequired } = propToJsonSchema(prop) - properties[key] = schema - if (isRequired) required.push(key) - } - - const result: JsonSchemaObject = { +/** + * Compile the implicit open parameter object into raw JSON Schema. + * @param spec - per-property parameter definitions. + * @returns An object-rooted raw schema with no implicit-root openness override. + */ +export function parameterSchemaSpecToJsonSchema(spec: ParameterSchemaSpec): ParameterJsonSchema { + const compiled = compilePropertyMap(spec, 'parameters', new Set()) + const schema: ParameterJsonSchema = { type: 'object', - properties, + properties: compiled.properties, + ...(compiled.required === undefined ? {} : { required: compiled.required }), } - if (required.length > 0) result.required = required - - return result + assertSupportedJsonSchema(schema) + return schema } -// --------------------------------------------------------------------------- -// Runtime validation: model-generated args ↔ SchemaSpec -// --------------------------------------------------------------------------- - -/** - * Thrown by a {@link defineTool} tool when the model-generated arguments don't - * match the declared {@link SchemaSpec}. Extends {@link HarnessError} - * (`code: 'INVALID_ARGS'`); the registry's execution pipeline catches it and - * returns an `isError` ToolExecutionResult carrying the structured error, so - * the model can self-correct and downstream plugins can route on the code. - */ +/** Invalid model-generated arguments for a typed tool. */ export class ToolArgsError extends HarnessError { - /** The individual violation messages, in declaration order. */ + /** Individual violations in schema-walk order. */ readonly violations: string[] constructor(violations: string[]) { @@ -177,152 +318,63 @@ export class ToolArgsError extends HarnessError { } } -/** Whether a value is a non-null, non-array object (a JSON Schema `object`). */ -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -/** Collect violations for one property value against its {@link SchemaProp}. */ -function checkValue(prop: SchemaProp, value: unknown, path: string): string[] { - switch (prop.type) { - case 'string': { - if (typeof value !== 'string') return [`"${path}" must be a string`] - break - } - case 'number': { - if (typeof value !== 'number') return [`"${path}" must be a number`] - break - } - case 'boolean': { - if (typeof value !== 'boolean') return [`"${path}" must be a boolean`] - break - } - case 'object': { - if (!isPlainObject(value)) return [`"${path}" must be an object`] - // Mirror the converter: an object without `properties` only type-checks. - return prop.properties ? checkSpec(prop.properties, value, path) : [] - } - case 'array': { - if (!Array.isArray(value)) return [`"${path}" must be an array`] - // Mirror the converter: an array without `items` only type-checks. - if (!prop.items) return [] - const items = prop.items - return value.flatMap((el, i) => checkValue(items, el, `${path}[${i}]`)) - } - default: return assertNever(prop.type, 'validateArgs') - } - // Enum membership, checked uniformly: the converter emits `enum` for any - // type ([prop.enum]), so the validator must too. `enum` is `string[]`, so a - // non-string value can never be a member — it falls out here, consistent - // with the schema the model was given. - if (prop.enum && !(prop.enum as unknown[]).includes(value)) { - return [`"${path}" must be one of ${JSON.stringify(prop.enum)}`] - } - return [] -} - -/** Collect violations for an object value against a {@link SchemaSpec}. */ -function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] { - if (!isPlainObject(value)) return [`"${path || 'arguments'}" must be an object`] - const violations: string[] = [] - for (const [key, prop] of Object.entries(spec)) { - const propPath = path ? `${path}.${key}` : key - const v = value[key] - if (v === undefined) { - // A required key absent OR present-but-undefined is a violation; an - // optional absent key is fine. `default` is NOT applied (validation only). - if (prop.required === true) violations.push(`missing required property "${propPath}"`) - continue - } - violations.push(...checkValue(prop, v, propPath)) - } - return violations -} - /** - * Validate model-generated `args` against a {@link SchemaSpec}, returning a - * list of human-readable violation messages (empty = valid). Total — never - * throws, regardless of how malformed `args` is. - * - * Semantics mirror {@link schemaSpecToJsonSchema} exactly: the top level must - * be a non-array object; required keys come only from `required: true`; extra - * keys are allowed (no `additionalProperties: false`); `default` is not - * applied; an `object`/`array` prop without `properties`/`items` only - * type-checks; `enum` is membership (strings only). - * @param spec - the declared parameter schema to validate against. - * @param args - the model-generated arguments, however malformed. - * @returns the violation messages in declaration order; empty means valid. + * Validate model-generated arguments against an implicit parameter schema. + * @param spec - declared parameter schema. + * @param args - candidate arguments, however malformed. + * @returns Path-qualified violations; empty means valid. */ -export function validateArgs(spec: SchemaSpec, args: unknown): string[] { - return checkSpec(spec, args, '') +export function validateArgs(spec: ParameterSchemaSpec, args: unknown): string[] { + return validateJsonSchemaValue(parameterSchemaSpecToJsonSchema(spec), args, '') } -// --------------------------------------------------------------------------- -// defineTool — typed helper for first-party plugin authors -// --------------------------------------------------------------------------- - /** Options for {@link defineTool}. */ -export interface DefineToolOptions { +export interface DefineToolOptions { /** Tool name (must be unique). */ readonly name: string /** Human-readable description sent to the model. */ readonly description: string - /** - * Parameter schema using the per-property-required DSL. Converted to - * standard JSON Schema at runtime. - */ + /** Per-property parameter schema compiled to an implicit open object root. */ readonly parameters: S - /** - * Optional cooperative tool-call timeout budget in milliseconds. When given it - * must be a positive finite number; it is attached to the produced - * {@link ToolDefinition} for `@deepseek-ai/dsh-timeout-policy` to enforce and - * is never sent to the model. - */ + /** Optional positive cooperative timeout budget in milliseconds. */ readonly timeoutMs?: number /** - * Optional pure synchronous classifier for sibling overlap. It receives typed - * arguments after soft validation; invalid input returns `false` without - * invoking it. See {@link ToolDefinition.isConcurrencySafe}. + * Pure classifier for sibling overlap. * @param args - typed validated arguments. - * @returns whether this call may join a parallel group. + * @returns Whether the call may join a parallel group. */ isConcurrencySafe?(args: InferArgs): boolean /** - * Tool execution function. `args` is typed as {@link InferArgs} — zero - * casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing - * content only) or a `{ content, meta }` object to also attach a tool-private - * presentation payload (see {@link ToolExecuteReturn}). + * Execute the tool after argument validation. + * @param args - typed validated arguments. + * @param exec - execution identity, caller, cancellation, and nesting data. + * @returns Model-facing content and optional presentation metadata. */ execute(args: InferArgs, exec: ToolRunContext): Promise /** - * Optional: how to present the PENDING state of one call in a UI (an editor - * tool-call card, a CLI log line). `args` is the typed, schema-validated - * argument shape — zero casts. Pure and side-effect-free: a UI may call it - * during live streaming AND a session-log replay, so depend only on `args`. - * The tool owns its presentation so a UI never special-cases tool names. See - * {@link ToolCallView}. + * Pure pending-state presenter. + * @param args - typed validated arguments. + * @returns Tool-owned render intent, or `undefined` for the generic card. */ presentCall?(args: InferArgs): ToolCallView | undefined /** - * Optional: how to present the COMPLETED state, given the typed `args` and the - * `result`. Use it to reformat result content for a UI distinctly from the - * model-facing text (e.g. a fenced ```console block). Pure and side-effect- - * free for the same replay reason. See {@link ToolResultView}. + * Pure completed-state presenter. + * @param args - typed validated arguments. + * @param result - final model-facing tool result. + * @returns Tool-owned render intent, or `undefined` for the generic card. */ presentResult?(args: InferArgs, result: ToolResult): ToolResultView | undefined } /** - * Define a first-party tool whose execution and presentation arguments are - * inferred from its per-property schema. Raw JSON-Schema definitions remain - * valid inputs to {@link ToolRegistry.register}; this helper is authoring sugar. - * @param options - the tool's name, description, typed parameter schema, - * execute body, and optional presenters. - * @returns a registry-ready definition with strict execution validation and - * soft presenter and classifier validation for replay compatibility. + * Define a first-party tool with inferred arguments and strict execution + * validation. Replay-only presenters validate softly and fall back to generic + * rendering for obsolete logged arguments. + * @param options - typed definition and optional presenters. + * @returns A registry-ready definition. */ -export function defineTool(options: DefineToolOptions): ToolDefinition { - // Object-literal execute methods don't use `this`; the reference is safe. +export function defineTool(options: DefineToolOptions): ToolDefinition { + // Object-literal methods do not use `this`; retaining references is safe. // eslint-disable-next-line @typescript-eslint/unbound-method const userExecute = options.execute // eslint-disable-next-line @typescript-eslint/unbound-method @@ -334,41 +386,34 @@ export function defineTool(options: DefineToolOptions): if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) { throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`) } + const parameters = parameterSchemaSpecToJsonSchema(options.parameters) + const validate = (args: unknown): string[] => validateJsonSchemaValue(parameters, args, '') const tool: ToolDefinition = { name: options.name, description: options.description, - parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record, + parameters: parameters as unknown as Record, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), async execute(args: unknown, exec: ToolRunContext): Promise { - // Validate the model-generated args before the typed body runs. On - // mismatch we throw ToolArgsError; the registry turns it into an - // isError result so the model can self-correct. After this guard, the - // cast to InferArgs reflects the validated shape. - const violations = validateArgs(options.parameters, args) + const violations = validate(args) if (violations.length > 0) throw new ToolArgsError(violations) return userExecute(args as InferArgs, exec) }, } - // Presentation is display-only and may run on REPLAY of arbitrary logged args - // (possibly from an older schema), so it must never throw: validate softly and - // fall back to `undefined` (a generic UI presentation) on any mismatch, rather - // than the hard `ToolArgsError` the execute path raises. if (userPresentCall) { tool.presentCall = (args: unknown): ToolCallView | undefined => { - if (validateArgs(options.parameters, args).length > 0) return undefined + if (validate(args).length > 0) return undefined return userPresentCall(args as InferArgs) } } if (userPresentResult) { tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => { - if (validateArgs(options.parameters, args).length > 0) return undefined + if (validate(args).length > 0) return undefined return userPresentResult(args as InferArgs, result) } } - // Invalid arguments fail closed without invoking the typed classifier. if (userIsConcurrencySafe) { tool.isConcurrencySafe = (args: unknown): boolean => { - if (validateArgs(options.parameters, args).length > 0) return false + if (validate(args).length > 0) return false return userIsConcurrencySafe(args as InferArgs) } } diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tools/src/ts-types.ts index e5f67d0891..39cec5665e 100644 --- a/packages/core/tools/src/ts-types.ts +++ b/packages/core/tools/src/ts-types.ts @@ -7,6 +7,8 @@ */ import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import { assertSupportedJsonSchema } from './json-schema.ts' +import type { JsonSchemaScalar } from './json-schema.ts' /** Property names that are valid bare TS identifiers; anything else is quoted. */ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ @@ -30,47 +32,72 @@ function docLines(description: unknown, indent: number): string[] { return [`${pad(indent)}/** ${collapsed.replaceAll('*/', String.raw`*\/`)} */`] } +/** Render one scalar already validated by the unified schema boundary. */ +function renderScalar(value: JsonSchemaScalar): string { + return JSON.stringify(value) +} + +/** Render a validated scalar `const`/`enum`, falling back to the broad type. */ +function renderConstrainedScalar(node: Record, type: string): string { + const broad = type === 'integer' ? 'number' : type + if (Object.hasOwn(node, 'const')) return renderScalar(node.const as JsonSchemaScalar) + if (Object.hasOwn(node, 'enum')) { + return (node.enum as JsonSchemaScalar[]).map(renderScalar).join(' | ') + } + return broad +} + +/** Parenthesize a union or object intersection before applying `[]`. */ +function arrayItem(type: string): string { + return type.includes('|') || type.includes('&') ? `(${type})[]` : `${type}[]` +} + /** - * Map one JSON-Schema node to a TypeScript type literal. Handles exactly the - * subset the `defineTool` DSL emits — `object` (`properties` + `required`), - * `string` (with `enum` → a literal union), `number`, `boolean`, `array` - * (`items`) — and returns `unknown` for anything else, without throwing. + * Map one enforced JSON-Schema node to a TypeScript type literal. Supports + * every unified schema construct and returns `unknown` for malformed or + * unsupported inputs without throwing. * @param schema - the JSON-Schema node (any shape; hostile inputs degrade). * @param indent - the indentation level for nested object members. * @returns the TS type text (multi-line for objects with properties). */ export function jsonSchemaToTs(schema: unknown, indent = 0): string { - if (typeof schema !== 'object' || schema === null) return 'unknown' + try { + assertSupportedJsonSchema(schema) + } catch { + return 'unknown' + } const node = schema as Record + if (Object.hasOwn(node, 'oneOf')) { + return (node.oneOf as unknown[]).map(branch => jsonSchemaToTs(branch, indent)).join(' | ') + } + if (!Object.hasOwn(node, 'type')) return 'JsonValue' switch (node.type) { - case 'string': { - if (Array.isArray(node.enum) && node.enum.length > 0 && node.enum.every(value => typeof value === 'string')) { - return node.enum.map(value => JSON.stringify(value)).join(' | ') - } - return 'string' - } - case 'number': return 'number' - case 'boolean': return 'boolean' + case 'string': return renderConstrainedScalar(node, 'string') + case 'number': return renderConstrainedScalar(node, 'number') + case 'integer': return renderConstrainedScalar(node, 'integer') + case 'boolean': return renderConstrainedScalar(node, 'boolean') + case 'null': return renderConstrainedScalar(node, 'null') case 'array': { - const item = jsonSchemaToTs(node.items, indent) - // Parenthesize a union item type so `('a' | 'b')[]` parses as intended. - return item.includes('|') ? `(${item})[]` : `${item}[]` + return arrayItem(Object.hasOwn(node, 'items') ? jsonSchemaToTs(node.items, indent) : 'JsonValue') } case 'object': { const properties = node.properties - if (typeof properties !== 'object' || properties === null) return 'Record' + const open = node.additionalProperties !== false + if (properties === undefined) return open ? 'Record' : 'Record' const entries = Object.entries(properties as Record) - if (entries.length === 0) return 'Record' - const required = new Set(Array.isArray(node.required) ? node.required.filter(name => typeof name === 'string') : []) + if (entries.length === 0) return open ? 'Record' : 'Record' + const required = new Set(node.required as string[] | undefined) const lines: string[] = ['{'] for (const [name, prop] of entries) { - const description = typeof prop === 'object' && prop !== null ? (prop as Record).description : undefined + const description = (prop as Record).description lines.push(...docLines(description, indent + 1)) lines.push(`${pad(indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: ${jsonSchemaToTs(prop, indent + 1)};`) } lines.push(`${pad(indent)}}`) - return lines.join('\n') + const declared = lines.join('\n') + return open ? `${declared} & Record` : declared } + /* v8 ignore next -- assertSupportedJsonSchema narrowed this closed type union. */ default: return 'unknown' } } @@ -106,5 +133,6 @@ export function renderToolsSdk(schemas: ToolSchema[]): string { const declaration = members.length > 0 ? `declare const tools: {\n${members.join('\n')}\n}` : 'declare const tools: {}' - return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${declaration}\n\`\`\`` + const jsonValue = 'type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }' + return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${jsonValue}\n\n${declaration}\n\`\`\`` } diff --git a/packages/core/tools/tests/json-schema.spec.ts b/packages/core/tools/tests/json-schema.spec.ts index 6fa895288e..26124083a9 100644 --- a/packages/core/tools/tests/json-schema.spec.ts +++ b/packages/core/tools/tests/json-schema.spec.ts @@ -1,304 +1,326 @@ import { describe, expect, it } from 'vitest' import { - assertSupportedOutputSchema, - OutputSchemaError, - validateStructuredValue, - type StructuredOutputSchema, -} from '../src/json-schema.ts' + assertObjectJsonSchema, + assertSupportedJsonSchema, + JsonSchemaError, + validateJsonSchemaValue, + type JsonSchemaNode, + type ObjectJsonSchema, +} from '../src/index.ts' -/** Assert-and-narrow helper: the asserted schema, typed. */ -function asserted(schema: unknown): StructuredOutputSchema { - assertSupportedOutputSchema(schema) +function asserted(schema: unknown): JsonSchemaNode { + assertSupportedJsonSchema(schema) return schema } -/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */ -function violationsOf(schema: unknown): string[] { - try { - assertSupportedOutputSchema(schema) - } catch (error: unknown) { - if (error instanceof OutputSchemaError) return error.violations - throw error - } - throw new Error('expected the schema to be rejected') +function assertedObject(schema: unknown): ObjectJsonSchema { + assertObjectJsonSchema(schema) + return schema } -describe('assertSupportedOutputSchema', () => { - it('accepts a representative subset schema (all supported keywords)', () => { - const schema = asserted({ - type: 'object', - description: 'a finding', - title: 'Finding', - properties: { - file: { type: 'string', description: 'path' }, - line: { type: 'integer' }, - severity: { type: 'string', enum: ['low', 'high'] }, - kind: { type: 'string', const: 'bug' }, - score: { type: 'number' }, - confirmed: { type: 'boolean' }, - parent: { type: 'null' }, - tags: { type: 'array', items: { type: 'string' } }, - nested: { - type: 'object', - properties: { x: { type: 'number', default: 3, examples: [1, 2] } }, - additionalProperties: false, +function violationsOf(schema: unknown, objectRoot = false): string[] { + try { + if (objectRoot) assertObjectJsonSchema(schema) + else assertSupportedJsonSchema(schema) + } catch (error: unknown) { + if (error instanceof JsonSchemaError) return error.violations + throw error + } + throw new Error('expected schema rejection') +} + +describe('the enforced raw JSON Schema subset', () => { + it('accepts every JSON root and every supported node', () => { + for (const schema of [ + { type: 'string' }, + { type: 'number' }, + { type: 'integer' }, + { type: 'boolean' }, + { type: 'null' }, + { type: 'array', items: { type: 'string' } }, + { + type: 'object', + properties: { + nested: { type: 'object', properties: {}, additionalProperties: false }, + free: {}, }, - anything: { type: 'array' }, + required: ['nested'], + additionalProperties: true, }, - required: ['file', 'line'], - additionalProperties: true, - }) - expect(schema.type).toBe('object') + { oneOf: [{ type: 'string' }, { type: 'number' }] }, + { description: 'any JSON', title: 'JSON', default: null, examples: [1, 'x'] }, + ]) { + expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow() + } }) - it('rejects a non-object root (scalar/array-rooted schemas)', () => { - expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)']) - expect(violationsOf({ type: 'array', items: { type: 'string' } })) - .toContain('schema.type must be "object" (structured output is object-rooted)') + it('retains an object-root guard only at consumers that need it', () => { + expect(assertedObject({ type: 'object' }).type).toBe('object') + for (const schema of [{}, { type: 'string' }, { type: 'array' }, { oneOf: [{ type: 'string' }, { type: 'null' }] }]) { + expect(violationsOf(schema, true)).toEqual(['schema.type must be "object" (structured output is object-rooted)']) + } }) - it('rejects non-object schema nodes and missing/unknown type', () => { - expect(violationsOf('nope')).toEqual(['schema must be a schema object']) + it('rejects non-schema nodes, unknown types, and type arrays', () => { expect(violationsOf(null)).toEqual(['schema must be a schema object']) expect(violationsOf([])).toEqual(['schema must be a schema object']) - expect(violationsOf({})).toEqual(['schema.type must be one of object/array/string/number/integer/boolean/null']) + expect(violationsOf('no')).toEqual(['schema must be a schema object']) expect(violationsOf({ type: 'tuple' })[0]).toMatch(/type must be one of/) - expect(violationsOf({ type: 'object', properties: { a: 'str' } })).toEqual(['schema.properties.a must be a schema object']) - }) - - it('rejects type ARRAYS with a dedicated message', () => { expect(violationsOf({ type: ['string', 'null'] })) .toEqual(['schema.type must be a single type string (type arrays are not supported)']) }) - it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => { - for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) { - const bad = violationsOf({ type: 'object', [keyword]: [] }) - expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true) - } + it('enforces oneOf vocabulary and its minimum branch count', () => { + expect(violationsOf({ oneOf: [] })).toEqual(['schema.oneOf must be an array of at least two schemas']) + expect(violationsOf({ oneOf: [{}] })).toEqual(['schema.oneOf must be an array of at least two schemas']) + expect(violationsOf({ oneOf: 'x' })).toEqual(['schema.oneOf must be an array of at least two schemas']) + expect(violationsOf({ type: 'string', oneOf: [{}, {}] })) + .toEqual(['schema cannot declare both type and oneOf']) + expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'number' }], items: {} })) + .toEqual(['schema.items is not supported beside oneOf']) + expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'weird' }] })[0]) + .toContain('schema.oneOf[1].type') }) - it('reports EVERY violation, not just the first', () => { - const bad = violationsOf({ + it('rejects unknown and misplaced keywords without accepted-then-ignored behavior', () => { + for (const keyword of ['anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) { + expect(violationsOf({ type: 'object', [keyword]: [] })[0]).toContain(`schema.${keyword} is not a supported keyword`) + } + expect(violationsOf({ type: 'object', items: {} })) + .toEqual(['schema.items is not supported on type "object"']) + expect(violationsOf({ type: 'array', properties: {} })) + .toEqual(['schema.properties is not supported on type "array"']) + expect(violationsOf({ type: 'object', enum: ['x'] })) + .toEqual(['schema.enum is not supported on type "object"']) + expect(violationsOf({ type: 'array', const: null })) + .toEqual(['schema.const is not supported on type "array"']) + expect(violationsOf({ properties: {}, required: [], additionalProperties: true, items: {}, enum: [], const: null })) + .toEqual([ + 'schema.properties requires type or oneOf', + 'schema.required requires type or oneOf', + 'schema.additionalProperties requires type or oneOf', + 'schema.items requires type or oneOf', + 'schema.enum requires type or oneOf', + 'schema.const requires type or oneOf', + ]) + }) + + it('reports every independent schema violation', () => { + expect(violationsOf({ type: 'object', pattern: 'x', properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } }, - }) - expect(bad.length).toBe(3) + })).toHaveLength(3) }) - it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => { - expect(violationsOf({ type: 'object', items: { type: 'string' } })) - .toEqual(['schema.items is not supported on type "object"']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } })) - .toEqual(['schema.properties.a.properties is not supported on type "string"']) - expect(violationsOf({ type: 'object', enum: [1] })) - .toEqual(['schema.enum is not supported on type "object"']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'array', const: 1 } } })) - .toEqual(['schema.properties.a.const is not supported on type "array"']) - }) - - it('validates required: must be string[] naming declared properties', () => { - expect(violationsOf({ type: 'object', required: 'file' })) + it('validates object properties, required names, and openness', () => { + expect(violationsOf({ type: 'object', properties: [] })) + .toEqual(['schema.properties must be an object of schemas']) + expect(violationsOf({ type: 'object', properties: { a: 'x' } })) + .toEqual(['schema.properties.a must be a schema object']) + expect(violationsOf({ type: 'object', required: 'a' })) .toEqual(['schema.required must be an array of strings']) expect(violationsOf({ type: 'object', required: [1] })) .toEqual(['schema.required must be an array of strings']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] })) - .toEqual(['schema.required names "b" which is not in properties']) - expect(violationsOf({ type: 'object', required: ['a'] })) - .toEqual(['schema.required names "a" which is not in properties']) - }) - - it('validates additionalProperties must be boolean and enum/const must be scalars', () => { - expect(violationsOf({ type: 'object', additionalProperties: {} })) + expect(violationsOf({ type: 'object', properties: {}, required: ['missing'] })) + .toEqual(['schema.required names "missing" which is not in properties']) + expect(violationsOf({ type: 'object', additionalProperties: 'yes' })) .toEqual(['schema.additionalProperties must be a boolean']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } })) - .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } })) - .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } })) - .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'number', enum: [Number.NaN] } } })) - .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', const: {} } } })) - .toEqual(['schema.properties.a.const must be a scalar']) + expect(violationsOf({ type: 'object', properties: undefined })) + .toEqual(['schema.properties must be an object of schemas']) + expect(violationsOf({ type: 'object', properties: undefined, required: ['missing'] })) + .toEqual([ + 'schema.properties must be an object of schemas', + 'schema.required names "missing" which is not in properties', + ]) }) - it('rejects non-string description/title and non-JSON annotation payloads', () => { - expect(violationsOf({ type: 'object', description: 7 })) - .toEqual(['schema.description must be a string']) - expect(violationsOf({ type: 'object', title: 7 })) - .toEqual(['schema.title must be a string']) - expect(violationsOf({ type: 'object', default: () => 1 })) - .toEqual(['schema.default annotation must be JSON data']) - expect(violationsOf({ type: 'object', examples: [undefined] })) - .toEqual(['schema.examples annotation must be JSON data']) - expect(violationsOf({ type: 'object', examples: [Number.POSITIVE_INFINITY] })) - .toEqual(['schema.examples annotation must be JSON data']) - // A cyclic annotation payload is caught by the JSON-data walk. - const cyclicAnnotation: Record = {} - cyclicAnnotation.self = cyclicAnnotation - expect(violationsOf({ type: 'object', default: cyclicAnnotation })) - .toEqual(['schema.default annotation must be JSON data']) - // Object/array annotations that ARE JSON data pass. - asserted({ type: 'object', default: { a: [1, 'x', null, true] } }) + it('requires type-correct scalar enum and const values', () => { + for (const schema of [ + { type: 'string', enum: ['a'], const: 'a' }, + { type: 'number', enum: [1.5], const: 1.5 }, + { type: 'integer', enum: [1], const: 1 }, + { type: 'boolean', enum: [true], const: true }, + { type: 'null', enum: [null], const: null }, + ]) { + expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow() + } + + expect(violationsOf({ type: 'string', enum: [] })) + .toEqual(['schema.enum must be a non-empty array of string values']) + expect(violationsOf({ type: 'number', enum: ['1'] })) + .toEqual(['schema.enum must be a non-empty array of number values']) + expect(violationsOf({ type: 'integer', enum: [1.5] })) + .toEqual(['schema.enum must be a non-empty array of integer values']) + expect(violationsOf({ type: 'number', enum: [Number.NaN] })) + .toEqual(['schema.enum must be a non-empty array of number values']) + expect(violationsOf({ type: 'number', const: -0 })) + .toEqual(['schema.const must be a number value']) + expect(violationsOf({ type: 'boolean', const: 1 })) + .toEqual(['schema.const must be a boolean value']) + expect(violationsOf({ type: 'string', enum: undefined })) + .toEqual(['schema.enum must be a non-empty array of string values']) }) - it('rejects a circular schema instead of recursing forever', () => { - const node: Record = { type: 'object' } - node.properties = { self: node } - expect(violationsOf(node)).toEqual(['schema.properties.self is circular']) + it('validates annotation types and lossless JSON payloads', () => { + expect(violationsOf({ description: 1 })).toEqual(['schema.description must be a string']) + expect(violationsOf({ title: 1 })).toEqual(['schema.title must be a string']) + for (const [key, value] of [ + ['default', undefined], + ['examples', [undefined]], + ['default', Number.POSITIVE_INFINITY], + ['examples', new Date(0)], + ] as const) { + expect(violationsOf({ [key]: value })).toEqual([`schema.${key} annotation must be lossless JSON data`]) + } + const cyclic: Record = {} + cyclic.self = cyclic + expect(violationsOf({ default: cyclic })) + .toEqual(['schema.default annotation must be lossless JSON data']) + + const explosive = new Proxy({}, { + ownKeys() { throw new Error('annotation trap') }, + }) + expect(violationsOf({ examples: explosive })) + .toEqual(['schema.examples annotation must be lossless JSON data']) }) - it('accepts the same subschema object reused in two SIBLING positions (a DAG, not a cycle)', () => { + it('rejects cyclic/exotic schema structure but permits sibling reuse', () => { + const cyclic: Record = { type: 'object' } + cyclic.properties = { self: cyclic } + expect(violationsOf(cyclic)).toEqual(['schema.properties.self is circular']) const leaf = { type: 'string' } - asserted({ type: 'object', properties: { a: leaf, b: leaf } }) - }) - - it('required cannot be satisfied by INHERITED names — `toString` is not a declared property', () => { - // `'toString' in {}` is true via Object.prototype; the declared-property - // contract must be an own-property check. - expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] })) - .toEqual(['schema.required names "toString" which is not in properties']) - }) - - it('rejects exotic host objects where the subset expects plain JSON structure', () => { - // A Map as `properties` has no own enumerable entries: structurally it - // would read as "no properties" and serialize to {} — lossy, not loud. + expect(() => { assertSupportedJsonSchema({ type: 'object', properties: { a: leaf, b: leaf } }) }).not.toThrow() expect(violationsOf({ type: 'object', properties: new Map() })) .toEqual(['schema.properties must be an object of schemas']) - // A Date node is not a schema object even though Object.values(date) is []. expect(violationsOf({ type: 'object', properties: { at: new Date(0) } })) .toEqual(['schema.properties.at must be a schema object']) }) - it('rejects exotic annotation payloads that would serialize lossily', () => { - expect(violationsOf({ type: 'object', default: new Date(0) })) - .toEqual(['schema.default annotation must be JSON data']) - expect(violationsOf({ type: 'object', examples: [new Map()] })) - .toEqual(['schema.examples annotation must be JSON data']) + it('uses own-property semantics for required declarations', () => { + expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] })) + .toEqual(['schema.required names "toString" which is not in properties']) }) }) -describe('validateStructuredValue', () => { - const schema = asserted({ - type: 'object', - properties: { - file: { type: 'string' }, - line: { type: 'integer' }, - score: { type: 'number' }, - confirmed: { type: 'boolean' }, - parent: { type: 'null' }, - severity: { type: 'string', enum: ['low', 'high'] }, - kind: { type: 'string', const: 'bug' }, - tags: { type: 'array', items: { type: 'string' } }, - free: { type: 'array' }, - nested: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false }, - }, - required: ['file'], +describe('validateJsonSchemaValue', () => { + it('validates scalar, array, object, and null roots', () => { + expect(validateJsonSchemaValue(asserted({ type: 'string' }), 'x')).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'number' }), 1.5)).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 2)).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), true)).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'null' }), null)).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'array', items: { type: 'string' } }), ['x'])).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: 1 })).toEqual([]) }) - it('accepts a fully valid value (empty violations)', () => { - expect(validateStructuredValue(schema, { - file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null, - severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 }, - })).toEqual([]) + it('rejects wrong scalar types and lossy numbers', () => { + expect(validateJsonSchemaValue(asserted({ type: 'string' }), 1)).toEqual(['"value" must be a string']) + expect(validateJsonSchemaValue(asserted({ type: 'number' }), '1')).toEqual(['"value" must be a number']) + expect(validateJsonSchemaValue(asserted({ type: 'number' }), Number.NaN)).toEqual(['"value" must be a finite JSON number']) + expect(validateJsonSchemaValue(asserted({ type: 'number' }), -0)).toEqual(['"value" must be a finite JSON number']) + expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 1.5)).toEqual(['"value" must be an integer']) + expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), 'true')).toEqual(['"value" must be a boolean']) + expect(validateJsonSchemaValue(asserted({ type: 'null' }), 0)).toEqual(['"value" must be null']) }) - it('reports missing required and wrong root type', () => { - expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"']) - expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object']) - expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object']) + it('enforces scalar enum and const together', () => { + const schema = asserted({ type: 'string', enum: ['a', 'b'], const: 'a' }) + expect(validateJsonSchemaValue(schema, 'a')).toEqual([]) + expect(validateJsonSchemaValue(schema, 'c')).toEqual(['"value" must be one of ["a","b"]']) + expect(validateJsonSchemaValue(schema, 'b')).toEqual(['"value" must be "a"']) }) - it('type-checks every scalar branch with path-qualified messages', () => { - expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string']) - expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer']) - expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer']) - expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number']) - expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number']) - expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean']) - expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null']) + it('validates object requiredness, nested values, and raw open defaults', () => { + const open = asserted({ + type: 'object', + properties: { + file: { type: 'string' }, + nested: { + type: 'object', + properties: { line: { type: 'integer' } }, + required: ['line'], + additionalProperties: false, + }, + }, + required: ['file'], + }) + expect(validateJsonSchemaValue(open, { file: 'a', extra: [1], nested: { line: 2 } })).toEqual([]) + expect(validateJsonSchemaValue(open, { nested: { line: 1 } })) + .toEqual(['missing required property "value.file"']) + expect(validateJsonSchemaValue(open, { file: 1, nested: {} })).toEqual([ + '"value.file" must be a string', + 'missing required property "value.nested.line"', + ]) + expect(validateJsonSchemaValue(open, { file: 'a', nested: { line: 1, extra: true } })) + .toEqual(['"value.nested.extra" is not a declared property (additionalProperties: false)']) + expect(validateJsonSchemaValue(open, 'x')).toEqual(['"value" must be an object']) }) - it('enforces enum membership and const equality', () => { - expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' })) - .toEqual(['"value.severity" must be one of ["low","high"]']) - expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' })) - .toEqual(['"value.kind" must be "bug"']) + it('treats present undefined as missing when required, then rejects other lossy objects', () => { + const required = asserted({ type: 'object', properties: { x: {} }, required: ['x'] }) + expect(validateJsonSchemaValue(required, { x: undefined })) + .toEqual(['missing required property "value.x"']) + expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: undefined })) + .toEqual(['"value" must be a lossless JSON object']) + expect(validateJsonSchemaValue(asserted({ type: 'object' }), new Date(0))) + .toEqual(['"value" must be an object']) }) - it('checks arrays per index; an items-less array accepts anything', () => { - expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array']) - expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string']) - expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).toEqual([]) + it('validates dense arrays per index and rejects lossy arrays', () => { + const schema = asserted({ type: 'array', items: { type: 'integer' } }) + expect(validateJsonSchemaValue(schema, [1, 2])).toEqual([]) + expect(validateJsonSchemaValue(schema, [1, 1.5])).toEqual(['"value[1]" must be an integer']) + expect(validateJsonSchemaValue(schema, 'x')).toEqual(['"value" must be an array']) + const sparse: number[] = [] + sparse.length = 2 + sparse[0] = 1 + expect(validateJsonSchemaValue(schema, sparse)).toEqual(['"value" must be a dense lossless JSON array']) }) - it('recurses into nested objects: required + additionalProperties: false', () => { - expect(validateStructuredValue(schema, { file: 'a', nested: {} })) - .toEqual(['missing required property "value.nested.x"']) - expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } })) - .toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)']) - expect(validateStructuredValue(schema, { file: 'a', nested: 3 })) - .toEqual(['"value.nested" must be an object']) + it('validates exact-one oneOf semantics, including overlap', () => { + const disjoint = asserted({ oneOf: [{ type: 'string' }, { type: 'number' }] }) + expect(validateJsonSchemaValue(disjoint, 'x')).toEqual([]) + expect(validateJsonSchemaValue(disjoint, null)) + .toEqual(['"value" must match exactly one oneOf branch (matched 0)']) + const overlap = asserted({ oneOf: [{ type: 'number' }, { type: 'integer' }] }) + expect(validateJsonSchemaValue(overlap, 1)) + .toEqual(['"value" must match exactly one oneOf branch (matched 2)']) + expect(validateJsonSchemaValue(overlap, 1.5)).toEqual([]) }) - it('a required key present-but-undefined counts as missing', () => { - expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"']) + it('an unconstrained schema accepts only lossless JSON values', () => { + const anyJson = asserted({}) + for (const value of [null, true, 1, 'x', [1], { x: null }]) { + expect(validateJsonSchemaValue(anyJson, value), JSON.stringify(value)).toEqual([]) + } + for (const value of [undefined, () => 1, Number.POSITIVE_INFINITY, -0, new Map()]) { + expect(validateJsonSchemaValue(anyJson, value)).toEqual(['"value" must be a lossless JSON value']) + } + const cyclic: Record = {} + cyclic.self = cyclic + expect(validateJsonSchemaValue(anyJson, cyclic)).toEqual(['"value" must be a lossless JSON value']) + const explosive = new Proxy({}, { + ownKeys() { throw new Error('value trap') }, + }) + expect(validateJsonSchemaValue(anyJson, explosive)).toEqual(['"value" must be a lossless JSON value']) }) - it('inherited properties satisfy nothing: required, additionalProperties, and recursion are own-property only', () => { - // required: ['toString'] must NOT be satisfied by Object.prototype.toString. - expect(validateStructuredValue( + it('uses own properties for requiredness, recursion, and closed-object checks', () => { + expect(validateJsonSchemaValue( asserted({ type: 'object', properties: { toString: { type: 'string' } }, required: ['toString'] }), {}, )).toEqual(['missing required property "value.toString"']) - // additionalProperties: false must flag an OWN `toString` key even though - // `'toString' in properties` is true via the prototype. - expect(validateStructuredValue( - asserted({ type: 'object', additionalProperties: false }), - { toString: 1 }, - )).toEqual(['"value.toString" is not a declared property (additionalProperties: false)']) - // A declared property the value does NOT carry must not be validated - // against the value's INHERITED member (constructor is a function on - // every plain object's prototype, not a carried property). - expect(validateStructuredValue( + expect(validateJsonSchemaValue(asserted({ type: 'object', additionalProperties: false }), { toString: 1 })) + .toEqual(['"value.toString" is not a declared property (additionalProperties: false)']) + expect(validateJsonSchemaValue( asserted({ type: 'object', properties: { constructor: { type: 'string' } } }), {}, )).toEqual([]) }) - it('a non-plain object value is not an object in the JSON sense', () => { - expect(validateStructuredValue(asserted({ type: 'object' }), new Date(0))) - .toEqual(['"value" must be an object']) - }) - - it('collects multiple violations across branches in one pass', () => { - expect(validateStructuredValue(schema, { line: 'x', severity: 'mid' })).toEqual([ - 'missing required property "value.file"', - '"value.line" must be an integer', - '"value.severity" must be one of ["low","high"]', - ]) - }) - - it('null-typed const/enum work through the scalar path', () => { - const nullish = asserted({ type: 'object', properties: { a: { type: 'null', const: null } } }) - expect(validateStructuredValue(nullish, { a: null })).toEqual([]) - }) - - it('rejects a non-object properties value in the schema walk', () => { - expect(violationsOf({ type: 'object', properties: [] })) - .toEqual(['schema.properties must be an object of schemas']) - }) - - it('an object schema without properties/required only type-checks its value', () => { - const bare = asserted({ type: 'object' }) - expect(validateStructuredValue(bare, { any: ['thing'] })).toEqual([]) - expect(validateStructuredValue(bare, 7)).toEqual(['"value" must be an object']) - }) - - it('validateStructuredValue throws on a type the assert would never let through (assertNever backstop)', () => { - const forged = { type: 'tuple' } as unknown as StructuredOutputSchema - expect(() => validateStructuredValue(forged, 1)).toThrow(/tuple/) + it('keeps assertNever as a forged-schema backstop', () => { + const forged = { type: 'tuple' } as unknown as JsonSchemaNode + expect(() => validateJsonSchemaValue(forged, 1)).toThrow(/tuple/) }) }) diff --git a/packages/core/tools/tests/properties.spec.ts b/packages/core/tools/tests/properties.spec.ts index 51b49fccd4..54c4088909 100644 --- a/packages/core/tools/tests/properties.spec.ts +++ b/packages/core/tools/tests/properties.spec.ts @@ -1,61 +1,91 @@ /** * Property-based tests for the tool-schema DSL (the property-testing Agent Note), including - * the the property-testing ↔ runtime-validation composition composition: generated args that satisfy a SchemaSpec must + * the property-testing ↔ runtime-validation composition: generated args that satisfy a ParameterSchemaSpec must * pass validateArgs, and targeted corruptions must be rejected. This closes the * validator/InferArgs drift risk noted in the arg-validation Agent Note. */ import { describe, expect, it } from 'vitest' import fc from 'fast-check' -import { schemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools' -import type { SchemaProp, SchemaSpec } from '@deepseek-ai/dsh-tools' +import { parameterSchemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools' +import type { ParameterPropertySpec, ParameterSchemaSpec, ValueSchemaSpec } from '@deepseek-ai/dsh-tools' + +/** Remove parameter-only requiredness before nesting a schema as an array item. */ +function asValueSchema(prop: ParameterPropertySpec): ValueSchemaSpec { + const { required: _required, ...schema } = prop + return schema +} // A leaf prop arbitrary (no nesting) with optional required/enum. -function leafPropArb(): fc.Arbitrary { +function leafPropArb(): fc.Arbitrary { return fc.oneof( - fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'string', ...required ? { required: true } : {} })), - fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'number', ...required ? { required: true } : {} })), - fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'boolean', ...required ? { required: true } : {} })), + fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'string', ...required ? { required: true } : {} })), + fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'number', ...required ? { required: true } : {} })), + fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'integer', ...required ? { required: true } : {} })), + fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'boolean', ...required ? { required: true } : {} })), + fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'null', ...required ? { required: true } : {} })), + fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'json', ...required ? { required: true } : {} })), fc.record({ values: fc.uniqueArray(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 3 }), required: fc.boolean() }) - .map(({ values, required }): SchemaProp => ({ type: 'string', enum: values, ...required ? { required: true } : {} })), + .map(({ values, required }): ParameterPropertySpec => ({ type: 'string', enum: values, ...required ? { required: true } : {} })), + fc.record({ value: fc.string(), required: fc.boolean() }) + .map(({ value, required }): ParameterPropertySpec => ({ type: 'string', const: value, ...required ? { required: true } : {} })), + fc.record({ required: fc.boolean() }) + .map(({ required }): ParameterPropertySpec => ({ + oneOf: [{ type: 'string' }, { type: 'null' }], + ...required ? { required: true } : {}, + })), ) } /** A prop arbitrary up to `depth` levels of nesting (objects and arrays). */ -function propArb(depth: number): fc.Arbitrary { +function propArb(depth: number): fc.Arbitrary { if (depth <= 0) return leafPropArb() return fc.oneof( { weight: 3, arbitrary: leafPropArb() }, { weight: 1, - arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean() }) - .map(({ properties, required }): SchemaProp => ({ type: 'object', properties, ...required ? { required: true } : {} })), + arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean(), additionalProperties: fc.boolean() }) + .map(({ properties, required, additionalProperties }): ParameterPropertySpec => ({ + type: 'object', + additionalProperties, + properties, + ...required ? { required: true } : {}, + })), }, { weight: 1, arbitrary: fc.record({ items: propArb(depth - 1), required: fc.boolean() }) - .map(({ items, required }): SchemaProp => ({ type: 'array', items, ...required ? { required: true } : {} })), + .map(({ items, required }): ParameterPropertySpec => ({ + type: 'array', + items: asValueSchema(items), + ...required ? { required: true } : {}, + })), }, ) } -function specArb(depth: number): fc.Arbitrary { +function specArb(depth: number): fc.Arbitrary { return fc.dictionary(fc.string({ minLength: 1, maxLength: 6 }), propArb(depth), { maxKeys: 4 }) } /** Generate a value that satisfies a prop (used to build valid args). */ -function valueForProp(prop: SchemaProp): fc.Arbitrary { +function valueForProp(prop: ParameterPropertySpec): fc.Arbitrary { + if ('oneOf' in prop) return fc.oneof(...prop.oneOf.map(valueForProp)) + if ('const' in prop) return fc.constant(prop.const) switch (prop.type) { case 'string': return prop.enum ? fc.constantFrom(...prop.enum) : fc.string() - case 'number': return fc.double({ noNaN: true, noDefaultInfinity: true }) + case 'number': return fc.double({ noNaN: true, noDefaultInfinity: true }).filter(value => !Object.is(value, -0)) + case 'integer': return fc.integer() case 'boolean': return fc.boolean() + case 'null': return fc.constant(null) case 'object': return prop.properties ? validArgsForSpec(prop.properties) : fc.constant({}) case 'array': return prop.items ? fc.array(valueForProp(prop.items), { maxLength: 3 }) : fc.constant([]) + case 'json': return fc.jsonValue() } } /** Generate args satisfying every required key of a spec (optionals included randomly). */ -function validArgsForSpec(spec: SchemaSpec): fc.Arbitrary> { +function validArgsForSpec(spec: ParameterSchemaSpec): fc.Arbitrary> { const entries = Object.entries(spec) return fc.tuple(...entries.map(([key, prop]) => fc.tuple( @@ -76,29 +106,29 @@ function validArgsForSpec(spec: SchemaSpec): fc.Arbitrary p.required === true).map(([k]) => k) } describe('schema DSL properties', () => { it('JSON Schema `required` equals the required:true keys at every level', () => { fc.assert(fc.property(specArb(2), (spec) => { - const checkLevel = (s: SchemaSpec, json: { required?: string[]; properties: Record }) => { + const checkLevel = (s: ParameterSchemaSpec, json: { required?: string[]; properties: Record }) => { expect(new Set(json.required ?? [])).toEqual(new Set(requiredKeys(s))) for (const [key, prop] of Object.entries(s)) { const propJson = json.properties[key] as Record - if (prop.type === 'object' && prop.properties) { + if ('type' in prop && prop.type === 'object' && prop.properties) { checkLevel(prop.properties, propJson as { required?: string[]; properties: Record }) } } } - checkLevel(spec, schemaSpecToJsonSchema(spec)) + checkLevel(spec, parameterSchemaSpecToJsonSchema(spec)) })) }) it('conversion is total (never throws) for any spec', () => { fc.assert(fc.property(specArb(3), (spec) => { - expect(() => schemaSpecToJsonSchema(spec)).not.toThrow() + expect(() => parameterSchemaSpecToJsonSchema(spec)).not.toThrow() })) }) diff --git a/packages/core/tools/tests/schema.spec.ts b/packages/core/tools/tests/schema.spec.ts new file mode 100644 index 0000000000..5bbd5b8b6d --- /dev/null +++ b/packages/core/tools/tests/schema.spec.ts @@ -0,0 +1,138 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { + JsonSchemaError, + parameterSchemaSpecToJsonSchema, + valueSchemaSpecToJsonSchema, + type InferArgs, + type InferValue, + type JsonValue, + type ParameterSchemaSpec, + type ValueSchemaSpec, +} from '../src/index.ts' + +describe('the unified author schema DSL', () => { + it('compiles every value root and the author-only json node', () => { + expect(valueSchemaSpecToJsonSchema({ type: 'string', enum: ['a', 'b'], const: 'a' })) + .toEqual({ type: 'string', enum: ['a', 'b'], const: 'a' }) + expect(valueSchemaSpecToJsonSchema({ type: 'number' })).toEqual({ type: 'number' }) + expect(valueSchemaSpecToJsonSchema({ type: 'integer' })).toEqual({ type: 'integer' }) + expect(valueSchemaSpecToJsonSchema({ type: 'boolean' })).toEqual({ type: 'boolean' }) + expect(valueSchemaSpecToJsonSchema({ type: 'null' })).toEqual({ type: 'null' }) + expect(valueSchemaSpecToJsonSchema({ type: 'array', items: { type: 'json' } })) + .toEqual({ type: 'array', items: {} }) + expect(valueSchemaSpecToJsonSchema({ type: 'object', additionalProperties: false, properties: {} })) + .toEqual({ type: 'object', additionalProperties: false, properties: {} }) + expect(valueSchemaSpecToJsonSchema({ + type: 'json', + description: 'anything', + title: 'Any JSON', + default: null, + examples: [{ nested: true }], + })).toEqual({ description: 'anything', title: 'Any JSON', default: null, examples: [{ nested: true }] }) + expect(valueSchemaSpecToJsonSchema({ oneOf: [{ type: 'string' }, { type: 'null' }] })) + .toEqual({ oneOf: [{ type: 'string' }, { type: 'null' }] }) + }) + + it('keeps the implicit parameter root open while preserving explicit object openness', () => { + expect(parameterSchemaSpecToJsonSchema({ + closed: { + type: 'object', + additionalProperties: false, + required: true, + properties: { id: { type: 'integer', required: true } }, + }, + open: { type: 'object', additionalProperties: true }, + })).toEqual({ + type: 'object', + properties: { + closed: { + type: 'object', + additionalProperties: false, + properties: { id: { type: 'integer' } }, + required: ['id'], + }, + open: { type: 'object', additionalProperties: true }, + }, + required: ['closed'], + }) + }) + + it('rejects runtime-forged author forms rather than compiling them lossily', () => { + for (const schema of [ + { type: 'object' }, + { oneOf: [{ type: 'string' }] }, + { type: 'number', enum: ['1'] }, + { type: 'integer', const: 1.5 }, + { type: 'json', default: undefined }, + { type: 'array', items: { type: 'string', required: true } }, + { type: 'array', items: 42 }, + { type: 'string', extra: true }, + { type: 'string', oneOf: [{ type: 'string' }, { type: 'null' }] }, + { oneOf: 'not-an-array' }, + { type: 'string', enum: 'a' }, + null, + ]) { + expect(() => valueSchemaSpecToJsonSchema(schema as ValueSchemaSpec), JSON.stringify(schema)).toThrow(JsonSchemaError) + } + expect(() => parameterSchemaSpecToJsonSchema({ + value: { type: 'string', required: false }, + } as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError) + expect(() => parameterSchemaSpecToJsonSchema(null as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError) + expect(() => parameterSchemaSpecToJsonSchema({ bad: 42 } as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError) + }) + + it('rejects cyclic author schemas', () => { + const schema: Record = { type: 'array' } + schema.items = schema + expect(() => valueSchemaSpecToJsonSchema(schema as unknown as ValueSchemaSpec)).toThrow(/circular/) + + const properties: Record = {} + properties.self = { type: 'object', additionalProperties: true, properties } + expect(() => parameterSchemaSpecToJsonSchema(properties as ParameterSchemaSpec)).toThrow(/circular/) + }) + + it('infers scalar literals, arrays, objects, json, and exact-one unions', () => { + expectTypeOf>().toEqualTypeOf<'a' | 'b'>() + expectTypeOf>().toEqualTypeOf<1>() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>() + .toEqualTypeOf() + expectTypeOf>().toEqualTypeOf<{ id: number; label?: string }>() + expectTypeOf>().toEqualTypeOf<{ id: number } & Record>() + }) + + it('infers required and optional parameter keys', () => { + expectTypeOf>().toEqualTypeOf<{ path: string; offset?: number; data?: JsonValue }>() + }) + + it('makes invalid author forms compile-time errors', () => { + const invalidObjects = { + // @ts-expect-error explicit object schemas require an openness decision + object: { type: 'object' } satisfies ValueSchemaSpec, + // @ts-expect-error oneOf requires at least two branches + oneOf: { oneOf: [{ type: 'string' }] } satisfies ValueSchemaSpec, + // @ts-expect-error scalar enum values must match the node type + enum: { type: 'number', enum: ['1'] } satisfies ValueSchemaSpec, + // @ts-expect-error parameter requiredness is true-or-absent + required: { value: { type: 'string', required: false } } satisfies ParameterSchemaSpec, + } + expect(Object.keys(invalidObjects)).toHaveLength(4) + }) +}) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 6c35f4f549..5ddac2cd31 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -5,8 +5,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Agent } from '@deepseek-ai/dsh-agent' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import ToolRegistry, { - defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, - type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision, + defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, + type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, type ToolExecution, type ToolExecutionResult, } from '@deepseek-ai/dsh-tools' @@ -775,13 +775,13 @@ describe('ToolRegistry', () => { }) describe('defineTool / schema DSL', () => { - it('converts SchemaSpec to standard JSON Schema with required array', () => { + it('converts ParameterSchemaSpec to standard JSON Schema with required array', () => { const spec = { path: { type: 'string', required: true, description: 'Absolute path' }, offset: { type: 'number' }, limit: { type: 'number', description: 'Max lines' }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema).toEqual({ type: 'object', properties: { @@ -794,7 +794,7 @@ describe('defineTool / schema DSL', () => { }) it('handles empty spec (no properties, no required)', () => { - expect(schemaSpecToJsonSchema({})).toEqual({ + expect(parameterSchemaSpecToJsonSchema({})).toEqual({ type: 'object', properties: {}, }) @@ -804,19 +804,21 @@ describe('defineTool / schema DSL', () => { const spec = { config: { type: 'object', + additionalProperties: true, required: true, properties: { host: { type: 'string', required: true }, port: { type: 'number' }, }, }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema).toEqual({ type: 'object', properties: { config: { type: 'object', + additionalProperties: true, properties: { host: { type: 'string' }, port: { type: 'number' }, @@ -958,8 +960,8 @@ describe('schema DSL edge cases', () => { it('emits enum values in JSON Schema property', () => { const spec = { color: { type: 'string', enum: ['red', 'green', 'blue'], description: 'Color choice' }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema.properties['color']).toMatchObject({ type: 'string', enum: ['red', 'green', 'blue'], @@ -970,8 +972,8 @@ describe('schema DSL edge cases', () => { it('emits default value in JSON Schema property', () => { const spec = { limit: { type: 'number', default: 25 }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema.properties['limit']).toMatchObject({ type: 'number', default: 25, @@ -981,8 +983,8 @@ describe('schema DSL edge cases', () => { it('handles array items without nested properties (plain type array)', () => { const spec = { tags: { type: 'array', items: { type: 'string' } }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema.properties['tags']).toEqual({ type: 'array', items: { type: 'string' }, @@ -992,8 +994,8 @@ describe('schema DSL edge cases', () => { it('handles enum and default together in one property', () => { const spec = { level: { type: 'string', enum: ['low', 'high'], default: 'low' }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema.properties['level']).toMatchObject({ type: 'string', enum: ['low', 'high'], @@ -1004,8 +1006,8 @@ describe('schema DSL edge cases', () => { it('omits description, enum, default keys when not specified', () => { const spec = { bare: { type: 'string' }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) const prop = jsonSchema.properties['bare'] as Record expect(prop).toEqual({ type: 'string' }) expect('description' in prop).toBe(false) @@ -1016,8 +1018,8 @@ describe('schema DSL edge cases', () => { it('handles array with no items (items omitted)', () => { const spec = { raw: { type: 'array' }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema.properties['raw']).toEqual({ type: 'array', }) @@ -1027,13 +1029,14 @@ describe('schema DSL edge cases', () => { const spec = { config: { type: 'object', + additionalProperties: true, properties: { host: { type: 'string' }, port: { type: 'number' }, }, }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema.properties['config']).toMatchObject({ type: 'object', properties: { @@ -1064,6 +1067,7 @@ describe('schema DSL optional and nested contracts', () => { type: 'array' items: { type: 'object' + additionalProperties: true properties: { host: { type: 'string'; required: true } port: { type: 'number' } @@ -1073,7 +1077,7 @@ describe('schema DSL optional and nested contracts', () => { }> expectTypeOf().toEqualTypeOf<{ names: string[] - servers?: { host: string; port?: number }[] + servers?: ({ host: string; port?: number } & Record)[] }>() }) @@ -1083,20 +1087,22 @@ describe('schema DSL optional and nested contracts', () => { type: 'array', items: { type: 'object', + additionalProperties: true, properties: { host: { type: 'string', required: true }, port: { type: 'number' }, }, }, }, - } satisfies SchemaSpec - expect(schemaSpecToJsonSchema(spec)).toEqual({ + } satisfies ParameterSchemaSpec + expect(parameterSchemaSpecToJsonSchema(spec)).toEqual({ type: 'object', properties: { servers: { type: 'array', items: { type: 'object', + additionalProperties: true, properties: { host: { type: 'string' }, port: { type: 'number' }, @@ -1178,7 +1184,7 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => { const spec = { path: { type: 'string', required: true }, limit: { type: 'number' }, - } satisfies SchemaSpec + } satisfies ParameterSchemaSpec expect(validateArgs(spec, { path: '/tmp' })).toEqual([]) expect(validateArgs(spec, { path: '/tmp', limit: 5 })).toEqual([]) // never throws regardless of shape @@ -1188,18 +1194,18 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => { }) it('flags a missing required key and a required key present as undefined', () => { - const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec + const spec = { path: { type: 'string', required: true } } satisfies ParameterSchemaSpec expect(validateArgs(spec, {})).toEqual(['missing required property "path"']) expect(validateArgs(spec, { path: undefined })).toEqual(['missing required property "path"']) }) it('allows extra keys (no additionalProperties:false) and omitted optionals', () => { - const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec + const spec = { path: { type: 'string', required: true } } satisfies ParameterSchemaSpec expect(validateArgs(spec, { path: '/tmp', extra: 1 })).toEqual([]) }) it('does not apply defaults (validation only)', () => { - const spec = { limit: { type: 'number', default: 25 } } satisfies SchemaSpec + const spec = { limit: { type: 'number', default: 25 } } satisfies ParameterSchemaSpec // absent optional is valid, and validation does not synthesize the default expect(validateArgs(spec, {})).toEqual([]) }) @@ -1209,39 +1215,41 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => { s: { type: 'string' }, n: { type: 'number' }, b: { type: 'boolean' }, - } satisfies SchemaSpec + } satisfies ParameterSchemaSpec expect(validateArgs(spec, { s: 1 })).toEqual(['"s" must be a string']) expect(validateArgs(spec, { n: 'x' })).toEqual(['"n" must be a number']) expect(validateArgs(spec, { b: 'x' })).toEqual(['"b" must be a boolean']) }) it('checks enum membership', () => { - const spec = { color: { type: 'string', enum: ['red', 'green'] } } satisfies SchemaSpec + const spec = { color: { type: 'string', enum: ['red', 'green'] } } satisfies ParameterSchemaSpec expect(validateArgs(spec, { color: 'red' })).toEqual([]) expect(validateArgs(spec, { color: 'blue' })).toEqual(['"color" must be one of ["red","green"]']) }) - it('checks enum uniformly with the converter (enum on a non-string prop)', () => { - // The converter emits `enum` regardless of type; the validator must agree. - // `enum` is string[], so a number value can never be a member. - const spec = { n: { type: 'number', enum: ['1', '2'] } } as unknown as SchemaSpec - expect(validateArgs(spec, { n: 1 })).toEqual(['"n" must be one of ["1","2"]']) + it('enforces type-correct scalar enum declarations', () => { + const spec = { n: { type: 'number', enum: [1, 2] } } satisfies ParameterSchemaSpec + expect(validateArgs(spec, { n: 1 })).toEqual([]) + expect(validateArgs(spec, { n: 3 })).toEqual(['"n" must be one of [1,2]']) + const invalid = { n: { type: 'number', enum: ['1', '2'] } } as unknown as ParameterSchemaSpec + expect(() => validateArgs(invalid, { n: 1 })).toThrow(JsonSchemaError) }) - it('rejects an unknown SchemaType at runtime (assertNever guard)', () => { - const spec = { x: { type: 'weird' } } as unknown as SchemaSpec - expect(() => validateArgs(spec, { x: 1 })).toThrow(/unreachable variant.*validateArgs/) + it('rejects an unknown schema type at the author boundary', () => { + const spec = { x: { type: 'weird' } } as unknown as ParameterSchemaSpec + expect(() => validateArgs(spec, { x: 1 })).toThrow(JsonSchemaError) }) it('recurses into nested objects (and an object without properties only type-checks)', () => { const spec = { config: { type: 'object', + additionalProperties: true, required: true, properties: { host: { type: 'string', required: true }, port: { type: 'number' } }, }, - bag: { type: 'object' }, - } satisfies SchemaSpec + bag: { type: 'object', additionalProperties: true }, + } satisfies ParameterSchemaSpec expect(validateArgs(spec, { config: { host: 'h' }, bag: { anything: true } })).toEqual([]) expect(validateArgs(spec, { config: { port: 9 }, bag: 5 })).toEqual([ 'missing required property "config.host"', @@ -1253,7 +1261,7 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => { const spec = { tags: { type: 'array', items: { type: 'string' } }, raw: { type: 'array' }, - } satisfies SchemaSpec + } satisfies ParameterSchemaSpec expect(validateArgs(spec, { tags: ['a', 'b'], raw: [1, {}, 'x'] })).toEqual([]) expect(validateArgs(spec, { tags: ['a', 2] })).toEqual(['"tags[1]" must be a string']) // a non-array value for an array-typed prop @@ -1264,9 +1272,9 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => { const spec = { servers: { type: 'array', - items: { type: 'object', properties: { host: { type: 'string', required: true } } }, + items: { type: 'object', additionalProperties: true, properties: { host: { type: 'string', required: true } } }, }, - } satisfies SchemaSpec + } satisfies ParameterSchemaSpec expect(validateArgs(spec, { servers: [{ host: 'a' }, {}] })).toEqual([ 'missing required property "servers[1].host"', ]) diff --git a/packages/core/tools/tests/ts-types.spec.ts b/packages/core/tools/tests/ts-types.spec.ts index df30a58238..14b14f7ecd 100644 --- a/packages/core/tools/tests/ts-types.spec.ts +++ b/packages/core/tools/tests/ts-types.spec.ts @@ -1,20 +1,37 @@ import { describe, expect, it } from 'vitest' import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tools/src/ts-types.ts' -import { schemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools' +import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools' import type { ToolSchema } from '@deepseek-ai/dsh-llm' describe('jsonSchemaToTs', () => { - it('maps the defineTool DSL subset', () => { + it('maps every unified schema construct', () => { const cases: [unknown, string][] = [ [{ type: 'string' }, 'string'], [{ type: 'number' }, 'number'], + [{ type: 'integer' }, 'number'], [{ type: 'boolean' }, 'boolean'], + [{ type: 'null' }, 'null'], [{ type: 'string', enum: ['a', 'b'] }, '"a" | "b"'], + [{ type: 'number', enum: [1, 2] }, '1 | 2'], + [{ type: 'integer', const: 2 }, '2'], + [{ type: 'boolean', const: true }, 'true'], + [{ type: 'null', const: null }, 'null'], + [{ type: 'string', enum: ['a', 'b'], const: 'a' }, '"a"'], + [{ oneOf: [{ type: 'string' }, { type: 'null' }] }, 'string | null'], [{ type: 'array', items: { type: 'number' } }, 'number[]'], [{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, '("x" | "y")[]'], - [{ type: 'array' }, 'unknown[]'], - [{ type: 'object' }, 'Record'], - [{ type: 'object', properties: {} }, 'Record'], + [{ type: 'array' }, 'JsonValue[]'], + [{ type: 'object' }, 'Record'], + [{ type: 'object', additionalProperties: false }, 'Record'], + [{ type: 'object', properties: {} }, 'Record'], + [{ type: 'object', properties: {}, additionalProperties: false }, 'Record'], + [{ + type: 'object', + additionalProperties: false, + properties: { id: { type: 'integer' }, label: { type: 'string' } }, + required: ['id'], + }, ['{', ' id: number;', ' label?: string;', '}'].join('\n')], + [{}, 'JsonValue'], ] for (const [schema, expected] of cases) { expect(jsonSchemaToTs(schema), JSON.stringify(schema)).toBe(expected) @@ -22,11 +39,12 @@ describe('jsonSchemaToTs', () => { }) it('renders objects with required/optional keys, nested shapes, and per-property docs', () => { - const schema = schemaSpecToJsonSchema({ + const schema = parameterSchemaSpecToJsonSchema({ path: { type: 'string', required: true, description: 'Absolute file path' }, limit: { type: 'number' }, opts: { type: 'object', + additionalProperties: true, properties: { deep: { type: 'boolean', required: true } }, }, }) @@ -37,8 +55,8 @@ describe('jsonSchemaToTs', () => { ' limit?: number;', ' opts?: {', ' deep: boolean;', - ' };', - '}', + ' } & Record;', + '} & Record', ].join('\n')) }) @@ -48,9 +66,6 @@ describe('jsonSchemaToTs', () => { null, 42, 'string-schema', - {}, - { type: 'integer' }, - { type: 'null' }, { oneOf: [{ type: 'string' }] }, { $ref: '#/defs/x' }, { type: 'object', properties: 7 }, @@ -61,18 +76,13 @@ describe('jsonSchemaToTs', () => { for (const schema of cases) { expect(() => jsonSchemaToTs(schema), JSON.stringify(schema)).not.toThrow() } - expect(jsonSchemaToTs({ type: 'integer' })).toBe('unknown') expect(jsonSchemaToTs({ oneOf: [] })).toBe('unknown') - expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('Record') - expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toContain('bad: unknown;') - // A non-string-only enum degrades to plain string; an empty one too. - expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('string') - expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('string') - // A hostile required list only accepts string members. - expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toContain('a?: string;') - // A property VALUE that is not an object degrades to unknown (and can - // carry no description). - expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toContain('weird?: unknown;') + expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('unknown') + expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toBe('unknown') + expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('unknown') + expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('unknown') + expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toBe('unknown') + expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toBe('unknown') }) it('escapes a comment-closer inside a description so the generated JSDoc cannot end early', () => { @@ -89,17 +99,18 @@ describe('renderToolsSdk', () => { const bash: ToolSchema = { name: 'bash', description: 'Run a shell command.', - parameters: schemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record, + parameters: parameterSchemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record, } const exotic: ToolSchema = { name: 'my-mcp.tool', description: 'Exotic name.', - parameters: schemaSpecToJsonSchema({}) as unknown as Record, + parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record, } it('declares every tool in lexicographic order with quoted keys for exotic names', () => { const text = renderToolsSdk([exotic, bash]) expect(text).toContain('declare const tools: {') + expect(text).toContain('type JsonValue = null | boolean | number | string') expect(text.indexOf('bash(args:')).toBeGreaterThan(0) expect(text).toContain('"my-mcp.tool"(args:') expect(text.indexOf('bash(args:')).toBeLessThan(text.indexOf('"my-mcp.tool"(args:')) diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 09aa2d24b7..9d2cce1570 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -14,7 +14,7 @@ import type { Context } from 'cordis' import type { ContinuationStop } from '@deepseek-ai/dsh-agent' import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import type { ToolExecution } from '@deepseek-ai/dsh-tools' -import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools' /** The model-facing tool name a structured child must call to finish. */ export const STRUCTURED_OUTPUT_TOOL = 'structured_output' @@ -44,10 +44,10 @@ export interface StructuredAttachment { * its creation window. Child disposal removes every registration. * @param childCtx - the child agent's scope context (`setup`'s argument). * @param schema - the trusted, already-asserted schema subset to enforce (see - * `assertSupportedOutputSchema` in dsh-tools). + * `assertObjectJsonSchema` in dsh-tools). * @returns the attachment handle (read `captured()` after the child settles). */ -export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment { +export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSchema): StructuredAttachment { /** * Validated values staged by the capture tool body, awaiting THEIR OWN * authoritative `tools/result` notification. The execution object's identity @@ -75,7 +75,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut childCtx.tools.register({ ...schemaEntry, execute(args: unknown, exec: ToolExecution): Promise { - const violations = validateStructuredValue(schema, args) + const violations = validateJsonSchemaValue(schema, args) // ToolArgsError → isError result with INVALID_ARGS: the model retries // within the same turn, exactly like a schema-validated defineTool call. if (violations.length > 0) throw new ToolArgsError(violations) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index e50457bcb4..56a78a225f 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -7,7 +7,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import type { Config as ToolConfig, StructuredOutputSchema } from '@deepseek-ai/dsh-tools' +import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools' import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' @@ -27,7 +27,7 @@ interface SetupOptions { codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }> } -const SCHEMA: StructuredOutputSchema = { +const SCHEMA: ObjectJsonSchema = { type: 'object', properties: { answer: { type: 'number' }, note: { type: 'string' } }, required: ['answer'], @@ -320,17 +320,17 @@ describe('in-process structured output', () => { it('rejects a schema outside the subset loud, before any child exists', async () => { const { ctx, parent } = await setup([]) await expect(ctx.subagents.start('spawn', structuredRequest(parent, { - outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema, - }))).rejects.toThrow(/unsupported output schema/) + outputSchema: { type: 'object', oneOf: [] } as unknown as ObjectJsonSchema, + }))).rejects.toThrow(/unsupported JSON schema/) expect(ctx.agents.get(SessionId('parent'))).toBeDefined() }) - it('a schema carrying non-JSON values fails as OutputSchemaError at the validation boundary', async () => { + it('a schema carrying non-JSON values fails as JsonSchemaError at the validation boundary', async () => { const { ctx, parent } = await setup([]) // Semantic assertion runs before provider startup. await expect(ctx.subagents.start('spawn', structuredRequest(parent, { - outputSchema: { type: 'object', default: () => {} } as unknown as StructuredOutputSchema, - }))).rejects.toThrow(/unsupported output schema.*annotation must be JSON data/) + outputSchema: { type: 'object', default: () => {} } as unknown as ObjectJsonSchema, + }))).rejects.toThrow(/unsupported JSON schema.*annotation must be lossless JSON data/) }) it('a post-execute BLOCK on the capture call denies the capture: log and result agree on failure', async () => { @@ -547,7 +547,7 @@ describe('in-process structured output', () => { }) it('two concurrent structured children each see their OWN schema', async () => { - const otherSchema: StructuredOutputSchema = { + const otherSchema: ObjectJsonSchema = { type: 'object', properties: { verdict: { type: 'string', enum: ['real', 'bogus'] } }, required: ['verdict'], diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 00655f1f51..95e9300b0b 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -32,7 +32,7 @@ import { randomUUID } from 'node:crypto' import { Context, Service } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' -import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' +import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -242,7 +242,7 @@ export class SubagentService extends Service { } this.assertCapabilities(provider, request) assertSubagentMaxDepth(request.maxDepth) - if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema) + if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema) const parent = request.parent const run = await provider.start(request) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 1b1645d89b..7448f087d4 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -10,7 +10,7 @@ import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type { Branded } from '@deepseek-ai/dsh-brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' -import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools' +import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools' /** Identifies one accepted subagent run across its lifecycle event pair. */ export type SubagentRunId = Branded<'SubagentRunId'> @@ -70,11 +70,11 @@ export interface SubagentStartRequest { /** Per-child agent options (model and plugin-defined extension fields). */ readonly agentOptions?: AgentOptions /** - * Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects + * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects * unsupported schemas or providers without the capability. Data must be plain host-realm JSON; * a successful child returns the matching value as {@link SubagentResult.structured}. */ - readonly outputSchema?: StructuredOutputSchema + readonly outputSchema?: ObjectJsonSchema /** * Optional absolute delegation-depth cap for the child being started: its * computed depth must be less than or equal to this non-negative safe diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index 2d556cada9..3f8256be15 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -41,7 +41,7 @@ export function statusLine(snapshot: TaskSnapshot): string { : `[status: ${snapshot.status}]` } -/** Validate the non-empty constraint that SchemaSpec cannot express. */ +/** Validate the non-empty constraint that ParameterSchemaSpec cannot express. */ function validateTaskId(value: string): TaskId { if (value.length === 0) { throw new Error(`invalid task_id: expected a non-empty string, got ${JSON.stringify(value)}`) diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 039d2085f7..919e53f7bb 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -28,7 +28,7 @@ const DESCRIPTION = + '(not started), `in_progress` (being worked on now), `completed` (finished).' /** - * Validate the value constraints the SchemaSpec can't express and build the canonical {@link + * Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link * TodoItem}[]: trimmed non-empty unique content and at most one in-progress item. The registry * has already enforced the status enum; the cast below records that guarantee. */ @@ -67,6 +67,7 @@ export function apply(ctx: Context): void { description: 'The COMPLETE task list, replacing any previous list.', items: { type: 'object', + additionalProperties: true, properties: { content: { type: 'string', required: true, description: 'What the task is — a short imperative line.' }, status: { diff --git a/packages/ui/tool-ask-user/src/index.ts b/packages/ui/tool-ask-user/src/index.ts index 2591b28ddd..47cb6e8d22 100644 --- a/packages/ui/tool-ask-user/src/index.ts +++ b/packages/ui/tool-ask-user/src/index.ts @@ -27,6 +27,7 @@ export function apply(ctx: Context): void { description: 'Questions to ask the user before continuing.', items: { type: 'object', + additionalProperties: true, properties: { id: { type: 'string', required: true, description: 'Stable id for this question; echoed in the answer.' }, question: { type: 'string', required: true, description: 'The specific question to ask the user.' }, @@ -39,6 +40,7 @@ export function apply(ctx: Context): void { description: 'Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label.', items: { type: 'object', + additionalProperties: true, properties: { label: { type: 'string', required: true, description: 'Short user-facing option label.' }, description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' }, diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 2a8ef96682..71121730e5 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -131,6 +131,7 @@ export function apply(ctx: Context, config: Config): void { }, meta: { type: 'object', + additionalProperties: true, required: true, description: 'The workflow identity block (plain JSON — never code).', properties: { @@ -142,6 +143,7 @@ export function apply(ctx: Context, config: Config): void { description: 'Optional phase declarations matched by phase() calls.', items: { type: 'object', + additionalProperties: true, properties: { title: { type: 'string', required: true, description: 'The phase title phase() calls match by exact string.' }, detail: { type: 'string', description: 'Optional one-line description of the phase.' }, @@ -154,6 +156,7 @@ export function apply(ctx: Context, config: Config): void { }, args: { type: 'object', + additionalProperties: true, description: 'Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}).', }, }, diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 535917fc42..38194f4c03 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -15,8 +15,8 @@ import * as vm from 'node:vm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools' -import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' +import { assertObjectJsonSchema, JsonSchemaError } from '@deepseek-ai/dsh-tools' +import type { ObjectJsonSchema } from '@deepseek-ai/dsh-tools' import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow' import type { WorkflowAgentEndInfo, @@ -350,7 +350,7 @@ export class WorkflowExecution { phase?: string provider?: string model?: string - schema?: StructuredOutputSchema + schema?: ObjectJsonSchema } { if (rawOpts === undefined) return {} let opts: unknown @@ -377,14 +377,14 @@ export class WorkflowExecution { throw new WorkflowError(`agent() option "${key}" must be a string`, 'INVALID_ARGUMENT') } } - let schema: StructuredOutputSchema | undefined + let schema: ObjectJsonSchema | undefined if (record.schema !== undefined) { try { - assertSupportedOutputSchema(record.schema) + assertObjectJsonSchema(record.schema) schema = record.schema } catch (error: unknown) { - /* v8 ignore next -- defensive rethrow arm: assertSupportedOutputSchema only throws OutputSchemaError */ - if (!(error instanceof OutputSchemaError)) throw error + /* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */ + if (!(error instanceof JsonSchemaError)) throw error throw new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, 'UNSUPPORTED_SCHEMA', { cause: error }) } } diff --git a/packages/workflow/workflow-workerthread/src/types.ts b/packages/workflow/workflow-workerthread/src/types.ts index 7e2bfff36c..9d7ddf6808 100644 --- a/packages/workflow/workflow-workerthread/src/types.ts +++ b/packages/workflow/workflow-workerthread/src/types.ts @@ -6,7 +6,7 @@ */ import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' +import type { ObjectJsonSchema } from '@deepseek-ai/dsh-tools' import type { WorkflowMeta } from '@deepseek-ai/dsh-workflow' /** @@ -41,7 +41,7 @@ export interface ChildStartRequest { /** The child's prompt text. */ prompt: string /** The structured-output schema, if the call passed one (already subset-checked). */ - schema?: StructuredOutputSchema + schema?: ObjectJsonSchema /** The per-child provider override, if the call passed one. */ provider?: string /** The per-child model override, if the call passed one. */ diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5cb84266da..ed53ac469c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -91,8 +91,10 @@ { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ValueSchemaSpec", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ParameterPropertySpec", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ParameterSchemaSpec", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "InferValue", "source": "packages/core/tools/src/schema.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" }, @@ -104,10 +106,10 @@ { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredScalar", "source": "packages/core/tools/src/json-schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaType", "source": "packages/core/tools/src/json-schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaNode", "source": "packages/core/tools/src/json-schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredOutputSchema", "source": "packages/core/tools/src/json-schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "JsonSchemaScalar", "source": "packages/core/tools/src/json-schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "JsonSchemaType", "source": "packages/core/tools/src/json-schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "JsonSchemaNode", "source": "packages/core/tools/src/json-schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ObjectJsonSchema", "source": "packages/core/tools/src/json-schema.ts" }, { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionOption", "source": "packages/ui/user-interaction/src/index.ts" }, { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionItem", "source": "packages/ui/user-interaction/src/index.ts" }, From 66c36e7325b1a496557a20e9c71c75e01a113692 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:08:35 +0800 Subject: [PATCH 019/207] feat: add canonical typed tool outputs --- .../2026-06-17-filesystem-capability-seam.md | 2 +- ...26-07-02-result-time-applied-hunk-diffs.md | 16 +- ...026-07-06-tool-result-retention-library.md | 2 +- .../2026-07-07-tool-call-timeout-policy.md | 5 +- .../2026-07-08-tool-output-spill-files.md | 8 +- ...0-canonical-tool-output-contract.i18n.yaml | 6 + ...26-07-20-canonical-tool-output-contract.md | 76 +++ ...07-20-canonical-tool-output-contract.zh.md | 76 +++ .../2026-06-17-filesystem-tool-schemas.md | 2 +- .../feature/2026-06-30-interception-seams.md | 6 +- ...6-07-08-self-referential-cordis-toolset.md | 2 +- docs/architecture.md | 5 +- docs/config-catalog.md | 14 +- docs/cookbook/adding-a-tool.i18n.yaml | 4 +- docs/cookbook/adding-a-tool.md | 21 +- docs/cookbook/adding-a-tool.zh.md | 21 +- docs/cordis-catalog/events.md | 10 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session.md | 25 +- docs/core-data-structures/tools.md | 123 +++-- docs/event-producer-consumer.md | 10 +- docs/persistence-catalog.md | 35 +- docs/tool-catalog.md | 2 +- docs/user/develop/basic/index.i18n.yaml | 4 +- docs/user/develop/basic/index.md | 6 +- docs/user/develop/basic/index.zh.md | 6 +- docs/user/develop/basic/tool.i18n.yaml | 4 +- docs/user/develop/basic/tool.md | 57 ++- docs/user/develop/basic/tool.zh.md | 57 ++- docs/user/develop/practice/index.i18n.yaml | 4 +- docs/user/develop/practice/index.md | 6 +- docs/user/develop/practice/index.zh.md | 6 +- .../system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 2 +- .../snapshots/cancel-tool-calls/session.jsonl | 4 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../escalation-rejected/session.jsonl | 2 +- .../fs-escalation-approved/session.jsonl | 2 +- .../snapshots/fs-policy-reject/session.jsonl | 2 +- .../tests/snapshots/fs-write/session.jsonl | 2 +- .../hook-cc-posttool-block/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 2 +- .../hook-cc-pretool-deny/session.jsonl | 2 +- .../hook-codex-posttool-block/session.jsonl | 2 +- .../hook-codex-pretool-block/session.jsonl | 2 +- .../session.2.jsonl | 2 +- packages/bash/tool-bash/README.md | 2 + packages/bash/tool-bash/src/index.ts | 97 +++- packages/bash/tool-bash/tests/tools.spec.ts | 36 +- .../tests/compact-loop-repro.spec.ts | 4 +- .../tests/tool-result-prune.spec.ts | 4 +- .../time-context/tests/time-context.spec.ts | 4 +- .../context/workspace-context/src/index.ts | 3 +- .../tests/workspace-context.spec.ts | 38 +- packages/cordis/tool-cordis/README.md | 2 + .../cordis/tool-cordis/src/api-catalog.ts | 28 +- .../cordis/tool-cordis/src/fiber-state.ts | 4 +- packages/cordis/tool-cordis/src/guard.ts | 84 ++-- packages/cordis/tool-cordis/src/index.ts | 67 ++- packages/cordis/tool-cordis/src/inspect.ts | 11 +- .../tool-cordis/tests/cross-mount.spec.ts | 3 +- packages/cordis/tool-cordis/tests/helpers.ts | 28 +- .../cordis/tool-cordis/tests/inspect.spec.ts | 2 + .../cordis/tool-cordis/tests/mount.spec.ts | 95 +++- .../tool-cordis/tests/sandbox-context.spec.ts | 8 +- .../tool-cordis/tests/unmount-hmr.spec.ts | 2 + packages/core/agent-loop/src/tool-calls.ts | 5 +- .../agent-loop/tests/agent-initiator.spec.ts | 10 +- packages/core/agent-loop/tests/cancel.spec.ts | 6 +- .../tests/contract-regressions.spec.ts | 30 +- .../agent-loop/tests/coverage-edges.spec.ts | 10 +- .../agent-loop/tests/interception.spec.ts | 16 +- packages/core/agent-loop/tests/loop.spec.ts | 48 +- .../agent-loop/tests/request-cache.e2e.ts | 4 +- .../tests/request-reconstruction.spec.ts | 4 +- .../agent-loop/tests/request-recovery.spec.ts | 8 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 10 +- .../core/agent-loop/tests/tool-calls.spec.ts | 46 +- .../core/agent-loop/tests/tool-order.spec.ts | 4 +- .../core/agent-loop/tests/turn-stop.spec.ts | 4 +- packages/core/session/README.md | 2 + packages/core/session/src/repair.ts | 5 +- packages/core/session/src/types.ts | 25 +- packages/core/session/tests/repair.spec.ts | 2 +- packages/core/tools/README.md | 23 +- packages/core/tools/src/code-mode.ts | 38 +- packages/core/tools/src/index.ts | 252 +++++++--- packages/core/tools/src/schema.ts | 85 ++-- packages/core/tools/src/testing.ts | 42 ++ packages/core/tools/tests/code-mode.spec.ts | 28 +- .../core/tools/tests/execution-mode.spec.ts | 23 +- packages/core/tools/tests/scoped.spec.ts | 23 +- packages/core/tools/tests/tools.spec.ts | 441 ++++++++++++++++-- .../examples/acp-demo/tests/acp-agent.spec.ts | 3 +- .../agent-spine-demo/tests/agent-core.spec.ts | 3 +- .../examples/cli-demo/tests/cli-demo.spec.ts | 8 +- packages/examples/cli-demo/tests/cli.spec.ts | 6 +- packages/fs/tool-fs-search/README.md | 4 +- packages/fs/tool-fs-search/src/glob.ts | 54 ++- packages/fs/tool-fs-search/src/grep.ts | 91 +++- packages/fs/tool-fs-search/src/surface.ts | 27 ++ .../tool-fs-search/tests/integration.spec.ts | 10 +- .../fs/tool-fs-search/tests/tools.spec.ts | 147 +++++- packages/fs/tool-fs/README.md | 2 + packages/fs/tool-fs/src/edit.ts | 31 +- packages/fs/tool-fs/src/read-render.ts | 2 +- packages/fs/tool-fs/src/read.ts | 47 +- packages/fs/tool-fs/src/write.ts | 41 +- packages/fs/tool-fs/tests/integration.spec.ts | 28 +- packages/fs/tool-fs/tests/tools.spec.ts | 38 +- packages/goal/tool-goal/README.md | 2 + packages/goal/tool-goal/src/index.ts | 93 +++- .../goal/tool-goal/tests/tool-goal.spec.ts | 47 +- packages/guard/repeat-tool-guard/src/index.ts | 3 +- .../tests/repeat-tool-guard.spec.ts | 10 +- packages/hooks/hooks-claude/src/index.ts | 3 +- .../hooks/hooks-claude/tests/bridge.spec.ts | 12 +- .../hooks-claude/tests/coverage-cases.ts | 48 +- packages/hooks/hooks-codex/src/index.ts | 3 +- .../hooks/hooks-codex/tests/bridge.spec.ts | 4 +- .../hooks/hooks-codex/tests/coverage-cases.ts | 52 +-- packages/llm/llm-retry/tests/retry.spec.ts | 4 +- packages/mcp/mcp-client/README.md | 10 +- packages/mcp/mcp-client/src/index.ts | 2 + packages/mcp/mcp-client/src/tools.ts | 69 ++- .../mcp/mcp-client/tests/mcp-client.spec.ts | 134 +++++- .../session-persistence/tests/contract.ts | 2 +- packages/skill/tool-skill/README.md | 2 +- packages/skill/tool-skill/src/index.ts | 55 ++- .../skill/tool-skill/tests/tool-skill.spec.ts | 16 +- packages/spill/spill-policy/README.md | 8 +- packages/spill/spill-policy/src/index.ts | 14 +- .../spill-policy/tests/spill-policy.spec.ts | 38 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent-inprocess/src/structured.ts | 15 +- .../tests/structured.spec.ts | 35 +- .../tests/subagent-spawn.spec.ts | 5 +- packages/subagent/tool-subagent/README.md | 4 +- packages/subagent/tool-subagent/src/index.ts | 52 ++- .../tool-subagent/tests/tool-subagent.spec.ts | 8 + packages/support/invariants/src/index.ts | 2 +- .../invariants/tests/invariants.spec.ts | 6 +- packages/tasks/tool-tasks/README.md | 2 + packages/tasks/tool-tasks/src/index.ts | 110 ++++- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 40 +- packages/timeout/timeout-policy/README.md | 2 +- packages/timeout/timeout-policy/src/index.ts | 5 +- .../tests/timeout-policy.spec.ts | 36 +- packages/todo/tool-todo/README.md | 2 +- packages/todo/tool-todo/src/index.ts | 49 +- .../todo/tool-todo/tests/tool-todo.spec.ts | 5 + packages/ui/acp/src/index.ts | 3 +- packages/ui/acp/tests/stream-update.spec.ts | 17 +- packages/ui/acp/tests/turns.spec.ts | 8 +- packages/ui/tool-ask-user/README.md | 4 +- packages/ui/tool-ask-user/src/index.ts | 30 +- .../tool-ask-user/tests/tool-ask-user.spec.ts | 12 +- packages/ui/tui/src/index.ts | 4 +- packages/ui/tui/tests/tui.snapshot.ts | 5 +- packages/ui/tui/tests/tui.spec.ts | 25 +- packages/web/tool-web/README.md | 2 + packages/web/tool-web/src/fetch.ts | 43 +- packages/web/tool-web/src/search.ts | 39 +- .../web/tool-web/tests/integration.spec.ts | 17 +- packages/web/tool-web/tests/tool-web.spec.ts | 33 +- packages/workflow/tool-ralph/README.md | 2 +- packages/workflow/tool-ralph/src/index.ts | 27 +- .../tool-ralph/tests/tool-ralph.spec.ts | 8 +- packages/workflow/tool-workflow/README.md | 4 +- packages/workflow/tool-workflow/src/index.ts | 30 +- .../tool-workflow/tests/tool-workflow.spec.ts | 11 +- scripts/type-equiv.manifest.json | 4 + 173 files changed, 3298 insertions(+), 954 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md create mode 100644 .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md create mode 100644 packages/core/tools/src/testing.ts create mode 100644 packages/fs/tool-fs-search/src/surface.ts diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md index e76ca22e2d..7fa2bde08c 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -122,7 +122,7 @@ The root plugin registers the full suite by composing the per-tool registration ## Testing -Tests follow the package boundary, not only the user-visible tools: the service seam in `dsh-fs`; real filesystem behavior through the `ctx.fs` interface in `dsh-fs-local` (resolution, symlinks, streaming, binary/UTF-8 rejection, unconditional and version-guarded writes, literal-edit semantics, line-ending preservation, structured `FsError` codes); the consumer surface in `dsh-tool-fs` against the real local provider (mock only the model/clock, never the collaborator); and integration through `ctx.tools.execute()` with and without `dsh-fs-policy`, world-verified by reading files back from disk rather than trusting the returned `ContentBlock[]`. The observed-state/owner-derivation policy is tested in `dsh-fs-policy`, not here. +Tests follow the package boundary, not only the user-visible tools: the service seam in `dsh-fs`; real filesystem behavior through the `ctx.fs` interface in `dsh-fs-local` (resolution, symlinks, streaming, binary/UTF-8 rejection, unconditional and version-guarded writes, literal-edit semantics, line-ending preservation, structured `FsError` codes); the consumer surface in `dsh-tool-fs` against the real local provider (mock only the model/clock, never the collaborator); and integration through `ctx.tools.execute()` with and without `dsh-fs-policy`, world-verified by reading files back from disk rather than trusting either the canonical value or rendered content. The observed-state/owner-derivation policy is tested in `dsh-fs-policy`, not here. The defensive-pattern classes this repo has been bitten by are pinned directly: diff --git a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md index 8ddd1e8941..ce0f146b39 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md +++ b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md @@ -14,24 +14,20 @@ The obstacle is a seam boundary: `presentResult(args, result)` is a **pure funct Add a **persisted, tool-private presentation channel** so a tool's `execute` can attach a result-time render payload that survives replay, and use it to carry the applied-hunk diff. -### 1. A `meta` channel on the tool result (core) +### 1. A replayable presentation projection on canonical tool output (core) -`ToolDefinition.execute` may now return either its model-facing `ContentBlock[]` (unchanged, the common case) OR `{ content: ContentBlock[]; meta?: unknown }`: +The original implementation let `execute` return `{ content, meta }`. The [canonical tool-output contract](2026-07-20-canonical-tool-output-contract.md) supersedes that authoring shape: every tool now returns one schema-declared JSON value, `output.render(args, value)` derives model-facing blocks, and optional `output.presentationMeta(args, value)` derives replayable UI data. -```ts ignore-check -type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } -``` +`presentationMeta` is tool-owned `JsonValue` that the core persists without interpreting its fields. `Session.append` validates it with the rest of the event, and replay passes the stored payload back to `presentResult`; presentations therefore reproduce without I/O or recomputation. The canonical value itself remains execution-local and is not added to the session format. -`meta` is tool-owned `unknown` that the core persists without interpretation. `Session.append` rejects non-JSON values, and replay passes the stored payload back to `presentResult`; presentations therefore reproduce without I/O or recomputation. Runtime validation avoids adding a shared serializable-value dependency to the tools core. - -This is the general shape ("a tool attaches durable result presentation"), not an fs-specific one — any tool can use it. +This remains the general shape ("a tool projects durable result presentation"), not an fs-specific one—any tool can use it. ### 2. The tool computes the hunk; the backend returns before/after (fs) Per the [capability-seam split](2026-06-13-capability-seams.md), the storage backend returns only **storage facts** and the model-facing tool owns **presentation**: - `dsh-fs` widens `FsEditOutcome` with `{ before: string; after: string }` and `FsWriteOutcome` with `{ before: string | null; after: string }` (`before: null` ⇒ a create, or an existing-but-undiffable binary/non-UTF-8 file). The local backend already holds both texts at write time; it returns them as raw LF-normalized text, with **no diff/UI concept** entering the seam. -- `dsh-tool-fs` stores contextual hunks in `meta: { diffs: FileDiff[] }`. Successful mutations always complete with a diff card because ACP result content replaces the pending card: creates or unchanged overwrites fall back to an args-derived whole-file diff, while edits use applied hunks. Failed mutations carry no diff metadata and render their error normally. +- `dsh-tool-fs` returns canonical before/after mutation facts and projects contextual hunks as `meta: { diffs: FileDiff[] }`. Successful mutations always complete with a diff card because ACP result content replaces the pending card: creates or unchanged overwrites fall back to an args-derived whole-file diff, while edits use applied hunks. Failed mutations carry no diff metadata and render their error normally. ### 3. The bridge renders a `diff` result card @@ -43,7 +39,7 @@ Per the [capability-seam split](2026-06-13-capability-seams.md), the storage bac ## Consequences -`tool/result` events may now carry a tool-private `meta` payload — part of the on-disk vocabulary, runtime-gated to JSON by `Session.append` — and any tool can attach durable result presentation without another core change. The diff card reproduces on session reload and snapshot replay for free: it is read back from the log, never recomputed. The costs: an overwrite holds both the prior and new text in memory to compute a UI-only hunk (`TODO(overwrite-diff-bound)`), and `dsh-tool-fs` carries a small, well-known runtime dependency. +`tool/result` events carry a tool-private `meta` payload—part of the on-disk vocabulary, runtime-gated to JSON by `Session.append`—and any tool can project durable result presentation without another core change. The diff card reproduces on session reload and snapshot replay for free: it is read back from the log, never recomputed. The costs: an overwrite holds both the prior and new text in memory to compute a UI-only hunk (`TODO(overwrite-diff-bound)`), and `dsh-tool-fs` carries a small, well-known runtime dependency. ## Non-goals diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md index f90e653a7c..2962f1006e 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md @@ -150,6 +150,6 @@ The formatter hook is deliberately small: a tool turns a `RetentionNotice` into **Put `read` windowing behind `ItemRetainer`.** Rejected for v1: `read` is the only current window consumer, and its semantics are file pagination rather than generic retention. A single `Omitted` count cannot represent both sides of a line window, and `read` also carries `totalLines`, offset-range errors, per-line preview truncation, and a byte cap over selected output. Keeping `read-render` tool-owned avoids growing the shared library around one special case. -**Make truncation part of `ToolExecutionResult`.** Rejected: the tool registry would have to understand tool-specific recovery guidance, grouping, line numbering, exit status, and provider semantics. Retention is a library used before a tool returns `ContentBlock[]`; the model-facing result remains tool-owned. +**Make truncation part of `ToolExecutionResult`.** Rejected: the tool registry would have to understand tool-specific recovery guidance, grouping, line numbering, exit status, and provider semantics. Retention is a library used by a tool's Native renderer; the model-facing projection remains tool-owned while the [canonical value](2026-07-20-canonical-tool-output-contract.md) may retain the complete acquired result. **Expose limits in every model-facing tool schema.** Rejected as the default: Claude Code's grep exposes `head_limit` / `offset`, but this harness keeps routine budgets as deployment config unless the model genuinely needs pagination control. A future read-like continuation field can be added per tool; it does not belong in the shared retention primitive. diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md index 040a6c2fdc..df0608cb34 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md @@ -61,7 +61,10 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { return { content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }], isError: true, - error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, + error: { + message: `tool call timed out after ${timeoutMs}ms`, + info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, + }, } } ``` diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index a9197de179..e2df9a902b 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -8,7 +8,7 @@ Tool outputs need bounded model-facing previews, but some oversized results are Before this change the behavior was uneven. `dsh-bash-local` already writes complete stdout/stderr streams to private temp spill files when its in-memory tail overflows, but ordinary text tool results were returned inline unless the tool hand-rolled its own cap. The [tool result retention library](2026-07-06-tool-result-retention-library.md) owns preview mechanics, but it does not own storage or an execution-pipeline policy that applies those mechanics to final tool results. -The shape matches the timeout policy design: a tool author normally returns the text result, and a policy plugin enforces the deployment's default context budget. Tool-specific early spill remains possible later for outputs that do not survive to the final `ToolExecutionResult`; the first cut proves the default final-result path. +The shape matches the timeout policy design: a tool author declares a canonical value plus Native renderer, and a policy plugin enforces the deployment's default context budget on rendered content. Tool-specific early spill remains possible for provider acquisition bounds; tool-owned surface spill may retain a complete acquired canonical value while replacing only presentation. The [canonical tool-output contract](2026-07-20-canonical-tool-output-contract.md) owns that split. ## Decision @@ -97,9 +97,13 @@ The policy skips `read` to avoid a circular `read -> spill file -> read again` l ```ts ignore-check ctx.tools.register(defineTool({ name: 'web_fetch', + output: { + schema: WEB_FETCH_RESULT_SCHEMA, + render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value) }], + }, async execute(args, exec) { const result = await ctx.web.fetch({ url: args.url }, exec.signal ? { signal: exec.signal } : undefined) - return [{ type: 'text', text: formatFetchOutput(result) }] + return result }, })) ``` diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml new file mode 100644 index 0000000000..d76de6a74e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-20-canonical-tool-output-contract.md: 226ca3274e08e2d46d29075ee412d4945fda753a +2026-07-20-canonical-tool-output-contract.zh.md: c5c5e46e267dd3d0795df7fb6761e867e52b5b2b diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md new file mode 100644 index 0000000000..226ca3274e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md @@ -0,0 +1,76 @@ +# Agent Note: Canonical tool output contract + +Status: implemented + +English | [中文](2026-07-20-canonical-tool-output-contract.zh.md) + +## Problem + +Tool bodies previously authored model-facing `ContentBlock[]` directly, optionally wrapping it with opaque `meta`. Native function calling therefore had a usable human projection, but a programmatic caller had no stable domain value: Code Mode flattened the blocks back into a string, dynamic tools repeated the content shape, and policy could replace presentation without any way to distinguish that change from replacing the operation's result. Several capability seams already returned richer provider values only to discard them at their model-facing tool boundary. + +The durable session contract made that presentation authoritative for replay, but persisting every rich intermediate value would enlarge logs, expose implementation data to compaction and migration, and incorrectly turn an execution-local API into session format. The foundation instead needs one typed value during execution and an explicit projection into the existing durable/model-facing content. + +## Decision + +Every tool declares a mandatory canonical output and returns only the value described by it: + +```ts ignore-check +output: { + schema: OutputSchema + render(args, value): ContentBlock[] + presentationMeta?(args, value): JsonValue +} +``` + +`defineTool` infers the body return and both projectors from the unified `ValueSchemaSpec`. Raw and dynamic definitions provide the compiled `JsonSchemaNode` form. Registration rejects a missing declaration or unsupported raw schema; there is no content-return compatibility path. + +For each successful dispatch the registry snapshots the returned value as lossless `JsonValue`, validates it against `output.schema`, deep-freezes it, then invokes the pure renderer and, for a direct surface call, the optional metadata projector. Renderer, projector, schema, or lossless-JSON failures are contained as ordinary `ToolOutputError` results. An around `tools/execute` wrapper receives and returns the canonical success/failure union; a wrapper-authored success is normalized again through the resolved tool's output declaration instead of trusting independently authored content. + +```ts ignore-check +type ToolExecutionResult = + | { isError: false; value: JsonValue; content: ContentBlock[]; meta?: JsonValue; additionalContexts?: HookContext[] } + | { isError: true; error: { message: string; info?: { name: string; code: string } }; content: ContentBlock[]; meta?: JsonValue; additionalContexts?: HookContext[] } +``` + +`tools/post-execute` has two mutually exclusive successful projections. Replacing `content` changes only Native/model presentation and preserves the canonical value and metadata. Replacing `value` revalidates the replacement and recomputes both presentation projections. A block removes the value and becomes a failure. Content replacement is therefore not a confidentiality mechanism: policy that must prevent programmatic access blocks the call or replaces the value. + +Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and `meta`; Code Mode's `tool/code-dispatch` persists only its bounded summary. Neither event stores the intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. `presentationMeta` is computed only for a direct surface call, including the outer `run_code`; a nested Code dispatch gets no metadata or result card. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context. + +The first-party tools preserve their existing Native text while returning domain DTOs: + +| Tool family | Canonical value | +|---|---| +| `read` | `{ path, offset, lines: [{ number, text }], totalLines }` | +| `write` | `{ path, operation: "create" | "update", before: string | null, after }` | +| `edit` | `{ path, before, after }` | +| `glob` | `{ paths: string[] }` | +| `grep` | `{ matches: [{ path, lineNumber, line }] }` | +| `web_search` / `web_fetch` | The normalized `WebSearchResult` / `WebFetchResult` | +| `bash` | `{ kind: "background", taskId }` or `{ kind: "foreground" } & BashRunResult` | +| `task_output` / `task_list` / `task_kill` | Public task snapshots without owner or notification bookkeeping | +| `subagent` | Background task handle or `{ kind: "foreground", runId, output: JsonValue[] }` | +| `workflow` / `ralph` | `{ runId, agentsStarted, result: JsonValue }` | +| `skill` | `{ name, provider, resourceBase?, content }` | +| `todo_write` | `{ todos, counts }` | +| `ask_user_question` | `{ answers: [{ id, selected, custom? }] }` | +| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | Inspection text or typed dynamic-mount handles | +| `structured_output` | `{ recorded: true }` | +| `run_code` | `{ logs: string[], result?: JsonValue }` | + +Provider and executor acquisition limits remain real limits on the canonical value. Formatting-only limits belong in `render`; `glob` and `grep`, for example, keep every acquired item in `value` while their Native projection retains and best-effort spills the configured first page. Filesystem mutations derive replayable diff metadata from `args` and the canonical before/after value rather than returning UI state from the body. + +MCP bridges preserve protocol blocks through `McpResult<{...}> = { content: JsonValue[]; structuredContent? }`. An advertised `outputSchema` is enforced when it belongs to the supported raw subset; unsupported schemas fall back to `JsonValue` rather than pretending to validate them. Native rendering still uses the existing MCP-to-`ContentBlock` projection, and MCP `isError` becomes a failed tool result. + +## Alternatives considered + +- **Return rendered text to Code Mode:** rejected because callers would continue scraping prose for task ids, mount ids, paths, and structured provider results. +- **Persist canonical values on `tool/result`:** rejected because nested execution values are not model history, need not survive replay, and would create a session-format and storage commitment unrelated to Native reconstruction. +- **Let tools return both value and content:** rejected because two author-owned results can disagree and policy cannot state which one is authoritative. The renderer makes presentation a deterministic projection of the validated value. +- **Treat content replacement as value redaction:** rejected because presentation and programmatic access are different consumers; hiding only the former would create a false security boundary. +- **Require object-rooted tool outputs:** rejected because scalar, array, and null results are legitimate JSON APIs. Object-rooting remains a consumer rule for caller-defined subagent/workflow structured output. + +## Consequences + +Native and replay behavior remains content-first and byte-compatible, while execution-time callers can use a validated domain value without parsing that content. Failures have one required message plus optional internal class/code information, successful and failed outcomes are discriminated, and a failed result can never promise a value. Tool authors must design the value and Native projection together; the extra declaration is intentional because it prevents accidental programmatic contracts from being inferred from prose. + +Intermediate values remain bounded only by the producing capability and process memory. Their omission from the log means replay cannot recover them, and a content-only post policy does not hide them. These are explicit properties of the execution-local contract, not accidental gaps. diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md new file mode 100644 index 0000000000..c5c5e46e26 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md @@ -0,0 +1,76 @@ +# Agent Note:规范工具输出契约 + +Status: implemented + +[English](2026-07-20-canonical-tool-output-contract.md) | 中文 + +## 问题 + +工具主体过去直接编写面向模型的 `ContentBlock[]`,并可选择将其与不透明的 `meta` 包装在一起。因此,Native 模式的 Function Calling(函数调用)虽然拥有可供人阅读的投影,但程序化调用方没有稳定的领域值:Code Mode 会将内容块重新展平为字符串,动态工具会重复定义内容形态,策略也可以替换展示内容,却无法区分这项变更究竟是替换展示,还是替换操作结果。多个能力 seam 已经返回了信息更丰富的提供方值,却又在面向模型的工具边界丢弃这些值。 + +持久会话契约将这份展示内容视为回放时的权威来源,但如果持久化每一个信息丰富的中间值,就会扩大日志、使实现数据进入压缩和迁移流程,还会错误地把执行期本地 API 变成会话格式的一部分。因此,系统底层需要在执行期间保留一个类型化值,并显式将其投影为现有的持久化内容和模型可见内容。 + +## 决策 + +每个工具都必须声明规范输出,并且只能返回该声明描述的值: + +```ts ignore-check +output: { + schema: OutputSchema + render(args, value): ContentBlock[] + presentationMeta?(args, value): JsonValue +} +``` + +`defineTool` 从统一的 `ValueSchemaSpec` 推导工具主体返回值和两个投影器的类型。原始定义和动态定义则提供编译后的 `JsonSchemaNode` 形式。注册时会拒绝缺失输出声明或采用不受支持原始 schema 的定义,不提供兼容旧式内容返回值的路径。 + +每次成功分发时,注册表会将返回值快照为无损 `JsonValue`,依据 `output.schema` 校验并深度冻结,然后调用纯渲染器;对于直接的外层调用,还会调用可选的元数据投影器。渲染器、投影器、schema 或无损 JSON 处理失败都会被收敛为普通 `ToolOutputError` 结果。around `tools/execute` 包装层接收并返回规范的成功/失败联合;包装层自行产生的成功结果会再次通过已解析工具的输出声明完成归一化,而不会信任其独立编写的内容。 + +```ts ignore-check +type ToolExecutionResult = + | { isError: false; value: JsonValue; content: ContentBlock[]; meta?: JsonValue; additionalContexts?: HookContext[] } + | { isError: true; error: { message: string; info?: { name: string; code: string } }; content: ContentBlock[]; meta?: JsonValue; additionalContexts?: HookContext[] } +``` + +`tools/post-execute` 为成功结果提供两种互斥的投影方式。替换 `content` 只改变 Native/模型展示,并保留规范值和元数据。替换 `value` 会重新校验替代值,并重新计算两份展示投影。阻止操作会移除值并转为失败。因此,替换内容并不是保密机制:必须阻止程序化访问的策略,应当阻止调用或替换值。 + +规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和 `meta`;Code Mode 的 `tool/code-dispatch` 只持久化其有界摘要。两个事件都不存储中间值,因此回放可以重现展示,却无法重建程序化结果。系统只会为直接的外层调用计算 `presentationMeta`,其中包括外层 `run_code`;嵌套 Code 分发没有元数据或结果卡片。通用以及工具自有的输出落盘投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。 + +第一方工具在保持现有 Native 文本不变的同时返回领域 DTO: + +| 工具系列 | 规范值 | +|---|---| +| `read` | `{ path, offset, lines: [{ number, text }], totalLines }` | +| `write` | `{ path, operation: "create" | "update", before: string | null, after }` | +| `edit` | `{ path, before, after }` | +| `glob` | `{ paths: string[] }` | +| `grep` | `{ matches: [{ path, lineNumber, line }] }` | +| `web_search` / `web_fetch` | 归一化后的 `WebSearchResult` / `WebFetchResult` | +| `bash` | `{ kind: "background", taskId }` 或 `{ kind: "foreground" } & BashRunResult` | +| `task_output` / `task_list` / `task_kill` | 不含所有者或通知账务字段的公开任务快照 | +| `subagent` | 后台任务句柄或 `{ kind: "foreground", runId, output: JsonValue[] }` | +| `workflow` / `ralph` | `{ runId, agentsStarted, result: JsonValue }` | +| `skill` | `{ name, provider, resourceBase?, content }` | +| `todo_write` | `{ todos, counts }` | +| `ask_user_question` | `{ answers: [{ id, selected, custom? }] }` | +| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | 检查文本或类型化的动态挂载句柄 | +| `structured_output` | `{ recorded: true }` | +| `run_code` | `{ logs: string[], result?: JsonValue }` | + +提供方和执行器的采集上限仍会实际限制规范值。仅用于格式化的限制归 `render` 所有;例如,`glob` 和 `grep` 会在 `value` 中保留所有已采集项,而其 Native 投影仍只保留配置指定的第一页,并尽力将完整展示内容写入落盘文件。文件系统变更工具根据 `args` 和规范的变更前/后值推导可回放的 diff 元数据,不再由工具主体返回 UI 状态。 + +MCP 桥接层通过 `McpResult<{...}> = { content: JsonValue[]; structuredContent? }` 保留协议内容块。当公布的 `outputSchema` 属于受支持的原始子集时,系统会强制校验;不受支持的 schema 则回退为 `JsonValue`,而不会假装已完成校验。Native 渲染仍使用现有的 MCP 到 `ContentBlock` 投影,MCP `isError` 则会变为失败的工具结果。 + +## 备选方案 + +- **向 Code Mode 返回渲染后的文本:**不予采纳。调用方仍需从自然语言中提取 task id、挂载 id、路径和结构化提供方结果。 +- **在 `tool/result` 上持久化规范值:**不予采纳。嵌套执行值不属于模型历史记录,无需在回放后继续存在;持久化还会引入与 Native 重建无关的会话格式和存储承诺。 +- **允许工具同时返回值和内容:**不予采纳。由作者分别维护的两份结果可能互相矛盾,策略也无法说明哪一份才是权威结果。渲染器会根据已校验值确定性地产生展示。 +- **将内容替换视为值脱敏:**不予采纳。展示内容和程序化访问面向不同消费方;只隐藏前者会制造虚假的安全边界。 +- **要求工具输出必须以对象为根:**不予采纳。标量、数组和 null 结果都是合理的 JSON API。只有由调用方定义的 subagent/工作流结构化输出仍受消费方的对象根规则约束。 + +## 影响 + +Native 和回放行为仍以内容为先,并保持逐字节兼容;执行期调用方则无需解析内容,即可使用经过校验的领域值。失败结果必须包含消息,并可选择附加内部类名/代码信息;成功与失败结果由判别字段区分,失败结果绝不会承诺存在值。工具作者必须一并设计值及其 Native 投影;增加这项声明是有意为之,因为它避免从自然语言内容意外推导出程序化契约。 + +中间值只受产生它们的能力和进程内存限制。日志不包含这些值,因此回放无法恢复;仅处理内容的 post 策略也无法隐藏这些值。这些都是执行期本地契约的明确属性,并非意外缺口。 diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md index a318956ddf..59bf9be768 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -68,7 +68,7 @@ The first pass rejects Codex-style patch grammars and multi-mode edit APIs. It u ## Result shape -The first implementation returns `ContentBlock[]` through the existing `ToolDefinition.execute()` contract. `ctx.fs` returns structured filesystem results and owns file-state recording/refreshing; `tool-fs` formats those results into the model projection. +The first implementation formatted `ContentBlock[]` in `execute`. The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) now keeps `ctx.fs` result facts as the tool's validated value and derives the same model text through `output.render`; file-state recording/refreshing remains on `ctx.fs`. Default native projections: diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md index 2535c1c564..82102efa99 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md @@ -24,11 +24,11 @@ Every call follows `tools/pre-execute` → guards → `tools/execute` → dispat - **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks. Deny skips `tools/execute` and core dispatch. Ask resolves through the optional approval seam: only `allowed-once` continues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every outcome still reaches post-policy and final observers. - **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids. -- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch. -- **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContexts`. The returned decision is the supported transform channel; after the waterfall, the registry materializes the complete outcome once before final observation. +- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized canonical success/failure result of a thrown or unknown tool; a wrapper-authored success is re-normalized through the resolved output declaration. +- **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, replaces either presentation content or canonical value, or attaches `additionalContexts`. Value replacement revalidates and recomputes presentation; content replacement preserves programmatic value and is not a confidentiality boundary. The returned decision is the supported transform channel; after the waterfall, the registry materializes the complete outcome once before final observation. - **`tools/result`** is the synchronous contained notification after every transform, lossless-JSON materialization, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome. -Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees exactly what the caller receives and the session log can persist. +Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, invalid canonical value, renderer/projector, non-JSON presentation, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees the execution-local canonical value beside exactly the presentation fields the session log can persist. The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/projection and durability rules. **`TurnEndReason.rejected`** (`dsh-session`): a zero-step turn whose claimed prompt was blocked by `prompt-submit`. diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 2038b2b719..429102aeee 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -30,7 +30,7 @@ Mount code runs as an async-function body in a fresh vm realm. Its documented su Sandbox globals are deliberately small: a tagged write-through `console` (`[cordis:] …` on the host stdout/stderr, so a listener that fires long after the mount call still lands somewhere the user sees), the `harness.defineTool` / `harness.registerTool` registration pair, the encoding primitives fresh vm contexts lack (`btoa`/`atob` as host closures over `Buffer` — a sanctioned exception, `Buffer` itself is never exposed — plus `TextEncoder`/`TextDecoder`), and callable traps over the withheld Node APIs (`require`, `setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval`, `fetch`) that throw a redirect naming the cordis alternative. Only function-shaped globals are trapped; `process` and `Buffer` stay `undefined` so a `typeof` feature probe stays inert rather than detonating a throwing accessor. -Mount code crosses the vm boundary through three controls. Dual-realm `instanceof` recognizes both host and vm objects. `harness.defineTool` normalizes results into host-realm JSON and validates the `ToolExecuteReturn` shape before logging. The mounted plugin receives a whitelist context façade, not a raw or pass-through `Context`; framework plumbing and context-valued returns are rejected. Service reads require a declared `inject`, preserving Cordis activation and unload semantics. `ctx.tools.get` exposes only the schema view, so mounted code cannot bypass `ToolRegistry.execute` by calling a definition directly. +Mount code crosses the vm boundary through three controls. Dual-realm `instanceof` recognizes both host and vm objects. `harness.defineTool` rebuilds the output schema/projectors in the host realm, snapshots the body value as host-owned JSON, and lets the registry enforce the [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) before observation. The mounted plugin receives a whitelist context façade, not a raw or pass-through `Context`; framework plumbing and context-valued returns are rejected. Service reads require a declared `inject`, preserving Cordis activation and unload semantics. `ctx.tools.get` exposes only the schema view, so mounted code cannot bypass `ToolRegistry.execute` by calling a definition directly. The boundary normalizes unambiguous JSON-Schema forms into `ParameterSchemaSpec`, preserving `integer`, raw object openness, and required arrays. Direct DSL object nodes must declare `additionalProperties`; invalid vocabulary fails with the accepted alternatives. Parse, TypeScript, missing-return, Node-API, and duplicate-tool errors include the relevant source line or corrective contract without narrating implementation internals. diff --git a/docs/architecture.md b/docs/architecture.md index 0c3e55f269..f51059e63d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -97,7 +97,8 @@ forever: exclusive -> one-call barrier parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute - each model-order result -> ordered tools/post-execute -> 'tool/result' + body value -> validate/snapshot -> Native/meta projection + each model-order result -> ordered tools/post-execute -> projected 'tool/result' append accepted tool-batch context after all recorded results, then steering agent/post-step 'step/end' @@ -110,7 +111,7 @@ forever: Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles after results. Steering drains; before signal closure, `agent/post-step` observes durable output, results, context, and steering. Leftovers queue. Terminal `agent/turn-stop` runs after continuation and steering folding, remains authoritative through close/flush, and discards later steering while preserving queued prompts. +Tool success separates an execution-local canonical JSON value from Native projections; post-policy replaces one projection or blocks, and the loop persists only projections ([contract](../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md)). Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles after results. Steering drains; before signal closure, `agent/post-step` observes durable output, results, context, and steering. Leftovers queue. Terminal `agent/turn-stop` runs after continuation and steering folding, remains authoritative through close/flush, and discards later steering while preserving queued prompts. Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 44e27bb572..47a9926575 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:247`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:248`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -666,7 +666,7 @@ export interface StreamableHttpConfig { } ``` -Source: [`packages/mcp/mcp-client/src/index.ts:91`](../packages/mcp/mcp-client/src/index.ts) +Source: [`packages/mcp/mcp-client/src/index.ts:93`](../packages/mcp/mcp-client/src/index.ts) ## `@deepseek-ai/dsh-permission` @@ -923,7 +923,7 @@ export interface Config { } ``` -Source: [`packages/spill/spill-policy/src/index.ts:45`](../packages/spill/spill-policy/src/index.ts) +Source: [`packages/spill/spill-policy/src/index.ts:50`](../packages/spill/spill-policy/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -1157,7 +1157,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-ralph/src/index.ts:22`](../packages/workflow/tool-ralph/src/index.ts) +Source: [`packages/workflow/tool-ralph/src/index.ts:23`](../packages/workflow/tool-ralph/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` @@ -1227,7 +1227,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:23`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:24`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-tasks` @@ -1281,7 +1281,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-workflow/src/index.ts:26`](../packages/workflow/tool-workflow/src/index.ts) +Source: [`packages/workflow/tool-workflow/src/index.ts:27`](../packages/workflow/tool-workflow/src/index.ts) ## `@deepseek-ai/dsh-tools` @@ -1303,7 +1303,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:397`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:448`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 288ee96446..eb536bb063 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -adding-a-tool.md: 94cb4fcfa9a0155fd57ef18f9a855a264c2eff84 -adding-a-tool.zh.md: 637dc3381765e57c2d0420164ac13fde7fb590fe +adding-a-tool.md: f94ddfaa9df53c0ee4596d683e676baae6bd85b2 +adding-a-tool.zh.md: 915dc8250c2bcfc490483f87c71e725b1f92f635 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 94cb4fcfa9..f94ddfaa9d 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -22,10 +22,14 @@ export function apply(ctx: Context) { path: { type: 'string', required: true, description: 'Absolute path' }, limit: { type: 'number' }, // optional by default }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args, exec) { // args is TYPED from the schema: { path: string; limit?: number } // exec carries immutable identity + token; signal is the operational field - return [{ type: 'text', text: await readFile(args.path, 'utf8') }] + return readFile(args.path, 'utf8') }, })) } @@ -38,9 +42,10 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w - **Args are validated for you.** `defineTool` validates model-generated `arguments` against the unified `ParameterSchemaSpec` before `execute` runs (types, required keys, literal constraints, exact-one unions, and nested values — [runtime arg validation](../../.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args match `InferArgs`. Explicit object nodes declare `additionalProperties: true | false`; the implicit parameter root stays open. You still hand-check constraints the DSL does not express, such as non-empty strings, positive numbers, or cross-field rules. Raw JSON-Schema tools registered directly own their input validation. - **Registration borrows your readonly definition.** A typed same-process contribution is not a serialization boundary; do not mutate its schema or replace callbacks after registration. `schemas()` materializes only the explicit model-facing projection. To hot-swap a tool, dispose its owning effect and register the replacement; mutable state inside the callback's closure remains ordinary plugin state. - **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. An around-dispatch wrapper may add, replace, or remove only `exec.signal` to impose cancellation or a deadline. -- **Throwing or returning non-JSON data means `isError`.** The registry catches throws and materializes the final result before observers run. A malformed or non-JSON result becomes `{ isError: true }`, preventing a live success that cannot be logged. Throw for infrastructure failures; report domain failures in result text when the model must interpret them. +- **Declare and return one canonical JSON value.** `output.schema` uses `ValueSchemaSpec` and may have an object, array, scalar, or null root. `execute` returns only the inferred value; the registry snapshots it as lossless JSON, validates it, freezes it, and passes it to `output.render(args, value)`. Do not return content blocks from the body or make callers parse prose for ids and fields. +- **Throwing or returning an invalid value means `isError`.** The registry catches throws and contains schema, renderer, metadata-projector, and lossless-JSON failures before observers run. Throw for infrastructure failures. Represent a successful domain outcome in the canonical value even when its Native renderer explains a non-ideal state, such as a non-zero process exit. - **Honor `exec.signal`.** Cancel in-flight work when it fires. -- **Attach durable card data with `meta` (optional).** `execute` may return `{ content, meta }` instead of a bare `ContentBlock[]` — `meta` is a JSON-serializable payload the core treats as opaque, persisted on the `tool/result` event and handed back to your `presentResult` (so a card that needs more than `args`, like `write`/`edit`'s applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facing `content`. +- **Project durable card data with `presentationMeta` (optional).** `output.presentationMeta(args, value)` derives replayable JSON from the same canonical value. The core persists it on `tool/result` and hands it to `presentResult`, so a card that needs result-time facts—such as `write`/`edit` applied hunks—survives replay without persisting the canonical value. The projector is skipped for nested Code dispatches because they have no cards. - **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: ''}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch). ## Long-running work @@ -51,7 +56,7 @@ The producer supplies synchronous `cancel`, non-rejecting `done` that settles af ## Execution policy and observation -Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](extension-cookbook.md#a-hook-plugin-permission-gate-example)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap core dispatch with a deadline/retry/metrics scope, `tools/post-execute` to transform or attach model-facing context, and `tools/result` to observe the immutable normalized outcome without changing it. A sandboxing implementation can also sit behind the tool's executor capability seam; the exact contracts are in the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points). +Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](extension-cookbook.md#a-hook-plugin-permission-gate-example)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap canonical dispatch with a deadline/retry/metrics scope, `tools/post-execute` to replace either presentation content or the canonical value, block, or attach model-facing context, and `tools/result` to observe the immutable normalized outcome. A content replacement leaves programmatic access to `value` intact; confidentiality policy blocks or replaces the value. A sandboxing implementation can also sit behind the tool's executor capability seam; the exact contracts are in the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points). ## Code Mode reaches your tool for free @@ -59,7 +64,7 @@ In [Code Mode](../../packages/core/tools/README.md), every visible registered to ## How your tool renders in an editor (ACP presentation) -Your tool's `execute` returns model-facing content; its **editor card** is a separate, optional concern you declare with two pure display methods on the `defineTool` options. Design this alongside `execute`, not after — an editor (Zed, over the ACP bridge) shows the card, and a tool with no presentation falls back to a bland generic card (title = tool name, raw args as input). +Your tool's `output.render` returns model-facing content; its **editor card** is a separate concern declared through pure presentation projections and optional `presentCall` / `presentResult` methods. Design these alongside the canonical value—an editor (Zed, over the ACP bridge) shows the card, and a tool with no UI presentation falls back to a generic card (title = tool name, raw args as input). Both methods return a **`card`-tagged render intent** — pick the card kind that matches what your tool does: @@ -70,16 +75,16 @@ Both methods return a **`card`-tagged render intent** — pick the card kind tha - `presentResult(args, { content, isError, meta? })` returns the completed card: - `generic` supplies an optional title and content. - `terminal` supplies raw output and optional exit metadata; the bridge renders the capability-specific or fenced fallback view. - - `diff` supplies applied hunks, often carried in persisted `result.meta` so replay reproduces them. Mutation tools keep a diff result because an ACP update replaces the pending card's content. + - `diff` supplies applied hunks, often derived by `output.presentationMeta` and carried in persisted `result.meta` so replay reproduces them. Mutation tools keep a diff result because an ACP update replaces the pending card's content. Hard rules (they bite if broken): - **Purity.** These run on live streaming AND on session-log REPLAY, so they must be pure functions of `args` (+ the result) — NO I/O, NO reading session state, NO clock/random. A diff is derived from the args (`write` uses `oldText: null` because a call-time presenter has no prior file content); the BRIDGE, not the tool, fills the session cwd and relativizes a display-path title. If you find yourself wanting the file's old content or the working directory inside `presentCall`, stop — that belongs on the bridge or a future result-event shape, not the presenter. -- **UI-only formatting stays out of the model result.** A fenced ` ```console ` block, a diff, a relativized path — none of these may appear in what `execute` returns to the model; they live only in the presentation. (A `terminal` result view carries RAW `output`; the bridge adds the fences.) +- **UI-only formatting stays out of the model result.** A fenced ` ```console ` block, a diff, a relativized path—none of these belongs in the canonical value or Native content merely to serve an editor. `output.render` owns model-facing prose; `presentationMeta` plus the card presenters own replayable UI state. A `terminal` result view carries raw output and the bridge adds fences. - **`defineTool` soft-validates the display path.** A malformed/older logged arg shape makes the wrapper return `undefined` (a generic fallback) rather than throw — display must never crash a replay. The neutral vocabulary lives in `dsh-tools` (never import an ACP type into a tool); the ACP bridge maps each `card` to the wire. The design and the why are in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations. ## Tests every tool needs -Cover argument rejection, every result shape, and HMR disposal. For a side-effecting tool, drive the real tool through the agent loop with a scripted `MockAdapter` and assert its `tool/call` and `tool/result` session events. For an editor card, assert the exact `presentCall` and `presentResult` views and add an [ACP snapshot](../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) through the real bridge; a terminal card's scenario sets `terminalOutput: true` to exercise the capable-client path. +Cover argument rejection, every canonical value and Native rendering shape, output-schema rejection, and HMR disposal. For a side-effecting tool, drive the real tool through the agent loop with a scripted `MockAdapter` and assert its `tool/call` and projected `tool/result` session events; prove the canonical value itself is not persisted. For an editor card, assert the exact `presentCall` and `presentResult` views and add an [ACP snapshot](../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) through the real bridge; a terminal card's scenario sets `terminalOutput: true` to exercise the capable-client path. diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 637dc33817..915dc8250c 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -22,10 +22,14 @@ export function apply(ctx: Context) { path: { type: 'string', required: true, description: 'Absolute path' }, limit: { type: 'number' }, // optional by default }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args, exec) { // args is TYPED from the schema: { path: string; limit?: number } // exec carries immutable identity + token; signal is the operational field - return [{ type: 'text', text: await readFile(args.path, 'utf8') }] + return readFile(args.path, 'utf8') }, })) } @@ -38,9 +42,10 @@ export function apply(ctx: Context) { - **参数已为你校验。** `defineTool` 在 `execute` 运行前,会根据统一的 `ParameterSchemaSpec` 校验模型生成的 `arguments`(类型、必填键、字面量约束、恰好匹配一个分支的联合以及嵌套值——见[运行时参数校验](../../.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md)),因此 `execute` 内的 args 会匹配 `InferArgs`。显式对象节点必须声明 `additionalProperties: true | false`;隐式参数根对象保持开放。你仍需手动检查 schema DSL 无法表达的约束,例如非空字符串、正数或跨字段规则。直接注册的原始 JSON Schema 工具自行负责输入校验。 - **注册借用你的只读定义。** 类型化的同进程贡献不是序列化边界;注册后不要修改其 schema 或替换回调。`schemas()` 只物化显式的模型可见投影。如需热替换工具,请 dispose 其所属副作用并注册替代品;回调闭包内的可变状态仍是普通的插件状态。 - **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token` 以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。around-dispatch 包装器只能添加、替换或移除 `exec.signal`,以施加取消或截止时间。 -- **抛出异常或返回非 JSON 数据意味着 `isError`。** 注册表捕获异常,并在观察者运行前物化最终结果。格式错误或非 JSON 的结果变为 `{ isError: true }`,防止出现无法记录的活跃成功。基础设施故障请抛异常;当模型需要解读领域失败时,请在结果文本中报告。 +- **声明并返回一个规范 JSON 值。** `output.schema` 使用 `ValueSchemaSpec`,根可以是对象、数组、标量或 null。`execute` 只返回推导出的值;注册表将其快照为无损 JSON,完成校验和冻结后,再传给 `output.render(args, value)`。工具主体不要返回内容块,也不要迫使调用方从自然语言中解析 id 和字段。 +- **抛出异常或返回无效值意味着 `isError`。** 注册表会捕获异常,并在观察者运行前收敛 schema、渲染器、元数据投影器和无损 JSON 失败。基础设施故障请抛异常。成功的领域结果即使表示不理想的状态,也应写入规范值;其 Native 渲染器可以解释该状态,例如进程以非零状态退出。 - **遵守 `exec.signal`。** 信号触发时取消进行中的工作。 -- **使用 `meta` 附加持久化的卡片数据(可选)。** `execute` 可以返回 `{ content, meta }` 而非裸的 `ContentBlock[]`。`meta` 是 JSON 可序列化的载荷,核心将其视为不透明数据,持久化在 `tool/result` 事件上并回传给你的 `presentResult`(这样需要 `args` 之外信息的卡片——如 `write`/`edit` 的已应用 hunk diff——在会话回放中依然存活)。仅在此处放 UI 数据,绝不放入模型可见的 `content`。 +- **使用 `presentationMeta` 投影持久化的卡片数据(可选)。** `output.presentationMeta(args, value)` 从同一个规范值派生可回放的 JSON。核心将其持久化在 `tool/result` 上并传给 `presentResult`,因此需要结果期事实的卡片——例如 `write`/`edit` 的已应用 hunk——无需持久化规范值也能在回放中重现。嵌套 Code 分发没有卡片,因此会跳过该投影器。 - **使用 `exec.agent` 发送异步通知。** `agent.inject(content, {source: {kind: 'plugin', plugin: ''}})` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。请防范已 dispose 的 agent(try/catch)。 ## 长时间运行的工作 @@ -51,7 +56,7 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 ## 执行策略与观测 -尽量不要把部署策略内建到工具中。使用 `tools/pre-execute` 实现可扩展的允许/拒绝/询问策略(见[权限门禁示例](extension-cookbook.md#a-hook-plugin-permission-gate-example));使用 `ctx.tools.guard()` 设置最终的单调拒绝(后续监听器无法撤销);使用 `tools/execute` 为核心分发包装截止时间/重试/指标作用域;使用 `tools/post-execute` 转换或附加模型可见的上下文;使用 `tools/result` 观测不可变的归一化结果而不改变它。沙箱实现也可以位于工具执行器的能力 seam 之后;确切契约见 [`dsh-tools` README](../../packages/core/tools/README.md#extension-points)。 +尽量不要把部署策略内建到工具中。使用 `tools/pre-execute` 实现可扩展的允许/拒绝/询问策略(见[权限门禁示例](extension-cookbook.md#a-hook-plugin-permission-gate-example));使用 `ctx.tools.guard()` 设置最终的单调拒绝,后续监听器无法撤销;使用 `tools/execute` 为规范分发包装截止时间/重试/指标作用域;使用 `tools/post-execute` 替换展示内容或规范值、阻止调用,或附加模型可见上下文;使用 `tools/result` 观测不可变的归一化结果而不改变它。替换内容不会阻止程序化访问 `value`;保密策略必须阻止调用或替换值。沙箱实现也可以位于工具执行器的能力 seam 之后;确切契约见 [`dsh-tools` README](../../packages/core/tools/README.md#extension-points)。 ## Code Mode 自动触达你的工具 @@ -59,7 +64,7 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 ## 工具在编辑器中的渲染方式(ACP 展示) -工具的 `execute` 返回模型可见的内容;其**编辑器卡片**是一个独立的、可选的关注点,通过 `defineTool` 选项中的两个纯展示方法声明。请与 `execute` 同步设计,而非事后补充——编辑器(如 Zed,通过 ACP(Agent Client Protocol)桥接)会展示该卡片,没有展示方法的工具回退为一个朴素的通用卡片(标题 = 工具名,原始 args 作为输入)。 +工具的 `output.render` 返回模型可见的内容;其**编辑器卡片**是另一项独立关注点,通过纯展示投影以及可选的 `presentCall`/`presentResult` 方法声明。请将这些内容与规范值一并设计:编辑器(如 Zed,通过 ACP(Agent Client Protocol)桥接)会展示该卡片,没有 UI 展示方法的工具则回退到通用卡片(标题 = 工具名,原始 args 作为输入)。 两个方法都返回一个 **`card` 标签的渲染意图**——选择与你的工具行为匹配的卡片类型: @@ -70,16 +75,16 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 - `presentResult(args, { content, isError, meta? })` 返回完成后的卡片: - `generic` 提供可选的标题和内容。 - `terminal` 提供原始输出和可选的退出元数据;桥接层渲染能力特定或围栏回退视图。 - - `diff` 提供已应用的 hunk,通常由持久化的 `result.meta` 携带,使回放能重现它们。变更类工具保留 diff 结果,因为 ACP 更新会替换 pending 卡片的内容。 + - `diff` 提供已应用的 hunk,通常由 `output.presentationMeta` 派生并通过持久化的 `result.meta` 携带,使回放能重现它们。变更类工具保留 diff 结果,因为 ACP 更新会替换 pending 卡片的内容。 硬性规则(违反会出问题): - **纯函数。** 这些方法在实时流式输出和会话日志回放时都会运行,因此必须是 `args`(加 result)的纯函数——不做 I/O、不读会话状态、不用时钟/随机数。diff 从 args 派生(`write` 使用 `oldText: null`,因为调用时的展示器没有文件先前内容);**桥接层**(而非工具)填充会话 cwd 并相对化展示路径标题。如果你发现自己想在 `presentCall` 内获取文件旧内容或工作目录,请停下——那属于桥接层或未来的 result-event 形态,不属于展示器。 -- **UI 格式不进入模型结果。** 围栏 ` ```console ` 块、diff、相对化路径——这些都不得出现在 `execute` 返回给模型的内容中;它们只存在于展示层。(`terminal` 结果视图携带原始 `output`;桥接层添加围栏。) +- **UI 格式不进入模型结果。** 围栏 ` ```console ` 块、diff、相对化路径均不应仅为服务编辑器而进入规范值或 Native 内容。`output.render` 负责模型可见的自然语言;`presentationMeta` 和卡片展示器负责可回放的 UI 状态。`terminal` 结果视图携带原始输出,由桥接层添加围栏。 - **`defineTool` 对展示路径做软校验。** 格式错误或旧版日志中的 arg 形态会使包装器返回 `undefined`(通用回退)而非抛异常——展示绝不能导致回放崩溃。 中性词汇定义在 `dsh-tools` 中(绝不在工具中导入 ACP 类型);ACP 桥接层将每个 `card` 映射到协议格式(wire format)。设计与原因见[渲染意图联合体 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。 ## 每个工具必须的测试 -覆盖参数拒绝、每种结果形态和 HMR dispose。对于有副作用的工具,使用脚本化的 `MockAdapter` 驱动真实工具通过 agent loop(智能体循环),并断言其 `tool/call` 和 `tool/result` 会话事件。对于编辑器卡片,断言 `presentCall` 和 `presentResult` 的精确视图,并通过真实桥接层添加一个 [ACP 快照](../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md);终端卡片的场景设置 `terminalOutput: true` 以覆盖 capable-client 路径。 +覆盖参数拒绝、每种规范值和 Native 渲染形态、输出 schema 拒绝以及 HMR dispose。对于有副作用的工具,使用脚本化的 `MockAdapter` 驱动真实工具通过 agent loop(智能体循环),并断言其 `tool/call` 和投影后的 `tool/result` 会话事件;同时证明规范值本身未被持久化。对于编辑器卡片,断言 `presentCall` 和 `presentResult` 的精确视图,并通过真实桥接层添加一个 [ACP 快照](../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md);终端卡片的场景设置 `terminalOutput: true` 以覆盖 capable-client 路径。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index cde1c4340b..2b995146ce 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -760,7 +760,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:131`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:135`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -780,7 +780,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:104`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:108`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -800,7 +800,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:117`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -819,7 +819,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:95`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:99`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -838,7 +838,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:121`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:125`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 48feaa92a6..53180d3621 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1361,7 +1361,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:453`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:504`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 8650688222..2df677790b 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -73,15 +73,24 @@ interface SessionEventMap { */ 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } /** - * A completed tool call's model-facing result, plus an optional tool-private - * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the - * producing tool owns its shape and reads it back in `presentResult`) but MUST - * be JSON-serializable: `Session.append` runtime-validates all event data with - * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the - * durable log reproduces the identical card on replay. Absent unless the tool - * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + * A completed tool call's model-facing result, canonical failure detail, and + * optional tool-private `meta` presentation payload. `meta` is opaque to the + * core (the producing tool owns its shape and reads it back in `presentResult`) + * but MUST be JSON-serializable: `Session.append` runtime-validates all event + * data with `isJsonValue`, so a non-serializable `meta` is rejected at the + * source, and the durable log reproduces the identical card on replay. Absent + * unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time + * contextual diff here). */ - 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } + 'tool/result': { + turn: number + step: number + callId: CallId + content: ContentBlock[] + isError: boolean + error?: { message: string; info?: { name: string; code: string } } + meta?: JsonValue + } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 20a74e0190..6ec941d0df 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -6,12 +6,27 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index ## `ToolDefinition` — a registered tool -A `ToolSchema` (the model-facing fields) plus the `execute` function, host-only scheduler metadata, and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's `schemas()` builds the model-facing `ToolSchema[]` by an explicit allowlist — `execute`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` must never leak into a model request. +A `ToolSchema` (the model-facing fields) plus a mandatory canonical output declaration, the `execute` function, host-only scheduler metadata, and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's `schemas()` builds the model-facing `ToolSchema[]` by an explicit allowlist — `output`/`execute`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` must never leak into a model request. + +```ts type-equiv +/** Tool-owned canonical output contract used after the body returns a JSON value. */ +interface ToolOutputDefinition { + /** Raw supported JSON Schema enforced against every successful canonical value. */ + readonly schema: JsonSchemaNode + /** Pure projection from validated arguments and value to Native/model content. */ + render(args: unknown, value: JsonValue): ContentBlock[] + /** Pure replayable presentation projection, computed only for surface calls. */ + presentationMeta?(args: unknown, value: JsonValue): JsonValue +} +``` ```ts type-equiv /** A registered tool: its schema plus the execution function. */ interface ToolDefinition extends ToolSchema { - execute(args: unknown, exec: ToolRunContext): Promise + /** Mandatory canonical output declaration. */ + readonly output: ToolOutputDefinition + /** Execute the tool and return only its canonical lossless-JSON value. */ + execute(args: unknown, exec: ToolRunContext): Promise /** * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. * Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it @@ -46,7 +61,7 @@ interface ToolDefinition extends ToolSchema { presentCall?(args: unknown): ToolCallView | undefined /** * Optional: how to present the COMPLETED state, given the same `args` and the - * `result` (`execute`'s content + whether it errored). Returns a + * durable result projection (`content`, failure state, and optional `meta`). Returns a * {@link ToolResultView}, or `undefined` (or omit the method) to keep the * pending title and render the raw result content. Pure and side-effect-free * for the same replay reason. @@ -55,7 +70,7 @@ interface ToolDefinition extends ToolSchema { } ``` -`execute` receives `args: unknown` — a raw `ToolDefinition` validates its own input. First-party tools don't write that by hand; they use `defineTool`, which validates and narrows for them. +`execute` receives `args: unknown` — a raw `ToolDefinition` validates its own input. First-party tools don't write that by hand; they use `defineTool`, which validates and narrows the arguments, infers the body return from `output.schema`, and types both output projectors. ## The unified JSON-value schema DSL @@ -97,27 +112,28 @@ type ParameterSchemaSpec = Record * Infer the TypeScript value accepted by an author-facing value schema. * Output schemas may therefore infer object, array, scalar, or null roots. */ -type InferValue = - S extends StringValueSchemaSpec ? InferScalar : - S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar : - S extends BooleanValueSchemaSpec ? InferScalar : - S extends NullValueSchemaSpec ? null : - S extends ArrayValueSchemaSpec - ? S extends { items: infer I extends ValueSchemaSpec } ? InferValue[] : JsonValue[] - : S extends ObjectValueSchemaSpec ? InferObject : - S extends JsonValueSchemaSpec ? JsonValue : - S extends OneOfValueSchemaSpec ? InferValue : - never +type InferValue = + D['length'] extends 12 ? JsonValue : + S extends StringValueSchemaSpec ? InferScalar : + S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar : + S extends BooleanValueSchemaSpec ? InferScalar : + S extends NullValueSchemaSpec ? null : + S extends ArrayValueSchemaSpec + ? S extends { items: infer I extends ValueSchemaSpec } ? InferValue>[] : JsonValue[] + : S extends ObjectValueSchemaSpec ? InferObject> : + S extends JsonValueSchemaSpec ? JsonValue : + S extends OneOfValueSchemaSpec ? InferValue> : + never ``` ```ts type-equiv /** Infer the TypeScript argument object for an implicit parameter schema. */ -type InferArgs = InferProperties +type InferArgs = InferProperties ``` -`defineTool({ name, description, parameters, execute, … })` ties parameter inference to `parameterSchemaSpecToJsonSchema()` and `validateArgs()`. `valueSchemaSpecToJsonSchema()` compiles value/output declarations through the same enforced raw subset. A parameter mismatch throws `ToolArgsError` (`INVALID_ARGS`), which the registry returns through the normal tool-error path. Raw JSON Schema remains open by default; unsupported keywords reject instead of being accepted without enforcement. +`defineTool({ name, description, parameters, output, execute, … })` ties parameter inference to `parameterSchemaSpecToJsonSchema()` and `validateArgs()`, and ties `execute`/`render`/`presentationMeta` to `InferValue`. Inference widens to `JsonValue` after twelve nested nodes so large schemas remain compilable; runtime validation keeps walking the complete schema. `valueSchemaSpecToJsonSchema()` compiles output declarations through the same enforced raw subset. A parameter mismatch throws `ToolArgsError` (`INVALID_ARGS`); an invalid body or post-policy value throws `ToolOutputError` (`INVALID_TOOL_OUTPUT`). Both use the normal tool-error path. Raw JSON Schema remains open by default; unsupported keywords reject instead of being accepted without enforcement. -Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input and validates only semantic requirements such as a positive finite `timeoutMs`; `schemas()` materializes the explicit model-facing projection at the model boundary so execution and presentation share one resolved definition without leaking callbacks onto the wire. +Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input, requires `output`, validates its raw schema, and checks semantic requirements such as a positive finite `timeoutMs`; `schemas()` materializes the explicit model-facing projection at the model boundary so execution and presentation share one resolved definition without leaking callbacks onto the wire. ## `ToolRestriction` — one scope's live global filter @@ -230,34 +246,48 @@ type ToolGuard = (execution: Readonly) => string | undefined ``` ```ts type-equiv -/** The outcome of one tool call. */ -interface ToolExecutionResult { - content: ContentBlock[] - isError: boolean - /** - * Set when the call failed with a {@link HarnessError}: machine-routable - * `{ name, code }` for retry/sandbox plugins and replay. The model-facing - * text in `content` is always present; this is extra structure for code. - */ - error?: ToolErrorInfo - /** - * Model-facing context for the next request, separate from this tool result. The loop - * accepts it into the active-batch FIFO, then appends after recorded results even if interrupted. - */ - additionalContexts?: HookContext[] - /** - * The tool-private presentation payload from a successful `execute` (the object - * return form). Threaded onto the `tool/result` session event and back into - * {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the - * tool attached none or the call failed. - */ - meta?: unknown +/** Canonical failure detail; internal routing information remains optional. */ +interface ToolFailure { + /** Human-readable failure message without the Native `Error: ` envelope. */ + message: string + /** Internal error class/code used by policy and durable diagnostics. */ + info?: ToolErrorInfo } ``` -The result carries only the outcome. Call identity remains on the immutable `ToolExecution` that accompanies it through every hook and on the durable `tool/call` / `tool/result` session events, so wrappers cannot create a second, disagreeing identity. +```ts type-equiv +/** Successful canonical tool execution, including its Native/model projection. */ +interface ToolExecutionSuccess { + readonly isError: false + /** Execution-local canonical value; deliberately omitted from durable events. */ + readonly value: JsonValue + readonly content: ContentBlock[] + readonly error?: never + readonly meta?: JsonValue + readonly additionalContexts?: HookContext[] +} +``` -The registry materializes and freezes the final accepted result immediately before `tools/result`. Its content, structured error, additional context, and presentation metadata must round-trip losslessly through JSON; an invalid outcome becomes a JSON-safe `isError` result, so the observed live outcome is safe for the later durable `tool/result` append. +```ts type-equiv +/** Failed canonical tool execution; failures never carry a successful value. */ +interface ToolExecutionFailure { + readonly isError: true + readonly error: ToolFailure + readonly value?: never + readonly content: ContentBlock[] + readonly meta?: JsonValue + readonly additionalContexts?: HookContext[] +} +``` + +```ts type-equiv +/** The discriminated, execution-local outcome of one tool call. */ +type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure +``` + +The result carries only the outcome. Call identity remains on the immutable `ToolExecution` that accompanies it through every hook and on the durable `tool/call` / `tool/result` session events, so wrappers cannot create a second, disagreeing identity. The canonical `value` is execution-local: the loop persists only `content`, `error`, and `meta`, while `tool/code-dispatch` stores a bounded summary. Replay reproduces presentation but cannot reconstruct intermediate values. + +On success the registry snapshots and validates the body value, freezes it, and invokes the pure renderer plus the optional direct-surface metadata projector. It separately materializes the durable presentation fields immediately before `tools/result`; an invalid value, renderer/projector failure, or non-JSON presentation becomes a JSON-safe `isError`. The final live observer therefore sees the exact execution-local value beside fields safe for the later durable append. Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/execute` wrappers return a `ToolExecutionResult`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`: @@ -276,17 +306,18 @@ type PreToolDecision = ```ts type-equiv /** - * Post-dispatch decision: accept or replace content, attach context for the next - * request, or block by turning corrective feedback into an error result. + * Post-dispatch decision: accept, replace one projection, attach context for the + * next request, or block by turning corrective feedback into an error result. */ type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] } + | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] } | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } ``` Call `next()` for the default or return a decision to short-circuit. Pre-policy may deny or ask; only `allowed-once` proceeds, while a non-grant, missing approval channel or service, or agent-less request becomes a denial. Guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree. -Post-policy may replace content; a block becomes an `isError` result containing its corrective feedback. `tools/result` receives the frozen execution and result after normalization; observers cannot transform them, and observer failures are contained. Unknown and throwing tools both become structured errors (`ToolNotFoundError` maps to `UNKNOWN_TOOL`), so the call fails without ending the turn. +Post-policy may replace either content or value, never both. Content replacement preserves the canonical value and existing metadata; value replacement is revalidated and recomputes content/metadata; a block removes the value and becomes an `isError` containing corrective feedback. Content replacement is presentation policy, not confidentiality policy: a listener that must hide the programmatic value blocks or replaces it. `tools/result` receives the frozen execution and result after normalization; observers cannot transform them, and observer failures are contained. Unknown and throwing tools both become structured errors (`ToolNotFoundError` maps to `UNKNOWN_TOOL`), so the call fails without ending the turn. ## The enforced raw JSON Schema subset diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6185522793..f84eee5ac7 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -41,11 +41,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:131`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:104`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:95`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:121`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:135`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:108`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:117`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:99`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 80b0b7196d..9ca545854c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:345`](../packages/core/session/src/types.ts) ## Events @@ -356,7 +356,7 @@ Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:272`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -387,7 +387,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/s Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:265`](../packages/core/session/src/types.ts) ### `step/*` @@ -420,7 +420,7 @@ Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:258`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/types.ts) ### `tool/*` @@ -468,20 +468,29 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c ```ts persistence-catalog /** - * A completed tool call's model-facing result, plus an optional tool-private - * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the - * producing tool owns its shape and reads it back in `presentResult`) but MUST - * be JSON-serializable: `Session.append` runtime-validates all event data with - * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the - * durable log reproduces the identical card on replay. Absent unless the tool - * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + * A completed tool call's model-facing result, canonical failure detail, and + * optional tool-private `meta` presentation payload. `meta` is opaque to the + * core (the producing tool owns its shape and reads it back in `presentResult`) + * but MUST be JSON-serializable: `Session.append` runtime-validates all event + * data with `isJsonValue`, so a non-serializable `meta` is rejected at the + * source, and the durable log reproduces the identical card on replay. Absent + * unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time + * contextual diff here). */ -'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } +'tool/result': { + turn: number + step: number + callId: CallId + content: ContentBlock[] + isError: boolean + error?: { message: string; info?: { name: string; code: string } } + meta?: JsonValue +} ``` Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) ### `turn/*` diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index eaf24e38a7..fa75d4dd17 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -203,7 +203,7 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor ### `cordis_mount` -Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. +Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. ```json { diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index 711715a5de..a6ab84c3e0 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -index.md: d7d657ff7b8cb9001dd5e9c3af658a7a3c45b5b7 -index.zh.md: 7a134f7aaed470b87ee8ca8978dd39593de2651b +index.md: 5a9f8dfb8f2d87dfbd2ba30b4d09d002ae9b635c +index.zh.md: 08aca87cbc02d1b0dfbe6fe2d92b3f6e87075097 diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index d7d657ff7b..5a9f8dfb8f 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -138,8 +138,12 @@ export function apply(ctx: Context) { parameters: { name: { type: 'string', required: true }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { - return [{ type: 'text', text: `Hello, ${args.name}!` }] + return `Hello, ${args.name}!` }, })) } diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index 7a134f7aae..08aca87cbc 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -138,8 +138,12 @@ export function apply(ctx: Context) { parameters: { name: { type: 'string', required: true }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { - return [{ type: 'text', text: `Hello, ${args.name}!` }] + return `Hello, ${args.name}!` }, })) } diff --git a/docs/user/develop/basic/tool.i18n.yaml b/docs/user/develop/basic/tool.i18n.yaml index b741ccf65f..73970f99d8 100644 --- a/docs/user/develop/basic/tool.i18n.yaml +++ b/docs/user/develop/basic/tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -tool.md: 17adbfc5f7eb804856dfe39b4d2b4a65374b6414 -tool.zh.md: 8857e16ca836dfa5b158a68581bd3c715dcb1ac5 +tool.md: 7b211cfef54306f7c316dc08da1df759dcbf1b06 +tool.zh.md: 214b35b28de0c647737bc8297b13b4997947b52e diff --git a/docs/user/develop/basic/tool.md b/docs/user/develop/basic/tool.md index 17adbfc5f7..7b211cfef5 100644 --- a/docs/user/develop/basic/tool.md +++ b/docs/user/develop/basic/tool.md @@ -20,9 +20,13 @@ export function apply(ctx: Context) { parameters: { name: { type: 'string', required: true, description: 'The name to greet' }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { // args is inferred as { name: string }. - return [{ type: 'text', text: `Hello, ${args.name}!` }] + return `Hello, ${args.name}!` }, })) } @@ -107,33 +111,45 @@ export const tool = defineTool({ name: 'example', description: 'Return an example result.', parameters: {}, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args, exec) { // args: inferred from parameters // exec: ToolExecution context - // Return a ContentBlock array. + // Return the value declared by output.schema. void args void exec - return [{ type: 'text', text: 'result here' }] + return 'result here' }, }) ``` ### Return value -`execute` returns a `ContentBlock[]` that becomes the tool result visible to the model: +`execute` returns the lossless JSON value declared by `output.schema`. `output.render(args, value)` separately turns that validated value into the Native/model-facing content: ```ts ignore-check -// Text result -return [{ type: 'text', text: 'file content here...' }] - -// Multiple blocks -return [ - { type: 'text', text: 'Found 3 matches:' }, - { type: 'text', text: matchResults.join('\n') }, -] +output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + content: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: value.content }], +}, +async execute(args) { + return { path: args.path, content: await readFile(args.path, 'utf8') } +} ``` +The canonical value is available to execution-time programmatic callers and is not persisted in `tool/result`; the rendered content and optional `presentationMeta` are the replayable projections. A body value that does not satisfy the schema, or is not lossless JSON, becomes an `INVALID_TOOL_OUTPUT` failure. + ### Argument validation Before calling `execute`, `defineTool` validates model-generated arguments. Invalid input raises `ToolArgsError`; the framework turns it into an `isError` result so the model can correct its call. @@ -148,6 +164,10 @@ A tool can define UI presentation methods for terminal and ACP clients: defineTool({ name: 'bash', // ... + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, presentCall(args) { return { card: 'terminal', @@ -196,13 +216,24 @@ export function apply(ctx: Context) { path: { type: 'string', required: true, description: 'Directory path' }, extension: { type: 'string', description: 'Filter by extension (e.g. ".ts")' }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + count: { type: 'integer', required: true }, + files: { type: 'array', required: true, items: { type: 'string' } }, + }, + }, + render: (_args, value) => [{ type: 'text', text: `Found ${value.count} files.` }], + }, async execute(args) { const entries = await readdir(args.path, { withFileTypes: true }) let files = entries.filter(e => e.isFile()) if (args.extension) { files = files.filter(f => f.name.endsWith(args.extension!)) } - return [{ type: 'text', text: `Found ${files.length} files.` }] + return { count: files.length, files: files.map(file => file.name) } }, })) } diff --git a/docs/user/develop/basic/tool.zh.md b/docs/user/develop/basic/tool.zh.md index 8857e16ca8..214b35b28d 100644 --- a/docs/user/develop/basic/tool.zh.md +++ b/docs/user/develop/basic/tool.zh.md @@ -20,9 +20,13 @@ export function apply(ctx: Context) { parameters: { name: { type: 'string', required: true, description: 'The name to greet' }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { // args is inferred as { name: string }. - return [{ type: 'text', text: `Hello, ${args.name}!` }] + return `Hello, ${args.name}!` }, })) } @@ -107,33 +111,45 @@ export const tool = defineTool({ name: 'example', description: 'Return an example result.', parameters: {}, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args, exec) { // args: inferred from parameters // exec: ToolExecution context - // Return a ContentBlock array. + // Return the value declared by output.schema. void args void exec - return [{ type: 'text', text: 'result here' }] + return 'result here' }, }) ``` ### 返回值 -`execute` 必须返回一个 `ContentBlock[]`,告诉模型 tool 的执行结果: +`execute` 返回由 `output.schema` 声明的无损 JSON 值。`output.render(args, value)` 会将经过校验的值另外转换为 Native/模型可见的内容: ```ts ignore-check -// Text result -return [{ type: 'text', text: 'file content here...' }] - -// Multiple blocks -return [ - { type: 'text', text: 'Found 3 matches:' }, - { type: 'text', text: matchResults.join('\n') }, -] +output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + content: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: value.content }], +}, +async execute(args) { + return { path: args.path, content: await readFile(args.path, 'utf8') } +} ``` +执行期间的程序化调用方可以使用规范值,但 `tool/result` 不会持久化该值;渲染后的内容和可选的 `presentationMeta` 才是可回放的投影。工具主体返回的值若不满足 schema 或不是无损 JSON,就会变为 `INVALID_TOOL_OUTPUT` 失败。 + ### 参数校验 `defineTool` 在调用 `execute` 之前会自动校验模型生成的参数。如果参数不合法,会抛出 `ToolArgsError`,框架将其转换为 `isError` 结果返回给模型,让模型自行修正。 @@ -148,6 +164,10 @@ Tool 可以定义 UI 渲染方法,用于在终端或 ACP 客户端中展示 to defineTool({ name: 'bash', // ... + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, presentCall(args) { return { card: 'terminal', @@ -196,13 +216,24 @@ export function apply(ctx: Context) { path: { type: 'string', required: true, description: 'Directory path' }, extension: { type: 'string', description: 'Filter by extension (e.g. ".ts")' }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + count: { type: 'integer', required: true }, + files: { type: 'array', required: true, items: { type: 'string' } }, + }, + }, + render: (_args, value) => [{ type: 'text', text: `Found ${value.count} files.` }], + }, async execute(args) { const entries = await readdir(args.path, { withFileTypes: true }) let files = entries.filter(e => e.isFile()) if (args.extension) { files = files.filter(f => f.name.endsWith(args.extension!)) } - return [{ type: 'text', text: `Found ${files.length} files.` }] + return { count: files.length, files: files.map(file => file.name) } }, })) } diff --git a/docs/user/develop/practice/index.i18n.yaml b/docs/user/develop/practice/index.i18n.yaml index d2478abf75..799dffc1c6 100644 --- a/docs/user/develop/practice/index.i18n.yaml +++ b/docs/user/develop/practice/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -index.md: 0261b49b071167f7c2a33f78bbc1959cc6f1879f -index.zh.md: 5819344430fcbde31bf825e9815120983e44e3f6 +index.md: e197d499d7f5bd9911ea60bebf584251cd4ed915 +index.zh.md: 8b8d08f9d0c6d0ca8d95fbaa3281c98b7a600fe4 diff --git a/docs/user/develop/practice/index.md b/docs/user/develop/practice/index.md index 0261b49b07..e197d499d7 100644 --- a/docs/user/develop/practice/index.md +++ b/docs/user/develop/practice/index.md @@ -132,9 +132,13 @@ export function apply(ctx: Context) { parameters: { input: { type: 'string', required: true }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { const result = await ctx.myCap.execute({ input: args.input }) - return [{ type: 'text', text: result.output }] + return result.output }, })) } diff --git a/docs/user/develop/practice/index.zh.md b/docs/user/develop/practice/index.zh.md index 5819344430..8b8d08f9d0 100644 --- a/docs/user/develop/practice/index.zh.md +++ b/docs/user/develop/practice/index.zh.md @@ -132,9 +132,13 @@ export function apply(ctx: Context) { parameters: { input: { type: 'string', required: true }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { const result = await ctx.myCap.execute({ input: args.input }) - return [{ type: 'text', text: result.output }] + return result.output }, })) } diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 3e111b8ba2..e463aff141 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -63,7 +63,7 @@ declare const tools: { /** Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events". */ name?: string; } & Record): Promise; - /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ + /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ cordis_mount(args: { /** Body of an async JS function; must `return` the plugin to mount. */ code: string; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 95cdd43b83..487af4392e 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -72,7 +72,7 @@ }, { "name": "cordis_mount", - "description": "Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.", + "description": "Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index cdb76be163..db08475efd 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -13,8 +13,8 @@ {"type":"assistant/chunk","seq":11,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":12,"time":1784437195078,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"} {"type":"tool/call","seq":13,"time":1784437195078,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} -{"type":"tool/result","seq":14,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","seq":14,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true,"error":{"message":"command aborted"}},"sourceEventSeqs":[13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}} -{"type":"tool/result","seq":16,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","content":[{"type":"text","text":"Error: tool call skipped because the step was aborted before execution"}],"isError":true,"error":{"name":"AbortError","code":"ABORTED"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","content":[{"type":"text","text":"Error: tool call skipped because the step was aborted before execution"}],"isError":true,"error":{"message":"tool call skipped because the step was aborted before execution","info":{"name":"AbortError","code":"ABORTED"}}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1784437195090,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":18,"time":1784437195090,"data":{"turn":1,"reason":{"kind":"aborted","reason":"session/cancel"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 207f8d8cc3..f04f7c9cf1 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -10,7 +10,7 @@ {"type":"assistant/chunk","seq":8,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":11,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 5528c956d8..45ae5c9f50 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -2,7 +2,7 @@ {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index ff6d1187b3..4190704a7f 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -157,7 +157,7 @@ {"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} {"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} {"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","outcome":"rejected"}} -{"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} +{"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true,"error":{"message":"the user rejected escalating this command to \"danger-full-access\""}},"sourceEventSeqs":[155],"surfaceOp":"append"} {"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":161,"time":1783860683140,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 54601f5354..013b002edc 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -91,7 +91,7 @@ {"type":"tool/call","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} {"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"c37500b3-c252-4a9b-ad0d-9c4349419b30","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} {"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"c37500b3-c252-4a9b-ad0d-9c4349419b30","outcome":"allowed-once"}} -{"type":"tool/result","seq":92,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"} +{"type":"tool/result","seq":92,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[89],"surfaceOp":"append"} {"type":"step/end","seq":93,"time":1784045703798,"data":{"turn":1,"step":1}} {"type":"step/start","seq":94,"time":1784045703799,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":95,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 66efd5f934..5b1ccd60fc 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -77,7 +77,7 @@ {"type":"assistant/chunk","seq":75,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":76,"time":1783611703972,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75],"surfaceOp":"append"} {"type":"tool/call","seq":77,"time":1783611703972,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":78,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[77],"surfaceOp":"append"} +{"type":"tool/result","seq":78,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"message":"edit requires reading \"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first","info":{"name":"FsError","code":"FS_NOT_OBSERVED"}}},"sourceEventSeqs":[77],"surfaceOp":"append"} {"type":"step/end","seq":79,"time":1783611703978,"data":{"turn":1,"step":1}} {"type":"step/start","seq":80,"time":1783611703978,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":81,"time":1783611704825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 04fd86da0d..acbe253e99 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -62,7 +62,7 @@ {"type":"assistant/chunk","seq":60,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":61,"time":1783352079888,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} {"type":"tool/call","seq":62,"time":1783352079888,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":63,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"tool/result","seq":63,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[62],"surfaceOp":"append"} {"type":"step/end","seq":64,"time":1783352079898,"data":{"turn":1,"step":1}} {"type":"step/start","seq":65,"time":1783352079899,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":66,"time":1783352080825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl index c362de7a26..8cab55c391 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl @@ -75,7 +75,7 @@ {"type":"tool/call","seq":73,"time":1783962505993,"data":{"turn":1,"step":1,"callId":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} {"type":"hook/invoked","seq":74,"time":1783962506001,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":75,"time":1783962506011,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: retry once","durationMs":9.922291999999743}} -{"type":"tool/result","seq":76,"time":1783962506011,"data":{"turn":1,"step":1,"callId":"call_00_VAByyMjsct4c7P6k1ysX9256","content":[{"type":"text","text":"tool output rejected by policy: retry once"}],"isError":true},"sourceEventSeqs":[73],"surfaceOp":"append"} +{"type":"tool/result","seq":76,"time":1783962506011,"data":{"turn":1,"step":1,"callId":"call_00_VAByyMjsct4c7P6k1ysX9256","content":[{"type":"text","text":"tool output rejected by policy: retry once"}],"isError":true,"error":{"message":"tool output rejected by policy: retry once"}},"sourceEventSeqs":[73],"surfaceOp":"append"} {"type":"step/end","seq":77,"time":1783962506012,"data":{"turn":1,"step":1}} {"type":"step/start","seq":78,"time":1783962506012,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":79,"time":1783962507038,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 247e13a075..1309b5c30d 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -57,7 +57,7 @@ {"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} {"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} {"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","outcome":"rejected"}} -{"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true,"error":{"message":"the user rejected tool \"bash\""}},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":61,"time":1783352173584,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl index 4193c7fe80..869fca1eca 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl @@ -55,7 +55,7 @@ {"type":"tool/call","seq":53,"time":1783352166514,"data":{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352166515,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352166528,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session","durationMs":12.367074000000684}} -{"type":"tool/result","seq":56,"time":1783352166528,"data":{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","content":[{"type":"text","text":"Error: bash is disabled by policy in this session"}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"tool/result","seq":56,"time":1783352166528,"data":{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","content":[{"type":"text","text":"Error: bash is disabled by policy in this session"}],"isError":true,"error":{"message":"bash is disabled by policy in this session"}},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":57,"time":1783352166529,"data":{"turn":1,"step":1}} {"type":"step/start","seq":58,"time":1783352166529,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":59,"time":1783352167307,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl index dc68891c14..4a05a2daa7 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl @@ -66,7 +66,7 @@ {"type":"tool/call","seq":64,"time":1783986963664,"data":{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} {"type":"hook/invoked","seq":65,"time":1783986963673,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":66,"time":1783986963677,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":4.42941699999983}} -{"type":"tool/result","seq":67,"time":1783986963678,"data":{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[64],"surfaceOp":"append"} +{"type":"tool/result","seq":67,"time":1783986963678,"data":{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true,"error":{"message":"tool output rejected by codex policy: summarize instead"}},"sourceEventSeqs":[64],"surfaceOp":"append"} {"type":"step/end","seq":68,"time":1783986963678,"data":{"turn":1,"step":1}} {"type":"step/start","seq":69,"time":1783986963679,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":70,"time":1783986964555,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl index c2675ae3af..eb928d7dff 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl @@ -55,7 +55,7 @@ {"type":"tool/call","seq":53,"time":1783352215804,"data":{"turn":1,"step":1,"callId":"call_00_tv0SMeLXaTuyuVrOxnV97085","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352215805,"data":{"turn":1,"point":"PreToolUse","dialect":"codex","handlerId":"codex:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352215832,"data":{"turn":1,"point":"PreToolUse","handlerId":"codex:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by codex policy in this session","durationMs":26.08518500000082}} -{"type":"tool/result","seq":56,"time":1783352215832,"data":{"turn":1,"step":1,"callId":"call_00_tv0SMeLXaTuyuVrOxnV97085","content":[{"type":"text","text":"Error: bash is disabled by codex policy in this session"}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"tool/result","seq":56,"time":1783352215832,"data":{"turn":1,"step":1,"callId":"call_00_tv0SMeLXaTuyuVrOxnV97085","content":[{"type":"text","text":"Error: bash is disabled by codex policy in this session"}],"isError":true,"error":{"message":"bash is disabled by codex policy in this session"}},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":57,"time":1783352215833,"data":{"turn":1,"step":1}} {"type":"step/start","seq":58,"time":1783352215834,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":59,"time":1783352216779,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl index 7b36136970..ed8f566219 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl @@ -10,7 +10,7 @@ {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":1784540790335,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":1784540790335,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}} -{"type":"tool/result","seq":11,"time":1784540790337,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","content":[{"type":"text","text":"Error: subagent depth 3 exceeds maxDepth 2"}],"isError":true},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":1784540790337,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","content":[{"type":"text","text":"Error: subagent depth 3 exceeds maxDepth 2"}],"isError":true,"error":{"message":"subagent depth 3 exceeds maxDepth 2"}},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1784540790338,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":1784540790338,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 258967fad9..a7eb37f9e3 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -49,6 +49,8 @@ The overlay is computed from the current `ToolExecution` and passed through the Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`. +The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The Native renderer preserves the text above, including exactly `started background task `; programmatic consumers use the typed fields without parsing those strings. Executor stream caps remain acquisition limits on `BashRunResult` and carry their spill paths. + When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. ## UI presentation diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 6098056fb9..6ee2fc2b99 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -22,7 +22,7 @@ import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' -import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' +import type { BashRunResult, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home' import { processOutcome } from './background.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' @@ -311,6 +311,38 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent return modelWorkdir } +/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */ +function canonicalBashResult(result: BashRunResult) { + const output = (stream: BashRunResult['stdout']) => ({ + text: stream.text, + truncated: stream.truncated, + ...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {}, + }) + return { + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + aborted: result.aborted, + timeoutMs: result.timeoutMs, + stdout: output(result.stdout), + stderr: output(result.stderr), + ...result.sandbox !== undefined ? { + sandbox: { + mode: result.sandbox.mode, + denied: result.sandbox.denied, + ...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {}, + ...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {}, + }, + } : {}, + } +} + +/** Canonical background-handle properties shared by the bash output union. */ +const BACKGROUND_OUTPUT_PROPERTIES = { + kind: { type: 'string', required: true, const: 'background' }, + taskId: { type: 'string', required: true }, +} as const + export function apply(ctx: Context, config: Config = {}): void { const bashEnv = new BashEnvRegistry(ctx, config) bashEnv.register({ @@ -398,6 +430,65 @@ export function apply(ctx: Context, config: Config = {}): void { }, } : {}, }, + output: { + schema: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: BACKGROUND_OUTPUT_PROPERTIES, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'foreground' }, + exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, + signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, + timedOut: { type: 'boolean', required: true }, + aborted: { type: 'boolean', required: true }, + timeoutMs: { type: 'number', required: true }, + stdout: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + stderr: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + sandbox: { + type: 'object', + additionalProperties: false, + properties: { + mode: { type: 'string', required: true }, + denied: { type: 'boolean', required: true }, + enforcement: { type: 'string' }, + runnerFailed: { type: 'boolean' }, + }, + }, + }, + }, + ], + }, + render: (_args, value) => [{ + type: 'text', + text: value.kind === 'background' + ? `started background task ${value.taskId}` + : renderResult(value as { kind: 'foreground' } & BashRunResult, escalationModes), + }], + }, async execute(args: BashToolArgs, exec) { validateBashArgs(args) // Description is display metadata; workdir defaults to the caller's session. @@ -438,14 +529,14 @@ export function apply(ctx: Context, config: Config = {}): void { } }, }) - return [{ type: 'text', text: `started background task ${id}` }] + return { kind: 'background' as const, taskId: id } } const result = await ctx.bash.run(ctx.bash.resolve({ ...request, ...exec.signal ? { signal: exec.signal } : {}, })) if (result.aborted) throw new Error('command aborted') - return [{ type: 'text', text: renderResult(result, escalationModes) }] + return { kind: 'foreground' as const, ...canonicalBashResult(result) } }, presentCall: presentBashCall, presentResult: presentBashResult, diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index b2a78ff9fe..0158f5f908 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -119,7 +119,13 @@ class RecordingSandboxExecutor extends BashExecutor { timeoutMs: spec.timeoutMs, stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false }, - sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false }, + sandbox: { + mode: spec.sandboxMode ?? 'read-only', + denied: false, + ...spec.command === 'without optional sandbox facts' + ? {} + : { enforcement: 'full' as const, runnerFailed: false }, + }, }) } @@ -204,6 +210,16 @@ describe('bash tool', () => { const ctx = await setup() const result = await call(ctx, 'bash', { command: 'echo hello', description: 'test command' }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected bash success') + expect(result.value).toMatchObject({ + kind: 'foreground', + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + stdout: { text: 'hello\n', truncated: false }, + stderr: { text: '', truncated: false }, + }) expect(text(result)).toBe('hello\n') }) @@ -399,6 +415,8 @@ describe('background execution through the task runtime', () => { const ctx = await setupWithTasks() const started = await call(ctx, 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }) expect(started.isError).toBe(false) + if (started.isError) throw new Error('expected background bash success') + expect(started.value).toEqual({ kind: 'background', taskId: 'bash-1' }) expect(text(started)).toBe('started background task bash-1') const read = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, 'bg-ok') @@ -606,6 +624,22 @@ describe('sandbox escalation through the generic task producer', () => { expect(bash.modes).toEqual(['workspace-write', 'danger-full-access']) }) + it('omits sandbox facts the executor did not acquire from the canonical result', async () => { + const { ctx } = await setupSandboxed() + const result = await call(ctx, 'bash', { + command: 'without optional sandbox facts', + description: 'exercise optional sandbox facts', + }) + + if (result.isError) throw new Error('expected foreground bash success') + expect(result.value).toMatchObject({ + kind: 'foreground', + sandbox: { mode: 'read-only', denied: false }, + }) + expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement') + expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed') + }) + it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => { const { ctx } = await setupSandboxed(true) ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index e899979f46..273bfb3987 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -4,7 +4,7 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -111,7 +111,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeterService, { contextWindow: 400 }) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'work', description: 'does work', parameters: { i: { type: 'number' } }, diff --git a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts index bc382c8e4e..0d3478b1c0 100644 --- a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts @@ -151,7 +151,7 @@ describe('ToolResultPruneService session transaction', () => { text: 'x'.repeat(100), }], { isError: true, - error: { name: 'ExitError', code: 'EXIT_1' }, + error: { message: 'exit 1', info: { name: 'ExitError', code: 'EXIT_1' } }, meta: { diff: ['a', 'b'] }, futureField: { nested: true }, }) @@ -180,7 +180,7 @@ describe('ToolResultPruneService session transaction', () => { step: 1, callId: CallId('one'), isError: true, - error: { name: 'ExitError', code: 'EXIT_1' }, + error: { message: 'exit 1', info: { name: 'ExitError', code: 'EXIT_1' } }, meta: { diff: ['a', 'b'] }, futureField: { nested: true }, }, diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 06ae13818d..d69a7bb2ed 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -6,7 +6,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as timeContext from '@deepseek-ai/dsh-time-context' @@ -387,7 +387,7 @@ describe('real agent-loop request history', () => { it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => { const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')]) const ctx = await loopHarness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'tick', description: 'advance fake time', parameters: {}, diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 21ef979459..77cef391b6 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -128,8 +128,7 @@ export function apply(ctx: Context, config: Config): void { if (update === undefined) return downstream pendingVersionUpdates.set(exec.token, update.versionUpdates) return { - kind: 'accept', - ...downstream.content !== undefined ? { content: downstream.content } : {}, + ...downstream, additionalContexts: [update.context, ...downstream.additionalContexts ?? []], } }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 8b0320bcfc..8d1fa4a339 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -22,7 +22,7 @@ import type { } from '@deepseek-ai/dsh-fs' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { @@ -800,6 +800,7 @@ describe('workspace context request injection', () => { agent: stubAgent('/virtual/repo'), }), { isError: false, + value: null, content: [{ type: 'text', text: 'file content' }], }, async () => ({ kind: 'accept', @@ -835,7 +836,8 @@ describe('workspace context request injection', () => { agent, }) const result = { - isError: false, + isError: false as const, + value: null, content: [{ type: 'text' as const, text: 'hello' }], } @@ -1589,7 +1591,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'abort_step', description: 'Abort the current test step.', parameters: {}, @@ -1660,6 +1662,7 @@ describe('dynamic nested workspace context injection', () => { const pending = ctx.waterfall('tools/post-execute', exec, { content: [{ type: 'text', text: 'ok' }], isError: false, + value: null, }, () => Promise.resolve({ kind: 'accept' as const })) await expect(pending).rejects.toBe(reason) @@ -2380,7 +2383,8 @@ describe('dynamic nested workspace context injection', () => { const result = { callId: CallId('provider-probe-result'), content: [{ type: 'text' as const, text: 'ok' }], - isError: false, + isError: false as const, + value: null, } const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ @@ -2429,7 +2433,7 @@ describe('dynamic nested workspace context injection', () => { } }) - it('preserves nested and downstream post-execute contexts as separate entries', async () => { + it('preserves a downstream canonical value replacement and keeps contexts separate', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -2440,7 +2444,12 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, - content: [{ type: 'text' as const, text: 'downstream replacement' }], + value: { + path: 'pkg/deep/file.txt', + offset: 1, + lines: [{ number: 1, text: 'downstream replacement' }], + totalLines: 1, + }, additionalContexts: [{ content: [{ type: 'text' as const, text: 'downstream context' }], source: { kind: 'plugin' as const, plugin: 'downstream' }, @@ -2454,7 +2463,15 @@ describe('dynamic nested workspace context injection', () => { agent: stubAgent(root), }) - expect(blocksText(result.content)).toBe('downstream replacement') + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected read replacement success') + expect(result.value).toEqual({ + path: 'pkg/deep/file.txt', + offset: 1, + lines: [{ number: 1, text: 'downstream replacement' }], + totalLines: 1, + }) + expect(blocksText(result.content)).toContain('downstream replacement') expect(result.additionalContexts).toHaveLength(2) expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) expect(workspaceContextOf(result)?.meta).toMatchObject({ @@ -2568,7 +2585,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'composite-read', description: 'read through a nested dispatch', parameters: {}, @@ -2620,7 +2637,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const agent = stubAgent('/') const parent = Symbol('parent') as ToolExecutionToken - const plainResult = { callId: CallId('plain'), content: [], isError: false } + const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null } ctx.emit('tools/result', stubToolExecution({ callId: CallId('agentless-child'), name: 'read', arguments: {}, parent, @@ -2658,7 +2675,8 @@ describe('dynamic nested workspace context injection', () => { const result = { callId: CallId('manual'), content: [{ type: 'text' as const, text: 'manual result' }], - isError: false, + isError: false as const, + value: null, } const cases = [ { name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, agent: undefined }, diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index 7819cb8c2c..51a6850fad 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -10,6 +10,8 @@ The self-referential cordis toolset: three model-facing tools over the live runt Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md). +Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native renderers preserve the existing prose, so programs can use `mounted.id` while ordinary function calling still sees `mounted dyn-1 (...)`. + ## Trust stance The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 23ffa88923..e8ddc86f83 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1450,7 +1450,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n /* …truncated — full shape in source */', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n message: string;\n info?: {\n name: string;\n code: string;\n };\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n content: Content /* …truncated — full shape in source */', }, { name: 'SessionEventReadRequest', @@ -1674,20 +1674,20 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolDefinition', - declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', + declaration: 'export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', }, { name: 'ToolErrorInfo', declaration: 'export interface ToolErrorInfo {\n name: string;\n code: string;\n}', }, - { - name: 'ToolExecuteReturn', - declaration: 'export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n};', - }, { name: 'ToolExecution', declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n}', }, + { + name: 'ToolExecutionFailure', + declaration: 'export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}', + }, { name: 'ToolExecutionInput', declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n}', @@ -1698,16 +1698,28 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionResult', - declaration: 'export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n}', + declaration: 'export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;', + }, + { + name: 'ToolExecutionSuccess', + declaration: 'export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}', }, { name: 'ToolExecutionToken', declaration: 'export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n};', }, + { + name: 'ToolFailure', + declaration: 'export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n}', + }, { name: 'ToolGuard', declaration: 'export type ToolGuard = (execution: Readonly) => string | undefined;', }, + { + name: 'ToolOutputDefinition', + declaration: 'export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n}', + }, { name: 'ToolProviderResult', declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n}', @@ -1718,7 +1730,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolResult', - declaration: 'export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n}', + declaration: 'export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n}', }, { name: 'ToolResultBlock', diff --git a/packages/cordis/tool-cordis/src/fiber-state.ts b/packages/cordis/tool-cordis/src/fiber-state.ts index 8c9f0e2f45..dcd9da149b 100644 --- a/packages/cordis/tool-cordis/src/fiber-state.ts +++ b/packages/cordis/tool-cordis/src/fiber-state.ts @@ -21,11 +21,11 @@ export const FiberState = { export type FiberState = FiberStateEnum /** Human-readable label for each {@link FiberState}, keyed by member (inlining-safe — no reverse mapping). */ -export const STATE_LABELS: Record = { +export const STATE_LABELS = { [FiberState.PENDING]: 'pending', [FiberState.LOADING]: 'loading', [FiberState.ACTIVE]: 'active', [FiberState.FAILED]: 'failed', [FiberState.DISPOSED]: 'disposed', [FiberState.UNLOADING]: 'unloading', -} +} as const satisfies Record diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index c55fd34b78..47cb8a7a53 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -6,8 +6,8 @@ * return values with. The façade is a whitelist of lifecycle-safe verbs and declared services; * framework internals and context-valued service returns are denied. * - * VM-realm schemas are rebuilt as host objects, and tool results are JSON-round-tripped and - * shape-checked before session logging. Common JSON-Schema spellings are normalized when they + * VM-realm schemas and canonical values are rebuilt as host objects, while rendered content and + * presentation metadata are shape-checked before entering the registry. Common JSON-Schema spellings are normalized when they * have one meaning; invalid vocabulary fails during registration with a teaching error. * @module @deepseek-ai/dsh-tool-cordis/guard */ @@ -16,7 +16,9 @@ import { Context } from 'cordis' import type { Plugin } from 'cordis' import { scopeOf } from '@deepseek-ai/dsh-scope' import { assertSupportedJsonSchema, defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools' +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { JsonValue } from '@deepseek-ai/dsh-session' const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool') const SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array', 'json']) @@ -254,34 +256,23 @@ const RETURN_PREVIEW_LIMIT = 120 * (`String(…)` for the un-stringifiable undefined case), truncated to * {@link RETURN_PREVIEW_LIMIT}. */ -function describeReturn(value: unknown): string { - // JSON.stringify is TYPED as always returning string, but it yields - // undefined for an undefined input (the routed forgot-return case) — the - // assertion widens the type back to the runtime truth. - const json = JSON.stringify(value) as string | undefined - if (json === undefined) return String(value) +function describeReturn(value: JsonValue): string { + // The caller has already crossed cloneJson, so this value is lossless JSON + // and serialization cannot produce undefined. + const json = JSON.stringify(value) return json.length > RETURN_PREVIEW_LIMIT ? `${json.slice(0, RETURN_PREVIEW_LIMIT)}…` : json } /** - * Validate a round-tripped `execute` return against the two shapes - * {@link ToolExecuteReturn} allows: an ARRAY of content blocks, or - * `{ content: blocks, meta? }`. The registry trusts the shape blindly — it - * spreads `result.content`, so an unvalidated `{ content: 'ok' }` would enter - * the session log as `['o','k']` and silently corrupt the next model request — - * so a wrong shape fails THIS call with a teaching error instead. + * Validate and host-materialize a sandbox renderer's content blocks. */ -function assertExecuteReturn(value: unknown): ToolExecuteReturn { +function assertRenderedContent(value: JsonValue): ContentBlock[] { if (Array.isArray(value) && value.every(isContentBlockShape)) { - return value as ToolExecuteReturn - } - if (isPlainRecord(value) && Array.isArray(value.content) && value.content.every(isContentBlockShape)) { - return value as ToolExecuteReturn + return value as unknown as ContentBlock[] } throw new Error( - `execute returned ${describeReturn(value)} — a tool's execute must return an ARRAY of content blocks, never a bare string:\n` - + ' ✓ return [{ type: \'text\', text: someString }]\n' - + ' ✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }', + `output.render returned ${describeReturn(value)} — it must return an ARRAY of content blocks:\n` + + ' ✓ return [{ type: \'text\', text: String(value) }]', ) } @@ -294,23 +285,46 @@ function assertExecuteReturn(value: unknown): ToolExecuteReturn { * @param options - the standard `defineTool` options; `parameters` may be the ParameterSchemaSpec DSL or a JSON-Schema-style wrapper. * @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts. */ -export function sandboxDefineTool(options: Parameters[0]): ToolDefinition { - const normalized = normalizeParameterSchemaSpec((options as { parameters?: unknown }).parameters) - const tool = defineTool({ ...options, parameters: normalized.spec } as Parameters[0]) +export function sandboxDefineTool(options: unknown): ToolDefinition { + if (!isPlainRecord(options)) throw new Error('harness.defineTool options must be an object') + const normalized = normalizeParameterSchemaSpec(options.parameters) + if (!isPlainRecord(options.output)) { + throw new Error('harness.defineTool output must declare { schema, render, presentationMeta? }') + } + const output = options.output + if (typeof output.render !== 'function') throw new Error('harness.defineTool output.render must be a function') + if (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function') { + throw new Error('harness.defineTool output.presentationMeta must be a function when present') + } + if (typeof options.execute !== 'function') throw new Error('harness.defineTool execute must be a function') + const schema = normalizeValueSchema(output.schema, 'output.schema') + const rawExecute = options.execute as (args: unknown, exec: unknown) => Promise + const rawRender = output.render as (args: unknown, value: unknown) => unknown + const rawPresentationMeta = output.presentationMeta as ((args: unknown, value: unknown) => unknown) | undefined + const erasedDefineTool = defineTool as unknown as (definition: unknown) => ToolDefinition + const tool = erasedDefineTool({ + ...options, + parameters: normalized.spec, + output: { + schema, + render(args: unknown, value: unknown): ContentBlock[] { + return assertRenderedContent(cloneJson(rawRender(args, value), 'output.render result') as JsonValue) + }, + ...rawPresentationMeta !== undefined ? { + presentationMeta(args: unknown, value: unknown): JsonValue { + return cloneJson(rawPresentationMeta(args, value), 'output.presentationMeta result') as JsonValue + }, + } : {}, + }, + async execute(args: unknown, exec: unknown): Promise { + return cloneJson(await rawExecute(args, exec), 'execute result') as JsonValue + }, + }) const parameters = { ...tool.parameters, ...normalized.rootAnnotations } assertSupportedJsonSchema(parameters) - const execute = tool.execute.bind(tool) return markDynamicTool({ ...tool, parameters, - async execute(args, exec) { - // JSON.stringify yields NO JSON for an undefined (or function/symbol) - // return despite its string-typed signature — route that into - // assertExecuteReturn's teaching error rather than letting JSON.parse - // throw its cryptic '"undefined" is not valid JSON'. - const json = JSON.stringify(await execute(args, exec)) as string | undefined - return assertExecuteReturn(json === undefined ? undefined : JSON.parse(json) as unknown) - }, }) } diff --git a/packages/cordis/tool-cordis/src/index.ts b/packages/cordis/tool-cordis/src/index.ts index 9ac29aeb07..15fd735468 100644 --- a/packages/cordis/tool-cordis/src/index.ts +++ b/packages/cordis/tool-cordis/src/index.ts @@ -13,7 +13,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import { STATE_LABELS } from './fiber-state.ts' import { isPlugin, pluginName } from './guard.ts' import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts' -import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools } from './inspect.ts' +import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools, providedServices } from './inspect.ts' import { missingServices, mountDynamic, type DynamicMount } from './mount.ts' import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts' import { createSandbox, evaluateMountCode } from './sandbox.ts' @@ -76,7 +76,11 @@ export function apply(ctx: Context, config: Config): void { description: 'Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events".', }, }, - execute(args, exec): Promise<{ type: 'text'; text: string }[]> { + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + execute(args, exec): Promise { if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') { throw new Error('name is valid only with what:"api" or what:"events"') } @@ -94,7 +98,7 @@ export function apply(ctx: Context, config: Config): void { const text = selected .map(([heading, body]) => `## ${heading}\n${body().join('\n')}`) .join('\n\n') - return Promise.resolve([{ type: 'text', text }]) + return Promise.resolve(text) }, presentCall: presentInspectCall, })) @@ -119,13 +123,14 @@ export function apply(ctx: Context, config: Config): void { + 'Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe ' + 'events (see cordis_inspect what:"events"), or call ' + '`harness.registerTool(ctx, harness.defineTool({ name, description, parameters: ' - + '{ text: { type: \'string\', required: true } }, async execute(args) { … } }))` ' + + '{ text: { type: \'string\', required: true } }, output: { schema: { type: \'string\' }, ' + + 'render(_args, value) { return [{ type: \'text\', text: value }] } }, async execute(args) { return args.text } }))` ' + 'to give yourself a new tool — it becomes callable on your NEXT step. ' + 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'integer\'|\'boolean\'|\'null\'|\'object\'|\'array\'|\'json\', ' + 'required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and ' + 'oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: \'object\', properties, required?: […] } wrapper is also accepted with open-by-default objects. A ' - + 'tool\'s `execute` MUST return an ARRAY of content blocks, e.g. `return ' - + '[{ type: \'text\', text: someString }]` — never a bare string. ' + + 'tool\'s `execute` MUST return the lossless JSON value declared by `output.schema`; ' + + '`output.render(args, value)` separately returns Native/model content blocks. ' + 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and ' + 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending ' + 'until the provider exists and returns to pending when the provider is unmounted. ' @@ -157,6 +162,32 @@ export function apply(ctx: Context, config: Config): void { description: 'Body of an async JS function; must `return` the plugin to mount.', }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + id: { type: 'string', required: true }, + pluginName: { type: 'string', required: true }, + state: { + type: 'string', + required: true, + enum: ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'], + }, + provides: { type: 'array', required: true, items: { type: 'string' } }, + waitingFor: { type: 'array', required: true, items: { type: 'string' } }, + }, + }, + render: (_args, value) => { + const note = value.waitingFor.length > 0 + ? ` — waiting for service(s): ${value.waitingFor.join(', ')} (activates when provided)` + : '' + return [{ + type: 'text', + text: `mounted ${value.id} (plugin "${value.pluginName}", state: ${value.state}${note})`, + }] + }, + }, async execute(args) { const id = `dyn-${nextId++}` const sandbox = createSandbox(id) @@ -180,10 +211,13 @@ export function apply(ctx: Context, config: Config): void { // it mounted but tell the model what it is waiting for. const missing = missingServices(ctx, fiber) const state = STATE_LABELS[fiber.state] - const note = missing.length > 0 - ? ` — waiting for service(s): ${missing.join(', ')} (activates when provided)` - : '' - return [{ type: 'text', text: `mounted ${id} (plugin "${pluginName(evaluated)}", state: ${state}${note})` }] + return { + id, + pluginName: pluginName(evaluated), + state, + provides: providedServices(ctx, fiber), + waitingFor: missing, + } }, presentCall: presentMountCall, })) @@ -202,6 +236,17 @@ export function apply(ctx: Context, config: Config): void { description: 'The dynamic mount id returned by cordis_mount (e.g. "dyn-1").', }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + id: { type: 'string', required: true }, + pluginName: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: `unmounted ${value.id} (plugin "${value.pluginName}")` }], + }, async execute(args) { const mount = mounts.get(args.id) if (!mount) { @@ -209,7 +254,7 @@ export function apply(ctx: Context, config: Config): void { } await mount.fiber.dispose() mounts.delete(args.id) - return [{ type: 'text', text: `unmounted ${args.id} (plugin "${mount.pluginName}")` }] + return { id: args.id, pluginName: mount.pluginName } }, presentCall: presentUnmountCall, })) diff --git a/packages/cordis/tool-cordis/src/inspect.ts b/packages/cordis/tool-cordis/src/inspect.ts index 7196f370ce..cdcad29699 100644 --- a/packages/cordis/tool-cordis/src/inspect.ts +++ b/packages/cordis/tool-cordis/src/inspect.ts @@ -33,8 +33,13 @@ function withinFiber(fiber: Fiber, root: Fiber): boolean { } } -/** The service names provided by a mount's fiber subtree, sorted. */ -function providedBy(ctx: Context, fiber: Fiber): string[] { +/** + * Return the service names provided by a mount's fiber subtree. + * @param ctx - the runtime whose service registrations are inspected. + * @param fiber - the root of the mounted fiber subtree. + * @returns the provided service names in lexical order. + */ +export function providedServices(ctx: Context, fiber: Fiber): string[] { return liveImpls(ctx) .filter(impl => withinFiber(impl.fiber, fiber)) .map(impl => impl.name) @@ -96,7 +101,7 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] { export function describeDynamic(ctx: Context, mounts: ReadonlyMap): string[] { if (mounts.size === 0) return ['(no dynamic plugins mounted)'] return [...mounts].map(([id, mount]) => { - const provides = providedBy(ctx, mount.fiber) + const provides = providedServices(ctx, mount.fiber) const waiting = missingServices(ctx, mount.fiber) const providesNote = provides.length > 0 ? ` — provides: ${provides.join(', ')}` : '' const waitingNote = waiting.length > 0 ? ` — waiting for: ${waiting.join(', ')}` : '' diff --git a/packages/cordis/tool-cordis/tests/cross-mount.spec.ts b/packages/cordis/tool-cordis/tests/cross-mount.spec.ts index dcfdb815c4..b2e515b4c8 100644 --- a/packages/cordis/tool-cordis/tests/cross-mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/cross-mount.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { call, CONSUMER_CODE, PROVIDER_CODE, setup, text } from './helpers.ts' +import { call, CONSUMER_CODE, CONTENT_OUTPUT_CODE, PROVIDER_CODE, setup, text } from './helpers.ts' /** * Cross-mount composition through ordinary cordis provide/inject semantics: @@ -116,6 +116,7 @@ describe('cross-mount provide/inject', () => { name: 'answer', description: 'Read the provided primitive services.', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { return [{ type: 'text', text: ctx.answer + '/' + ctx.get('answer') + '/' + ctx.nothing }] }, diff --git a/packages/cordis/tool-cordis/tests/helpers.ts b/packages/cordis/tool-cordis/tests/helpers.ts index b183a2444f..6a681b93c6 100644 --- a/packages/cordis/tool-cordis/tests/helpers.ts +++ b/packages/cordis/tool-cordis/tests/helpers.ts @@ -45,6 +45,13 @@ export const LISTENER_CODE = ` } ` +/** Explicit content-array output declaration for dynamic-tool behavior fixtures. */ +export const CONTENT_OUTPUT_CODE = ` + output: { + schema: { type: 'array', items: { type: 'json' } }, + render(_args, value) { return value }, + },` + /** Mount code for a self-made tool: registers `reverse_text` via the sandbox's harness helpers. */ export const REVERSE_TOOL_CODE = ` return { @@ -55,8 +62,14 @@ export const REVERSE_TOOL_CODE = ` name: 'reverse_text', description: 'Reverse a string.', parameters: { text: { type: 'string', required: true } }, + output: { + schema: { type: 'string' }, + render(_args, value) { + return [{ type: 'text', text: value }] + }, + }, async execute(args) { - return [{ type: 'text', text: args.text.split('').reverse().join('') }] + return args.text.split('').reverse().join('') }, })) }, @@ -83,8 +96,14 @@ export const CONSUMER_CODE = ` name: 'greet', description: 'Greet someone via the greeter service.', parameters: { name: { type: 'string', required: true } }, + output: { + schema: { type: 'string' }, + render(_args, value) { + return [{ type: 'text', text: value }] + }, + }, async execute(args) { - return [{ type: 'text', text: ctx.greeter.greet(args.name) }] + return ctx.greeter.greet(args.name) }, })) }, @@ -97,8 +116,9 @@ export function dummyTool(name: string): ToolDefinition { name, description: 'test trigger', parameters: { type: 'object' as const, properties: {} }, - async execute(): Promise<[]> { - return [] + output: { schema: { type: 'null' }, render: () => [] }, + async execute(): Promise { + return null }, } } diff --git a/packages/cordis/tool-cordis/tests/inspect.spec.ts b/packages/cordis/tool-cordis/tests/inspect.spec.ts index 4d986d4f3e..8eaa154535 100644 --- a/packages/cordis/tool-cordis/tests/inspect.spec.ts +++ b/packages/cordis/tool-cordis/tests/inspect.spec.ts @@ -16,6 +16,8 @@ describe('cordis_inspect', () => { const result = await call(ctx, 'cordis_inspect', {}) expect(result.isError).toBe(false) const report = text(result) + if (result.isError) throw new Error('expected cordis_inspect success') + expect(result.value).toBe(report) for (const heading of ['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) { expect(report).toContain(`## ${heading}`) } diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index 42af6fc64e..6d98cb33a3 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { isJsonValue } from '@deepseek-ai/dsh-session' +import { sandboxDefineTool } from '../src/guard.ts' import { syntaxErrorContext } from '../src/sandbox.ts' -import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts' +import { call, CONTENT_OUTPUT_CODE, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts' /** * The `cordis_mount` success/failure family: real plugins land on a genuine @@ -14,12 +15,48 @@ afterEach(() => { }) describe('cordis_mount', () => { + it.each([ + [42, 'options must be an object'], + [{ parameters: {} }, 'output must declare { schema, render, presentationMeta? }'], + [{ parameters: {}, output: { schema: { type: 'json' } }, execute: async (): Promise => null }, 'output.render must be a function'], + [{ parameters: {}, output: { schema: { type: 'json' }, render: () => [] }, execute: true }, 'execute must be a function'], + [{ + parameters: {}, + output: { schema: { type: 'json' }, render: () => [], presentationMeta: true }, + execute: async (): Promise => null, + }, 'output.presentationMeta must be a function'], + ])('rejects an invalid dynamic tool declaration before registration: %j', (definition, message) => { + expect(() => sandboxDefineTool(definition)).toThrow(message) + }) + + it('bounds the preview of an invalid dynamic renderer return', () => { + const definition = sandboxDefineTool({ + name: 'invalid-renderer', + description: 'invalid renderer', + parameters: {}, + output: { + schema: { type: 'string' }, + render: () => ['x'.repeat(500)], + }, + execute: async () => 'ok', + }) + expect(() => definition.output.render({}, 'ok')).toThrow(/output\.render returned \["x+…/) + }) + it('mounts a listener plugin that observes real events, tagged-logging through to the host console', async () => { const ctx = await setup() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const result = await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected cordis_mount success') + expect(result.value).toEqual({ + id: 'dyn-1', + pluginName: 'change-logger', + state: 'active', + provides: [], + waitingFor: [], + }) expect(text(result)).toContain('mounted dyn-1 (plugin "change-logger", state: active)') // Fire a REAL tools/change by registering a tool; the mounted listener logs. @@ -44,6 +81,8 @@ describe('cordis_mount', () => { expect(ctx.tools.schemas().map(schema => schema.name)).toContain('reverse_text') const reversed = await call(ctx, 'reverse_text', { text: 'harness' }) expect(reversed.isError).toBe(false) + if (reversed.isError) throw new Error('expected dynamic tool success') + expect(reversed.value).toBe('ssenrah') expect(text(reversed)).toBe('ssenrah') }) @@ -55,7 +94,7 @@ describe('cordis_mount', () => { expect(isJsonValue({ content: reversed.content, isError: reversed.isError })).toBe(true) }) - it('threads the { content, meta } object return form through to the registry result', async () => { + it('projects presentation metadata from a dynamic canonical value', async () => { const ctx = await setup() await call(ctx, 'cordis_mount', { code: ` @@ -67,8 +106,13 @@ describe('cordis_mount', () => { name: 'meta_tool', description: 'attaches a private presentation payload', parameters: {}, + output: { + schema: { type: 'string' }, + render(_args, value) { return [{ type: 'text', text: value }] }, + presentationMeta() { return { kind: 'demo' } }, + }, async execute() { - return { content: [{ type: 'text', text: 'ok' }], meta: { kind: 'demo' } } + return 'ok' }, })) }, @@ -77,20 +121,20 @@ describe('cordis_mount', () => { }) const result = await call(ctx, 'meta_tool', {}) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected dynamic tool success') + expect(result.value).toBe('ok') expect(text(result)).toBe('ok') expect(result.meta).toEqual({ kind: 'demo' }) }) it.each([ - ['a bare string', 'return \'ok\'', '"ok"'], - ['an object whose content is a string', 'return { content: \'ok\' }', '{"content":"ok"}'], - ['an array of non-objects', 'return [\'ok\']', '["ok"]'], - ['blocks missing the type tag', 'return [{ text: \'hi\' }]', '[{"text":"hi"}]'], - ['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', '{"content":[{"text":"hi"}]}'], - ['undefined — a forgotten return', 'return undefined', 'undefined'], - ])('rejects an execute return of %s as that one call\'s teaching error', async (_label, returnStatement, preview) => { - // The registry spreads result.content, so { content: 'ok' } would become ['o','k']; reject - // it as this call's error before it corrupts the next request. + ['a bare string', 'return \'ok\'', 'returned invalid output: "value" must be an array'], + ['an object whose content is a string', 'return { content: \'ok\' }', 'returned invalid output: "value" must be an array'], + ['an array of non-objects', 'return [\'ok\']', 'output.render returned ["ok"]'], + ['blocks missing the type tag', 'return [{ text: \'hi\' }]', 'output.render returned [{"text":"hi"}]'], + ['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', 'returned invalid output: "value" must be an array'], + ['undefined — a forgotten return', 'return undefined', 'execute result must be lossless JSON data'], + ])('rejects an execute return of %s against its declared output', async (_label, returnStatement, diagnostic) => { const ctx = await setup() await call(ctx, 'cordis_mount', { code: ` @@ -102,6 +146,7 @@ describe('cordis_mount', () => { name: 'bad_return_tool', description: 'returns a wrong shape', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { ${returnStatement} }, })) }, @@ -112,12 +157,10 @@ describe('cordis_mount', () => { expect(result.isError).toBe(true) expect(result.content).toHaveLength(1) expect(result.content[0]!.type).toBe('text') - expect(text(result)).toContain(`execute returned ${preview}`) - expect(text(result)).toContain('must return an ARRAY of content blocks') - expect(text(result)).toContain('✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }') + expect(text(result)).toContain(diagnostic) }) - it('truncates a huge invalid execute return in the teaching error', async () => { + it('does not echo a huge schema-invalid canonical value in the diagnostic', async () => { const ctx = await setup() await call(ctx, 'cordis_mount', { code: ` @@ -129,6 +172,7 @@ describe('cordis_mount', () => { name: 'huge_return_tool', description: 'returns a huge wrong shape', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { return 'x'.repeat(500) }, })) }, @@ -137,7 +181,7 @@ describe('cordis_mount', () => { }) const result = await call(ctx, 'huge_return_tool', {}) expect(result.isError).toBe(true) - expect(text(result)).toContain('…') + expect(text(result)).toContain('returned invalid output') expect(text(result)).not.toContain('x'.repeat(200)) }) @@ -167,6 +211,7 @@ describe('cordis_mount', () => { }, required: ['text'], }, + ${CONTENT_OUTPUT_CODE} async execute(args) { return [{ type: 'text', text: args.text + ':' + (args.count ?? 0) }] }, })) }, @@ -215,6 +260,7 @@ describe('cordis_mount', () => { cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] }, }, }, + ${CONTENT_OUTPUT_CODE} async execute(args) { return [{ type: 'text', text: args.cfg.label }] }, })) }, @@ -254,6 +300,7 @@ describe('cordis_mount', () => { closed: { type: 'object', additionalProperties: false }, count: { type: 'number', enum: [1, 2], const: 1 }, }, + ${CONTENT_OUTPUT_CODE} async execute(args) { return [{ type: 'text', text: String(args.choice) }] }, })) }, @@ -299,6 +346,7 @@ describe('cordis_mount', () => { choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] }, }, }, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, })) }, @@ -356,6 +404,7 @@ describe('cordis_mount', () => { name: 'bad_schema_tool', description: 'bad', ${parameters}, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, })) }, @@ -381,6 +430,7 @@ describe('cordis_mount', () => { item: { type: 'object', additionalProperties: true, required: true, properties: { label: { type: 'string', required: true } } }, tags: { type: 'array', items: { type: 'string' } }, }, + ${CONTENT_OUTPUT_CODE} async execute(args) { return [{ type: 'text', text: args.item.label }] }, })) }, @@ -404,6 +454,7 @@ describe('cordis_mount', () => { name: 'raw_dynamic_tool', description: 'raw', parameters: { type: 'object', properties: {} }, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, }) }, @@ -457,6 +508,14 @@ describe('cordis_mount', () => { code: 'return { name: \'waiter\', inject: [\'no-such-service\'], apply(ctx) {} }', }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected pending cordis_mount success') + expect(result.value).toEqual({ + id: 'dyn-1', + pluginName: 'waiter', + state: 'pending', + provides: [], + waitingFor: ['no-such-service'], + }) expect(text(result)).toContain('state: pending') expect(text(result)).toContain('waiting for service(s): no-such-service') // Unmounting a pending mount works like any other. @@ -517,6 +576,7 @@ describe('cordis_mount', () => { name: 'cordis_mount', description: 'dup', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, })) }, @@ -663,6 +723,7 @@ describe('cordis_mount', () => { name: 'probe_instanceof', description: 'report instanceof checks across realms', parameters: { items: { type: 'array', required: true, items: { type: 'string' } } }, + ${CONTENT_OUTPUT_CODE} async execute(args) { const checks = { hostArray: args.items instanceof Array, diff --git a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts index c27d34d4c3..05a33848c0 100644 --- a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts +++ b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { call, setup, text } from './helpers.ts' +import { call, CONTENT_OUTPUT_CODE, setup, text } from './helpers.ts' /** * The sandbox context façade is a whitelist, not a pass-through proxy. Mounted code reaches only @@ -51,6 +51,7 @@ describe('sandbox context façade — escape surface is closed', () => { name: 'smuggled', description: 'raw, unguarded', parameters: { type: 'object', properties: {} }, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, }) }, @@ -87,6 +88,7 @@ describe('sandbox context façade — escape surface is closed', () => { name: 'smuggled_via_service', description: 'raw, unguarded', parameters: { type: 'object', properties: {} }, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, }) }, @@ -116,6 +118,7 @@ describe('sandbox context façade — escape surface is closed', () => { name: 'do_fetch', description: 'awaits the host async service', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { const value = await ctx.hostAsync.grab() return [{ type: 'text', text: value }] @@ -203,6 +206,7 @@ describe('sandbox context façade — inject gate on services', () => { name: 'greet_undeclared', description: 'uses greeter without declaring it', parameters: { n: { type: 'string', required: true } }, + ${CONTENT_OUTPUT_CODE} async execute(args) { return [{ type: 'text', text: ctx.greeter.greet(args.n) }] }, })) }, @@ -235,6 +239,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => { name: 'report_view', description: 'reports the shape of a tool view', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { const view = ctx.tools.get('cordis_mount') return [{ type: 'text', text: JSON.stringify({ @@ -270,6 +275,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => { name: 'probe_unknown', description: 'reports whether an unknown tool resolves', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { return [{ type: 'text', text: String(ctx.tools.get('no_such_tool') === undefined) }] }, diff --git a/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts b/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts index 718a213968..92e5ee7a66 100644 --- a/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts +++ b/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts @@ -26,6 +26,8 @@ describe('cordis_unmount', () => { const result = await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected cordis_unmount success') + expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger' }) expect(text(result)).toContain('unmounted dyn-1') // Immediately after the awaited unmount, the listener must be gone — no diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index 663dda1b53..61a6d37947 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -219,7 +219,10 @@ function appendSkippedToolCall(session: Session, turn: number, step: number, blo appendToolResult(session, turn, step, block, { content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }], isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { + message: 'tool call skipped because the step was aborted before execution', + info: { name: 'AbortError', code: 'ABORTED' }, + }, }, callSeq) } diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 8e9b951fa1..5ede648726 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -6,7 +6,7 @@ import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' interface Harness { @@ -156,7 +156,7 @@ describe('AgentLoop initiator scope', () => { let parentWhileChildDriverActive: Agent | undefined let child: Agent | undefined - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'spawn-child', description: 'create one child agent', parameters: {}, @@ -168,7 +168,7 @@ describe('AgentLoop initiator scope', () => { setup: (agentCtx) => { parentDuringSetup = ctx.agents.requireInitiator() explicitChild = agentCtx.agent - agentCtx.tools.register(defineTool({ + agentCtx.tools.register(defineContentToolFixture({ name: 'observe-child', description: 'observe child execution identity', parameters: {}, @@ -216,7 +216,7 @@ describe('AgentLoop initiator scope', () => { let directAmbient: Agent | undefined let captured: Agent | undefined - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'agentless-probe', description: 'observe an agentless call', parameters: {}, @@ -226,7 +226,7 @@ describe('AgentLoop initiator scope', () => { return [{ type: 'text', text: 'ok' }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'capability-request', description: 'call the test capability transport', parameters: { path: { type: 'string' } }, diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index f6df171365..301ebbf992 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -12,7 +12,7 @@ import { Context } from 'cordis' import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -366,7 +366,7 @@ describe('Agent.cancel()', () => { ]) const ctx = await harness(adapter) let executions = 0 - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'must not run after cancellation', parameters: {}, @@ -397,7 +397,7 @@ describe('Agent.cancel()', () => { expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({ callId: 'c1', isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) send(agent, 'continue safely') diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 7ed8c2075e..17f12de400 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' @@ -50,7 +50,7 @@ describe('session log records what agent/step-result actually produced', () => { const adapter = new MockAdapter([original, textResponse('done')]) const ctx = await harness(adapter) const executed: string[] = [] - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'injected-tool', description: '', parameters: {}, @@ -219,7 +219,7 @@ describe('abort during tool execution ends the turn', () => { const ctx = await harness(adapter) const executed: string[] = [] const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'aborter', description: '', parameters: {}, @@ -241,7 +241,7 @@ describe('abort during tool execution ends the turn', () => { source: { kind: 'plugin', plugin: 'abort-test' }, }], })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'second', description: '', parameters: {}, @@ -259,7 +259,7 @@ describe('abort during tool execution ends the turn', () => { case 'assistant/message': order.push('assistant/message'); break case 'tool/call': order.push(`tool/call:${event.data.callId}`); break case 'tool/result': { - const outcome = event.data.error?.code === 'ABORTED' ? 'synthetic-aborted' : 'real' + const outcome = event.data.error?.info?.code === 'ABORTED' ? 'synthetic-aborted' : 'real' order.push(`tool/result:${event.data.callId}:${outcome}`) break } @@ -308,7 +308,7 @@ describe('abort during tool execution ends the turn', () => { expect(results[1]!.data).toMatchObject({ callId: CallId('c2'), isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) }) @@ -316,7 +316,7 @@ describe('abort during tool execution ends the turn', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'aborter', description: '', parameters: {}, @@ -362,7 +362,7 @@ describe('abort during tool execution ends the turn', () => { ] satisfies StreamChunk[]]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'first', description: '', parameters: {}, @@ -370,7 +370,7 @@ describe('abort during tool execution ends the turn', () => { return [{ type: 'text', text: 'first done' }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'aborter', description: '', parameters: {}, @@ -411,7 +411,7 @@ describe('abort during tool execution ends the turn', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'waiter', description: '', parameters: {}, @@ -463,7 +463,7 @@ describe('abort during tool execution ends the turn', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'aborter', description: '', parameters: {}, @@ -472,7 +472,7 @@ describe('abort during tool execution ends the turn', () => { return [{ type: 'text', text: 'done' }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'second', description: '', parameters: {}, @@ -771,7 +771,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'noop', description: '', parameters: {}, @@ -837,7 +837,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const agent = ctx.agentLoop.create(SessionId('owned-steer'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const release = Promise.withResolvers() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'gate', description: '', parameters: {}, @@ -1423,7 +1423,7 @@ describe('tool result call identity', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { x: { type: 'number' } }, diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index ac7f301525..65c2258d0b 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -4,7 +4,7 @@ import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -77,7 +77,7 @@ describe('tool JSON parse', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo tool', parameters: { input: { type: 'string' } }, @@ -110,7 +110,7 @@ describe('tool JSON parse', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'noarg', description: 'no-arg tool', parameters: {}, @@ -259,7 +259,7 @@ describe('structured tool error propagation (the runtime-validation Agent Note, ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'boom', description: 'always fails', parameters: {}, @@ -274,6 +274,6 @@ describe('structured tool error propagation (the runtime-validation Agent Note, const toolResult = agent.session.events.find(e => e.type === 'tool/result') expect(toolResult?.type === 'tool/result' && toolResult.data.isError).toBe(true) expect(toolResult?.type === 'tool/result' && toolResult.data.error) - .toEqual({ name: 'HarnessError', code: 'BOOM' }) + .toEqual({ message: 'exploded', info: { name: 'HarnessError', code: 'BOOM' } }) }) }) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 83b156b0ef..3d4a5e67c5 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -347,7 +347,7 @@ describe('agent/session-prefix', () => { textResponse('again'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) @@ -469,7 +469,7 @@ describe('agent/session-prefix', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) @@ -522,7 +522,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { it('a stop decision ends the turn even when the step had tool calls', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' })]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) @@ -552,7 +552,7 @@ describe('tool additionalContexts buffering across a step', () => { ] const adapter = new MockAdapter([twoCalls, textResponse('done')]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) @@ -594,7 +594,7 @@ describe('tool additionalContexts buffering across a step', () => { it('appends multiple contexts deferred by one composite tool after its outer result', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'composite', {}), textResponse('done')]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'composite', description: 'composite', parameters: {}, async execute(_args, exec) { exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } }) @@ -625,7 +625,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('ok')]) const ctx = await harness(adapter) let ran = false - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'danger', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] }, })) @@ -687,7 +687,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done')]) const ctx = await harness(adapter) await ctx.plugin(NativeGuard) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index dd686edfcb..aa3a35bc36 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, TurnEndReason, type JsonValue } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -89,7 +89,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo back', parameters: { text: { type: 'string' } }, @@ -118,22 +118,27 @@ describe('agent loop', () => { const types = agent.session.events.map(e => e.type) expect(types).toContain('tool/call') expect(types).toContain('tool/result') + const durableResult = agent.session.events.find(event => event.type === 'tool/result') + expect(durableResult?.type === 'tool/result' && 'value' in durableResult.data).toBe(false) }) - it('threads a tool-attached meta (execute object return) onto the tool/result event', async () => { + it('persists presentation metadata projected from the canonical value', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'writer', { path: 'a.txt' }, 'writing'), textResponse('done'), ]) const ctx = await harness(adapter) - // A tool that returns the { content, meta } object form: the loop must - // persist `meta` on the tool/result event so a UI reproduces the card on replay. ctx.tools.register(defineTool({ name: 'writer', description: 'writes a file', parameters: { path: { type: 'string' } }, + output: { + schema: { type: 'string' }, + render: () => [{ type: 'text', text: 'ok' }], + presentationMeta: (_args, value) => ({ diffs: [{ path: value, oldText: null, newText: 'x' }] }), + }, async execute() { - return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } } + return 'a.txt' }, })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -152,7 +157,7 @@ describe('agent loop', () => { // projecting this agent's configured model, so the model knows its own name. const ctx = await harness(adapter, 'You are a test agent on {{model}}.') ctx.systemPrompt.section({ name: 'tool:noop', order: 100, text: 'Use the noop tool wisely.' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'noop', description: 'does nothing', parameters: {}, @@ -248,7 +253,7 @@ describe('agent loop', () => { ['BigInt', { n: 1n }], ['Map', new Map([['key', 'value']])], ['class instance', new (class ResultMeta { x = 1 })()], - ])('normalizes non-JSON tool meta (%s) before the durable result commit', async (_kind, meta) => { + ])('normalizes non-JSON presentation metadata (%s) before the durable result commit', async (_kind, meta) => { const adapter = new MockAdapter([ toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'), textResponse('recovered'), @@ -258,7 +263,12 @@ describe('agent loop', () => { name: 'bad-meta', description: 'returns invalid durable metadata', parameters: {}, - execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }), + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + presentationMeta: () => meta as unknown as JsonValue, + }, + execute: () => Promise.resolve('apparent success'), })) const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' }) @@ -326,7 +336,7 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'slow', description: '', parameters: {}, @@ -432,7 +442,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let visibleDuringTool = false const meta = { kind: 'deferred-test', version: 1 } - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'noticer', description: 'injects a notice', parameters: {}, @@ -494,7 +504,7 @@ describe('agent loop', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'invalid-injector', description: 'attempts an invalid context injection', parameters: {}, @@ -541,7 +551,7 @@ describe('agent loop', () => { it('agent/turn-continuation can veto continuation despite tool calls (budget-guard pattern)', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'x' })]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -589,7 +599,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: {}, async execute() { return [{ type: 'text', text: 'echoed' }] }, })) @@ -781,7 +791,7 @@ describe('agent loop', () => { ]]) const ctx = await harness(adapter) let executions = 0 - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -821,7 +831,7 @@ describe('agent loop', () => { { type: 'finish', reason: { kind: 'max-tokens' } }, ]]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -908,7 +918,7 @@ describe('agent loop', () => { textResponse('continued after tool call'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -1240,7 +1250,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 36d88feaa9..f1e1a1a367 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -45,7 +45,7 @@ async function loopHarness(): Promise { await created.plugin(AgentRegistry) await created.plugin(AgentLoop, { agents: [] }) await created.plugin(LlmDeepSeek) - created.tools.register(defineTool({ + created.tools.register(defineContentToolFixture({ name: 'lookup', description: 'Look up the stored value for a key.', parameters: { key: { type: 'string', description: 'The key to look up.' } }, diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 46cfe3eb56..755953c6f7 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -11,7 +11,7 @@ import LlmService from '@deepseek-ai/dsh-llm' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -53,7 +53,7 @@ function expectPrefixExtension(previous: GenerateOptions, current: GenerateOptio } function registerEcho(ctx: Context) { - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo back', parameters: { text: { type: 'string' } }, diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index cf87d376ef..2bdd7e8d33 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -11,7 +11,7 @@ import LlmService, { import type { GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { PostToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -133,7 +133,7 @@ describe('agent post-step and request-error lifecycle', () => { ] const adapter = new FailureScriptAdapter([twoCalls, textResponse('done')]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'work', description: 'do work', parameters: {}, @@ -222,7 +222,7 @@ describe('agent post-step and request-error lifecycle', () => { textResponse('must not continue'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'work', description: 'do work', parameters: {}, @@ -535,7 +535,7 @@ describe('agent post-step and request-error lifecycle', () => { contextError('later overflow'), ]) const resetCtx = await harness(reset) - resetCtx.tools.register(defineTool({ + resetCtx.tools.register(defineContentToolFixture({ name: 'work', description: 'continue', parameters: {}, diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 73ee48f7a6..469a815d8b 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -3,7 +3,7 @@ import { Context, symbols, type EffectMeta, type Fiber } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -172,10 +172,10 @@ describe('agent scope lifecycle', () => { const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } }) const { agent } = handle agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' }) - agent.ctx.tools.register({ + agent.ctx.tools.register(defineContentToolFixture({ name: 'mine', description: 'scoped', parameters: {}, execute: () => Promise.resolve(text('ran')), - }) + })) const scopedAssembly = await ctx.systemPrompt.assemble(assembleContextFor(agent)) expect(scopedAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You run tests.') @@ -587,12 +587,12 @@ describe('agent scope lifecycle', () => { sessionId: SessionId('dependency-origin-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: (agentCtx) => { - agentCtx.tools.register({ + agentCtx.tools.register(defineContentToolFixture({ name: 'dependency-origin-tool', description: 'proves AgentLoop dependency origin', parameters: {}, execute: () => Promise.resolve(text('ok')), - }) + })) agentCtx.systemPrompt.section({ name: 'dependency-origin-section', order: 1, diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index a77e1d678e..e13caa13c5 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -9,7 +9,7 @@ import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import LlmService from '@deepseek-ai/dsh-llm' -import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -61,7 +61,7 @@ function multiCall(calls: { id: string; name: string; args: object }[]): StreamC function gatedTool(name: string, parallel: boolean) { const gates = new Map void>() const started: string[] = [] - const tool = defineTool({ + const tool = defineContentToolFixture({ name, description: `gated ${name}`, parameters: { id: { type: 'string', required: true } }, @@ -123,12 +123,12 @@ describe('tool-call scheduler: grouping and barriers', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'r', description: 'read', parameters: { id: { type: 'string', required: true } }, isConcurrencySafe: () => true, async execute(args) { order.push(`r-start-${args.id}`); order.push(`r-end-${args.id}`); return [{ type: 'text', text: 'r' }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } }, async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] }, })) @@ -150,14 +150,14 @@ describe('tool-call scheduler: grouping and barriers', () => { ]) const ctx = await harness(adapter) const replacement = gatedExclusiveTool('x') - const disposeSafe = ctx.tools.register(defineTool({ + const disposeSafe = ctx.tools.register(defineContentToolFixture({ name: 'x', description: 'initially safe', parameters: { id: { type: 'string', required: true } }, isConcurrencySafe: () => true, async execute(args) { return [{ type: 'text', text: `old-${args.id}` }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'replace', description: 'replace x', parameters: { id: { type: 'string', required: true } }, @@ -476,8 +476,22 @@ describe('tool-call scheduler: abort handling', () => { isError: e.data.isError, error: e.data.error, }))).toEqual([ - { callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }, - { callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }, + { + callId: CallId('c1'), + isError: true, + error: { + message: 'tool call skipped because the step was aborted before execution', + info: { name: 'AbortError', code: 'ABORTED' }, + }, + }, + { + callId: CallId('c2'), + isError: true, + error: { + message: 'tool call skipped because the step was aborted before execution', + info: { name: 'AbortError', code: 'ABORTED' }, + }, + }, ]) }) @@ -509,7 +523,7 @@ describe('tool-call scheduler: abort handling', () => { expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) .toEqual([CallId('c1'), CallId('c2')]) expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data) - .toMatchObject({ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }) + .toMatchObject({ callId: CallId('c2'), isError: true, error: { info: { name: 'AbortError', code: 'ABORTED' } } }) }) it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => { @@ -538,10 +552,14 @@ describe('tool-call scheduler: abort handling', () => { .toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')]) expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) .toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')]) - expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data)) + expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => ({ + callId: e.data.callId, + isError: e.data.isError, + errorInfo: e.data.error?.info, + }))) .toEqual([ - expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }), - expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }), + { callId: CallId('c3'), isError: true, errorInfo: { name: 'AbortError', code: 'ABORTED' } }, + { callId: CallId('c4'), isError: true, errorInfo: { name: 'AbortError', code: 'ABORTED' } }, ]) const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message') expect(settled.map(e => e.type)) @@ -564,7 +582,7 @@ describe('tool-call scheduler: abort handling', () => { const gated = gatedParallelTool('p') const exclusive: string[] = [] ctx.tools.register(gated.tool) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'x', description: 'exclusive', parameters: { id: { type: 'string', required: true } }, @@ -583,6 +601,6 @@ describe('tool-call scheduler: abort handling', () => { expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) .toEqual([CallId('c1'), CallId('c2'), CallId('c3')]) expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data) - .toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }) + .toMatchObject({ callId: CallId('c3'), isError: true, error: { info: { name: 'AbortError', code: 'ABORTED' } } }) }) }) diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index bf78208a42..76b14e92c2 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -12,7 +12,7 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function registerNamed(ctx: Context, name: string) { - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name, description: `the ${name} tool`, parameters: {}, diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index 355e1e8e3d..f7b59e3585 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -29,7 +29,7 @@ function send(agent: Agent, text = 'go'): Promise { } function registerEcho(ctx: Context): void { - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 28210e8f6c..aaf1d429ee 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -58,6 +58,8 @@ Durable values need one accepted representation, not a check followed by a secon `context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. +`tool/result` persists the model-facing content, canonical failure detail, and optional presentation metadata. A tool's successful canonical `value` is deliberately execution-local and never enters the session event, so replay reconstructs the Native/model presentation but cannot recover intermediate programmatic values. This does not change `SESSION_FORMAT_VERSION`: the persisted projection remains authoritative. + ### Session event vocabulary (`types.ts`) The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure. diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index efbb3d2004..fa3b8bbb50 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -92,7 +92,10 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session callId, content: [{ type: 'text', text: 'Tool call interrupted by a crash; no result was recorded.' }], isError: true, - error: { name: 'InterruptedError', code: 'interrupted' }, + error: { + message: 'Tool call interrupted by a crash; no result was recorded.', + info: { name: 'InterruptedError', code: 'interrupted' }, + }, }, surfaceOp: 'append', ...callSeq !== undefined ? { sourceEventSeqs: [callSeq] } : {}, diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index ea0ad55a51..cc7ea81ba1 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -243,15 +243,24 @@ export interface SessionEventMap { */ 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } /** - * A completed tool call's model-facing result, plus an optional tool-private - * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the - * producing tool owns its shape and reads it back in `presentResult`) but MUST - * be JSON-serializable: `Session.append` runtime-validates all event data with - * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the - * durable log reproduces the identical card on replay. Absent unless the tool - * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + * A completed tool call's model-facing result, canonical failure detail, and + * optional tool-private `meta` presentation payload. `meta` is opaque to the + * core (the producing tool owns its shape and reads it back in `presentResult`) + * but MUST be JSON-serializable: `Session.append` runtime-validates all event + * data with `isJsonValue`, so a non-serializable `meta` is rejected at the + * source, and the durable log reproduces the identical card on replay. Absent + * unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time + * contextual diff here). */ - 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } + 'tool/result': { + turn: number + step: number + callId: CallId + content: ContentBlock[] + isError: boolean + error?: { message: string; info?: { name: string; code: string } } + meta?: JsonValue + } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts index 765502b8ce..826d410dc8 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -64,7 +64,7 @@ describe('interruptedTurnClosers', () => { expect(closers.map(e => e.seq)).toEqual([3, 4, 5]) const result = closers[0]! expect(result.type === 'tool/result' && result.data).toMatchObject({ - turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: 'interrupted' }, + turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { info: { code: 'interrupted' } }, }) }) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 5ec2d4e7e4..1b42f2c28f 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -15,7 +15,7 @@ tools: ### Public API -- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. Disposed with the calling fiber. +- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing/unsupported output declarations and a non-positive/non-finite `timeoutMs` fail at registration. Disposed with the calling fiber. - `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)). @@ -33,14 +33,14 @@ The live registry pipeline has three transformable waterfalls followed by the ob ### Key types -- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. +- `ToolDefinition` — `ToolSchema` + mandatory `output { schema, render, presentationMeta? }` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. A body returns only the canonical JSON value declared by the output schema. - `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. - `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately. -- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step. +- `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute `HookContext` for the loop's post-result FIFO. - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny. -- `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. +- `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. - `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch. - `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation"). @@ -48,8 +48,8 @@ The live registry pipeline has three transformable waterfalls followed by the ob - Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically. - `tools/pre-execute` is the reorderable allow/deny/ask gate; `ctx.tools.guard()` adds monotonic owner policy after it. -- `tools/execute` wraps normalized core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal. -- `tools/post-execute` may replace content, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome. +- `tools/execute` wraps normalized canonical dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal; a wrapper-authored success is normalized through the resolved tool's output declaration. +- `tools/post-execute` may replace presentation content, replace the canonical value, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome. Content replacement is not a confidentiality boundary: block or replace the value when programmatic consumers must not receive it. - Exact signatures and ordering live in the generated [event catalog](../../../docs/cordis-catalog/events.md) and [pipeline](../../../docs/tool-execution-pipeline.md). - MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas. @@ -72,17 +72,20 @@ ctx.tools.register(defineTool({ offset: { type: 'number' }, limit: { type: 'number' }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args, exec) { // args is typed: { path: string; offset?: number; limit?: number } - const text = await readFile(args.path, 'utf8') - return [{ type: 'text', text }] + return readFile(args.path, 'utf8') }, })) ``` The unified schema DSL uses `ParameterSchemaSpec` for the implicit open parameter object and `ValueSchemaSpec` for any JSON-value root. It supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum`/`const` values are type-correct. Every explicit DSL object declares `additionalProperties: true | false`, while the implicit parameter root and raw JSON Schema keep the standard open default. -A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. Extra keys are allowed, defaults are not applied, and object or array fields without `properties` or `items` receive only a type check. Raw-registered tools own their validation. +A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. It also infers the body return and pure output projectors from `output.schema`; the registry snapshots and validates the returned lossless JSON before presentation. Extra parameter keys are allowed, defaults are not applied, and object or array fields without `properties` or `items` receive only a type check. Raw-registered tools own input validation but still declare and receive registry-enforced output. See `defineTool`, `validateArgs`, `ToolArgsError`, `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, `InferArgs`, `valueSchemaSpecToJsonSchema`, and `parameterSchemaSpecToJsonSchema` in the public API for details. @@ -101,7 +104,7 @@ Tools optionally own pure `presentCall()` and `presentResult()` render intents, - Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`. - Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`. -Returning `undefined` selects generic fallback. Presenters depend only on their arguments because UIs call them during live streaming and log replay. Result presentation may read JSON-serializable `result.meta`, which persists with the result; `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the rationale. +Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary. ### Code Mode diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 0a65c9e434..21fcfcb8b5 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -10,7 +10,7 @@ import { inspect } from 'node:util' import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime' -import type {} from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-session' import { defineTool } from './schema.ts' import type { ToolDefinition, ToolRegistry } from './index.ts' @@ -111,9 +111,8 @@ function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unkno return { dispatched: JSON.parse(text) as unknown, logged: JSON.parse(text) as unknown } } -/** Render the program's completion value for the model-facing result text (`''` when the program returned nothing). */ -function renderValue(value: unknown): string { - if (value === undefined) return '' +/** Render one present program completion value for the model-facing result text. */ +function renderValue(value: JsonValue): string { return typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS) } @@ -122,6 +121,9 @@ interface RunCodeMeta { logs: CodeRunResult['logs'] } +/** Canonical value returned by the outer Code Mode transport. */ +type RunCodeOutput = { logs: string[]; result?: JsonValue } + /** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined { if (typeof meta !== 'object' || meta === null) return undefined @@ -152,7 +154,23 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => parameters: { code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' }, }, - async execute(args, exec) { + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + logs: { type: 'array', required: true, items: { type: 'string' } }, + result: { type: 'json' }, + }, + }, + render: (_args, value) => { + const rendered = value.result === undefined ? '' : renderValue(value.result) + const parts = [value.logs.join('\n'), rendered].filter(part => part.length > 0) + return [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }] + }, + presentationMeta: (_args, value) => ({ logs: value.logs }), + }, + async execute(args, exec): Promise { const runtime = requireRuntime() // The run-scoped abort: follows the outer signal in, and fires when the @@ -265,12 +283,12 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : '' throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`) } - const rendered = renderValue(result.value) - const parts = [result.logs.join('\n'), rendered].filter(part => part.length > 0) - const meta: RunCodeMeta = { logs: result.logs } + // The runtime seam is wider than JSON until PR 3 makes this boundary + // lossless. The registry immediately snapshots and rejects any value + // that does not satisfy the declared JSON output. return { - content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }], - meta, + logs: result.logs, + ...result.value !== undefined ? { result: result.value as JsonValue } : {}, } } finally { exec.signal?.removeEventListener('abort', onOuterAbort) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 2c01639047..f6fe38d4da 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -12,12 +12,15 @@ import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-session' import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' // Type-only: makes `ctx.get('approval')` resolve to the ApprovalService // augmentation. The seam stays optional at runtime — see `serviceAsk`. import type {} from '@deepseek-ai/dsh-user-approval' import type { ToolCallView, ToolResultView } from './presentation.ts' +import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts' +import type { JsonSchemaNode } from './json-schema.ts' import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts' import { renderToolsSdk } from './ts-types.ts' @@ -61,6 +64,7 @@ export type { JsonValue } from '@deepseek-ai/dsh-session' export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts' export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts' +export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts' // The render-intent vocabulary a tool declares via `presentCall`/`presentResult` // lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools` @@ -132,12 +136,22 @@ declare module 'cordis' { } } -/** Tool output, optionally with lossless-JSON presentation metadata persisted for replay. */ -export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } +/** Tool-owned canonical output contract used after the body returns a JSON value. */ +export interface ToolOutputDefinition { + /** Raw supported JSON Schema enforced against every successful canonical value. */ + readonly schema: JsonSchemaNode + /** Pure projection from validated arguments and value to Native/model content. */ + render(args: unknown, value: JsonValue): ContentBlock[] + /** Pure replayable presentation projection, computed only for surface calls. */ + presentationMeta?(args: unknown, value: JsonValue): JsonValue +} /** A registered tool: its schema plus the execution function. */ export interface ToolDefinition extends ToolSchema { - execute(args: unknown, exec: ToolRunContext): Promise + /** Mandatory canonical output declaration. */ + readonly output: ToolOutputDefinition + /** Execute the tool and return only its canonical lossless-JSON value. */ + execute(args: unknown, exec: ToolRunContext): Promise /** * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. * Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it @@ -172,7 +186,7 @@ export interface ToolDefinition extends ToolSchema { presentCall?(args: unknown): ToolCallView | undefined /** * Optional: how to present the COMPLETED state, given the same `args` and the - * `result` (`execute`'s content + whether it errored). Returns a + * durable result projection (`content`, failure state, and optional `meta`). Returns a * {@link ToolResultView}, or `undefined` (or omit the method) to keep the * pending title and render the raw result content. Pure and side-effect-free * for the same replay reason. @@ -182,17 +196,16 @@ export interface ToolDefinition extends ToolSchema { /** The completed outcome handed to {@link ToolDefinition.presentResult}. */ export interface ToolResult { - /** The model-facing content `execute` returned (or the error text on failure). */ + /** The final model-facing content (or the rendered error text on failure). */ content: ContentBlock[] /** Whether the call failed. */ isError: boolean /** - * The tool-private presentation payload the tool attached from `execute` (via - * the object return form), threaded verbatim from the `tool/result` event. - * Opaque (`unknown`); the tool narrows it back to its own shape. Absent when - * the tool attached none. + * The tool-private presentation payload projected by its output declaration + * and threaded verbatim from the `tool/result` event. Absent when the tool + * declared no projector or the call was nested under a composite transport. */ - meta?: unknown + meta?: JsonValue } declare const toolExecutionTokenBrand: unique symbol @@ -303,6 +316,14 @@ export interface ToolErrorInfo { code: string } +/** Canonical failure detail; internal routing information remains optional. */ +export interface ToolFailure { + /** Human-readable failure message without the Native `Error: ` envelope. */ + message: string + /** Internal error class/code used by policy and durable diagnostics. */ + info?: ToolErrorInfo +} + /** * Thrown (internally) when the model requests a tool that isn't registered. * Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool @@ -316,30 +337,42 @@ export class ToolNotFoundError extends HarnessError { } } -/** The outcome of one tool call. */ -export interface ToolExecutionResult { - content: ContentBlock[] - isError: boolean - /** - * Set when the call failed with a {@link HarnessError}: machine-routable - * `{ name, code }` for retry/sandbox plugins and replay. The model-facing - * text in `content` is always present; this is extra structure for code. - */ - error?: ToolErrorInfo - /** - * Model-facing context for the next request, separate from this tool result. The loop - * accepts it into the active-batch FIFO, then appends after recorded results even if interrupted. - */ - additionalContexts?: HookContext[] - /** - * The tool-private presentation payload from a successful `execute` (the object - * return form). Threaded onto the `tool/result` session event and back into - * {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the - * tool attached none or the call failed. - */ - meta?: unknown +/** Thrown when a tool body or post-policy value violates its declared output. */ +export class ToolOutputError extends HarnessError { + /** Schema/value violations in validation order. */ + readonly violations: string[] + + constructor(toolName: string, violations: string[]) { + super(`tool "${toolName}" returned invalid output: ${violations.join('; ')}`, 'INVALID_TOOL_OUTPUT') + this.name = 'ToolOutputError' + this.violations = violations + } } +/** Successful canonical tool execution, including its Native/model projection. */ +export interface ToolExecutionSuccess { + readonly isError: false + /** Execution-local canonical value; deliberately omitted from durable events. */ + readonly value: JsonValue + readonly content: ContentBlock[] + readonly error?: never + readonly meta?: JsonValue + readonly additionalContexts?: HookContext[] +} + +/** Failed canonical tool execution; failures never carry a successful value. */ +export interface ToolExecutionFailure { + readonly isError: true + readonly error: ToolFailure + readonly value?: never + readonly content: ContentBlock[] + readonly meta?: JsonValue + readonly additionalContexts?: HookContext[] +} + +/** The discriminated, execution-local outcome of one tool call. */ +export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure + /** * Pre-dispatch decision. `allow` runs the call; `deny` materializes an error; * `ask` runs only after an approval service returns `allowed-once` and otherwise @@ -352,11 +385,12 @@ export type PreToolDecision = | { kind: 'ask'; reason?: string } /** - * Post-dispatch decision: accept or replace content, attach context for the next - * request, or block by turning corrective feedback into an error result. + * Post-dispatch decision: accept, replace one projection, attach context for the + * next request, or block by turning corrective feedback into an error result. */ export type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] } + | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] } | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } /** @@ -381,6 +415,23 @@ function errorMessage(error: unknown): string { } } +/** Derive one failure message from policy feedback without changing its rendered blocks. */ +function failureMessageFromContent(content: ContentBlock[]): string { + const text = content + .map(block => block.type === 'text' ? block.text : `[${block.type} content]`) + .join('\n') + return text.length > 0 ? text : 'tool result blocked by post-execute policy' +} + +/** Snapshot and freeze one durable tool-result projection or reject lossy data. */ +function materializePresentation(candidate: T): T { + const detached = snapshotJsonValue(candidate) + if (detached === undefined) { + throw new TypeError('tool result must be losslessly JSON-serializable') + } + return deepFreeze(detached) +} + /** Structured `{ name, code }` for a thrown HarnessError, else undefined. */ function errorInfo(error: unknown): ToolErrorInfo | undefined { try { @@ -553,6 +604,13 @@ export class ToolRegistry extends Service { register(definition: ToolDefinition): () => void { const scope = scopeOf(this.ctx) const name = definition.name + const output = (definition as Partial).output + if (output === undefined || typeof output !== 'object' + || typeof output.render !== 'function' + || (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function')) { + throw new TypeError(`tool "${name}" must declare output { schema, render, presentationMeta? }`) + } + assertSupportedJsonSchema(output.schema) const timeoutMs = definition.timeoutMs if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) { @@ -880,10 +938,11 @@ export class ToolRegistry extends Service { return await next({ kind: 'post-result', exec, - result: { + result: this.materializeFinalResult({ content: [{ type: 'text', text: `Error: ${denialReason}` }], isError: true, - }, + error: { message: denialReason }, + }), }) } return await next({ kind: 'dispatch', exec }) @@ -909,27 +968,26 @@ export class ToolRegistry extends Service { const tool = this.get(exec.name, exec.agent) if (!tool) throw new ToolNotFoundError(exec.name) const returned = await tool.execute(exec.arguments, exec) - const content = Array.isArray(returned) ? returned : returned.content - const meta = Array.isArray(returned) ? undefined : returned.meta - return { content, isError: false, ...meta !== undefined ? { meta } : {} } + return this.createSuccessResult(exec, tool, returned) } catch (error: unknown) { - return toolErrorResult(error) + return this.materializeFinalResult(toolErrorResult(error)) } }, ) + const normalized = this.normalizeDispatchResult(exec, result) const deferredContexts = this.deferredContexts.get(exec) /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */ if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution') const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0 - ? result - : { - ...result, + ? normalized + : this.markCanonical({ + ...normalized, additionalContexts: [ ...deferredContexts, - ...result.additionalContexts ?? [], + ...normalized.additionalContexts ?? [], ], - } - return { kind: 'post-result', result: resultWithDeferredContexts } + }) + return { kind: 'post-result', result: this.materializeFinalResult(resultWithDeferredContexts) } } catch (error: unknown) { return { kind: 'final-result', result: toolErrorResult(error) } } @@ -1046,32 +1104,103 @@ export class ToolRegistry extends Service { ) const decisionContexts = decision.additionalContexts ?? [] if (decision.kind === 'block') { - return { + const message = failureMessageFromContent(decision.feedback) + return this.markCanonical({ content: decision.feedback, isError: true, + error: { message }, ...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {}, - } + }) + } + if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) { + throw new TypeError('tools/post-execute accept decision cannot replace both value and content') } - // Accept: replace content if supplied, preserve the dispatched outcome, and - // append decision contexts after contexts deferred by the tool body. const additionalContexts = [ ...result.additionalContexts ?? [], ...decisionContexts, ] - return { - ...result, - ...decision.content ? { content: decision.content } : {}, - ...additionalContexts.length > 0 ? { additionalContexts } : {}, + if (Object.hasOwn(decision, 'value')) { + if (result.isError) { + throw new TypeError('tools/post-execute cannot replace the value of a failed result') + } + const tool = this.get(exec.name, exec.agent) + if (tool === undefined) throw new ToolNotFoundError(exec.name) + const replaced = this.createSuccessResult(exec, tool, decision.value) + return this.markCanonical({ + ...replaced, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + }) } + return this.markCanonical({ + ...result, + ...decision.content !== undefined ? { content: decision.content } : {}, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + }) + } + + /** Results created by the registry already own a validated, frozen canonical value. */ + private readonly canonicalResults = new WeakSet() + + /** Mark a registry-normalized result without freezing presentation fields prematurely. */ + private markCanonical(result: T): T { + this.canonicalResults.add(result) + return result + } + + /** Snapshot, validate, render, and optionally project one successful body value. */ + private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess { + const detached = snapshotJsonValue(candidate) + if (detached === undefined) { + throw new ToolOutputError(tool.name, ['value is not lossless JSON']) + } + const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value') + if (violations.length > 0) throw new ToolOutputError(tool.name, violations) + const value = deepFreeze(detached as JsonValue) + const content = tool.output.render(exec.arguments, value) + const meta = exec.parent === undefined && tool.output.presentationMeta !== undefined + ? tool.output.presentationMeta(exec.arguments, value) + : undefined + return this.markCanonical(this.materializeFinalResult({ + isError: false, + value, + content, + ...meta !== undefined ? { meta } : {}, + }) as ToolExecutionSuccess) + } + + /** Normalize an around-dispatch wrapper's authored result through the owning output contract. */ + private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult { + if (this.canonicalResults.has(result)) return result + if (result.isError) { + return this.markCanonical({ + isError: true, + error: result.error, + content: result.content, + ...result.meta !== undefined ? { meta: result.meta } : {}, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, + }) + } + const tool = this.get(exec.name, exec.agent) + if (tool === undefined) throw new ToolNotFoundError(exec.name) + const normalized = this.createSuccessResult(exec, tool, result.value) + return this.markCanonical({ + ...normalized, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, + }) } /** Materialize the authoritative commit outcome once, immediately before `tools/result`. */ private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult { - const detached = snapshotJsonValue(result) - if (detached === undefined) { - throw new TypeError('tool result must be losslessly JSON-serializable') + const presentation = { + content: result.content, + ...result.meta !== undefined ? { meta: result.meta } : {}, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, } - return deepFreeze(detached) + if (result.isError) { + return materializePresentation({ isError: true as const, error: result.error, ...presentation }) + } + const detached = materializePresentation({ isError: false as const, ...presentation }) + return deepFreeze({ ...detached, value: result.value }) } } @@ -1082,10 +1211,11 @@ function createExecutionToken(): ToolExecutionToken { function toolErrorResult(error: unknown): ToolExecutionResult { const info = errorInfo(error) + const message = errorMessage(error) return { - content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }], + content: [{ type: 'text', text: `Error: ${message}` }], isError: true, - ...info ? { error: info } : {}, + error: { message, ...info ? { info } : {} }, } } diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 98be959e4e..d81a97508c 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -1,8 +1,9 @@ /** Unified JSON-value schema DSL, inference, compilation, and typed tool helper. @module dsh-tools/schema */ import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-session' -import type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts' +import type { ToolDefinition, ToolRunContext, ToolResult } from './index.ts' import { assertSupportedJsonSchema, isPlainJsonRecord, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts' import type { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts' import type { ToolCallView, ToolResultView } from './presentation.ts' @@ -114,21 +115,25 @@ type RequiredKeys = { [K in keyof S]: S[K] extends { required: true } ? K : never }[keyof S] +/** Advance the bounded inference walk through one nested schema node. */ +type NextDepth = readonly [...D, unknown] + /** Infer the declared value of one parameter property without key optionality. */ -type InferProperty

= P extends ValueSchemaSpec ? InferValue

: never +type InferProperty

= + P extends ValueSchemaSpec ? InferValue : never /** Infer an implicit property map into required and optional object keys. */ -type InferProperties = Simplify< - & { [K in RequiredKeys]: InferProperty } - & { [K in Exclude>]?: InferProperty } +type InferProperties = Simplify< + & { [K in RequiredKeys]: InferProperty } + & { [K in Exclude>]?: InferProperty } > /** Infer an explicit object node, including its declared openness. */ -type InferObject = +type InferObject = S extends { properties: infer P extends ParameterSchemaSpec } ? S['additionalProperties'] extends true - ? InferProperties

& Record - : InferProperties

+ ? InferProperties & Record + : InferProperties : S['additionalProperties'] extends true ? Record : Record @@ -143,20 +148,21 @@ type InferScalar = * Infer the TypeScript value accepted by an author-facing value schema. * Output schemas may therefore infer object, array, scalar, or null roots. */ -export type InferValue = - S extends StringValueSchemaSpec ? InferScalar : - S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar : - S extends BooleanValueSchemaSpec ? InferScalar : - S extends NullValueSchemaSpec ? null : - S extends ArrayValueSchemaSpec - ? S extends { items: infer I extends ValueSchemaSpec } ? InferValue[] : JsonValue[] - : S extends ObjectValueSchemaSpec ? InferObject : - S extends JsonValueSchemaSpec ? JsonValue : - S extends OneOfValueSchemaSpec ? InferValue : - never +export type InferValue = + D['length'] extends 12 ? JsonValue : + S extends StringValueSchemaSpec ? InferScalar : + S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar : + S extends BooleanValueSchemaSpec ? InferScalar : + S extends NullValueSchemaSpec ? null : + S extends ArrayValueSchemaSpec + ? S extends { items: infer I extends ValueSchemaSpec } ? InferValue>[] : JsonValue[] + : S extends ObjectValueSchemaSpec ? InferObject> : + S extends JsonValueSchemaSpec ? JsonValue : + S extends OneOfValueSchemaSpec ? InferValue> : + never /** Infer the TypeScript argument object for an implicit parameter schema. */ -export type InferArgs = InferProperties +export type InferArgs = InferProperties const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const @@ -329,13 +335,22 @@ export function validateArgs(spec: ParameterSchemaSpec, args: unknown): string[] } /** Options for {@link defineTool}. */ -export interface DefineToolOptions { +export interface DefineToolOptions { /** Tool name (must be unique). */ readonly name: string /** Human-readable description sent to the model. */ readonly description: string /** Per-property parameter schema compiled to an implicit open object root. */ readonly parameters: S + /** Canonical output schema plus pure Native and presentation projections. */ + readonly output: { + /** Schema enforced against every successful body or policy-replaced value. */ + readonly schema: O + /** Pure Native/model rendering of one validated canonical value. */ + render(args: InferArgs, value: InferValue>): ContentBlock[] + /** Pure replayable presentation metadata for direct surface calls. */ + presentationMeta?(args: InferArgs, value: InferValue>): JsonValue + } /** Optional positive cooperative timeout budget in milliseconds. */ readonly timeoutMs?: number /** @@ -348,9 +363,9 @@ export interface DefineToolOptions { * Execute the tool after argument validation. * @param args - typed validated arguments. * @param exec - execution identity, caller, cancellation, and nesting data. - * @returns Model-facing content and optional presentation metadata. + * @returns The canonical value declared by `output.schema`. */ - execute(args: InferArgs, exec: ToolRunContext): Promise + execute(args: InferArgs, exec: ToolRunContext): Promise>> /** * Pure pending-state presenter. * @param args - typed validated arguments. @@ -373,11 +388,17 @@ export interface DefineToolOptions { * @param options - typed definition and optional presenters. * @returns A registry-ready definition. */ -export function defineTool(options: DefineToolOptions): ToolDefinition { +export function defineTool( + options: DefineToolOptions, +): ToolDefinition { // Object-literal methods do not use `this`; retaining references is safe. // eslint-disable-next-line @typescript-eslint/unbound-method const userExecute = options.execute // eslint-disable-next-line @typescript-eslint/unbound-method + const userRender = options.output.render + // eslint-disable-next-line @typescript-eslint/unbound-method + const userPresentationMeta = options.output.presentationMeta + // eslint-disable-next-line @typescript-eslint/unbound-method const userPresentCall = options.presentCall // eslint-disable-next-line @typescript-eslint/unbound-method const userPresentResult = options.presentResult @@ -387,16 +408,28 @@ export function defineTool(options: DefineToolOpt throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`) } const parameters = parameterSchemaSpecToJsonSchema(options.parameters) + const outputSchema = valueSchemaSpecToJsonSchema(options.output.schema) const validate = (args: unknown): string[] => validateJsonSchemaValue(parameters, args, '') const tool: ToolDefinition = { name: options.name, description: options.description, parameters: parameters as unknown as Record, + output: { + schema: outputSchema, + render(args: unknown, value: JsonValue): ContentBlock[] { + return userRender(args as InferArgs, value as unknown as InferValue>) + }, + ...userPresentationMeta !== undefined ? { + presentationMeta(args: unknown, value: JsonValue): JsonValue { + return userPresentationMeta(args as InferArgs, value as unknown as InferValue>) + }, + } : {}, + }, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - async execute(args: unknown, exec: ToolRunContext): Promise { + async execute(args: unknown, exec: ToolRunContext): Promise { const violations = validate(args) if (violations.length > 0) throw new ToolArgsError(violations) - return userExecute(args as InferArgs, exec) + return userExecute(args as InferArgs, exec) as Promise }, } if (userPresentCall) { diff --git a/packages/core/tools/src/testing.ts b/packages/core/tools/src/testing.ts new file mode 100644 index 0000000000..ad9fa52d85 --- /dev/null +++ b/packages/core/tools/src/testing.ts @@ -0,0 +1,42 @@ +/** Canonical tool-definition fixtures for repository tests. @module dsh-tools/testing */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { JsonValue } from '@deepseek-ai/dsh-session' +import { defineTool } from './schema.ts' +import type { DefineToolOptions, ParameterSchemaSpec } from './schema.ts' +import type { ToolDefinition, ToolRunContext } from './index.ts' + +const CONTENT_VALUE_SCHEMA = { type: 'array', items: { type: 'json' } } as const + +/** Options for a fixture whose canonical value is its rendered content array. */ +export type ContentToolFixtureOptions = Omit< + DefineToolOptions, + 'output' | 'execute' +> & { + /** Produce the fixture's content blocks as its canonical test value. */ + execute(args: import('./schema.ts').InferArgs, exec: ToolRunContext): Promise +} + +/** + * Define a test fixture that deliberately uses its content blocks as the + * canonical JSON value. Product tools must declare domain-owned DTOs instead. + * @param options - ordinary fixture fields plus a content-producing body. + * @returns a registry-ready tool with an explicit JSON-array output contract. + * @internal + */ +export function defineContentToolFixture( + options: ContentToolFixtureOptions, +): ToolDefinition { + // eslint-disable-next-line @typescript-eslint/unbound-method + const execute = options.execute + return defineTool({ + ...options, + output: { + schema: CONTENT_VALUE_SCHEMA, + render: (_args, value) => value as unknown as ContentBlock[], + }, + async execute(args, exec) { + return await execute(args, exec) as unknown as JsonValue[] + }, + }) +} diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 227ff98129..0fe48af14a 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -6,7 +6,7 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' -import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -68,7 +68,7 @@ async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: S /** Register a trivial echo tool; returns the calls it received. */ function registerEcho(ctx: Context, name = 'echo'): unknown[] { const calls: unknown[] = [] - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name, description: `Echo tool ${name}.`, parameters: { value: { type: 'string', required: true } }, @@ -217,7 +217,7 @@ describe('mode-aware wire contribution', () => { it.each(['code', 'both'] as const)('reserves run_code against scoped shadows and explicit restrictions in mode %s', async (mode) => { const { ctx, systemPrompt } = await setup({ mode }) const { scope, agent } = await mintAgentScope(ctx) - const impostor = defineTool({ + const impostor = defineContentToolFixture({ name: RUN_CODE_NAME, description: 'Scoped impostor.', parameters: {}, @@ -229,7 +229,7 @@ describe('mode-aware wire contribution', () => { expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) scope.ctx.systemPrompt.section({ name: 'scoped-note', order: 149, text: 'safe note' }) - scope.ctx.tools.register(defineTool({ + scope.ctx.tools.register(defineContentToolFixture({ name: 'scoped_safe', description: 'Safe scoped tool.', parameters: {}, @@ -332,6 +332,8 @@ describe('the run_code dispatch bridge', () => { } const result = await runCode(ctx, 'const …: string = …', { agent }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected run_code success') + expect(result.value).toEqual({ logs: ['saw echo:one'], result: 'echo:two' }) expect(result.content).toEqual([{ type: 'text', text: 'saw echo:one\necho:two' }]) expect(calls).toEqual([{ value: 'one' }, { value: 'two' }]) const dispatches = events.filter(event => event.type === 'tool/code-dispatch') @@ -374,7 +376,7 @@ describe('the run_code dispatch bridge', () => { const { ctx, runtime } = await setup({ mode: 'code' }) const intervals: [string, string][] = [] let active = 0 - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'Records execution overlap.', parameters: { id: { type: 'string', required: true } }, @@ -405,7 +407,7 @@ describe('the run_code dispatch bridge', () => { it('rejects the program-side call when the tool errors, with the tool error text', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'fail', description: 'Always fails.', parameters: {}, @@ -549,7 +551,7 @@ describe('the run_code dispatch bridge', () => { }) const result = await runCode(ctx, 'program') expect(result.isError).toBe(true) - expect(result.error).toEqual({ name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' }) + expect(result.error).toMatchObject({ info: { name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' } }) const text = (result.content[0] as { text: string }).text expect(text).toContain('code run failed (timeout)') expect(text).toContain('compute budget exhausted') @@ -566,7 +568,7 @@ describe('the run_code dispatch bridge', () => { const { ctx, runtime } = await setup({ mode: 'code' }) const seen: string[] = [] let sawAbort = false - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'slow', description: 'Slow tool observing its signal.', parameters: { id: { type: 'string', required: true } }, @@ -602,7 +604,7 @@ describe('the run_code dispatch bridge', () => { let sawAbort = false let started!: () => void const inFlight = new Promise((resolve) => { started = resolve }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'slow', description: 'Slow tool observing its signal.', parameters: { id: { type: 'string', required: true } }, @@ -689,7 +691,7 @@ describe('the run_code dispatch bridge', () => { const { ctx, runtime } = await setup({ mode: 'code' }) const { agent, events } = fakeAgent() const long = 'x'.repeat(300) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'mixed', description: 'Returns mixed content.', parameters: {}, @@ -714,7 +716,7 @@ describe('the run_code dispatch bridge', () => { it('normalizes the session workspace root before bounding durable result summaries', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'workspace_path', description: 'Return a path beneath the session workspace.', parameters: {}, @@ -792,7 +794,7 @@ describe('the run_code dispatch bridge', () => { const { ctx, runtime } = await setup({ mode: 'code' }) const { agent, events } = fakeAgent() let mutationSucceeded: boolean | undefined - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'mutator', description: 'Attempts to mutate its args object.', parameters: { list: { type: 'array', required: true } }, @@ -814,7 +816,7 @@ describe('the run_code dispatch bridge', () => { it('exposes a tool named __proto__ as an ordinary own binding', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: '__proto__', description: 'A prototype-colliding tool name.', parameters: {}, diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts index 9a12f33a51..ac490396a4 100644 --- a/packages/core/tools/tests/execution-mode.spec.ts +++ b/packages/core/tools/tests/execution-mode.spec.ts @@ -5,7 +5,7 @@ import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { - defineTool, + defineContentToolFixture, type ToolDefinition, type ToolExecutionInput, type ToolExecutionMode, @@ -25,7 +25,7 @@ function exec(name: string, args: unknown): ToolExecutionInput { describe('ToolRegistry.executionMode', () => { it('returns parallel only for an explicit true classifier', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 'parallel-safe', parameters: {}, @@ -37,7 +37,7 @@ describe('ToolRegistry.executionMode', () => { it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'plain', description: 'no declaration', parameters: {}, @@ -53,7 +53,7 @@ describe('ToolRegistry.executionMode', () => { it('returns exclusive when the classifier returns false for these args', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'rw', description: 'read or write', parameters: { mode: { type: 'string', required: true } }, @@ -64,9 +64,9 @@ describe('ToolRegistry.executionMode', () => { expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' }) }) - it('classifies invalid defineTool arguments as exclusive without throwing', async () => { + it('classifies invalid defineContentToolFixture arguments as exclusive without throwing', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'needs-mode', description: 'requires mode', parameters: { mode: { type: 'string', required: true } }, @@ -82,8 +82,9 @@ describe('ToolRegistry.executionMode', () => { name: 'thrower', description: 'classifier throws', parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'null' }, render: () => [] }, isConcurrencySafe() { throw new Error('boom') }, - async execute() { return [] }, + async execute() { return null }, } ctx.tools.register(raw) expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' }) @@ -95,8 +96,9 @@ describe('ToolRegistry.executionMode', () => { name: 'truthy', description: 'classifier returns a truthy string', parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'null' }, render: () => [] }, isConcurrencySafe() { return 'yes' }, - async execute() { return [] }, + async execute() { return null }, } as unknown as ToolDefinition ctx.tools.register(raw) expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' }) @@ -109,8 +111,9 @@ describe('ToolRegistry.executionMode', () => { name: 'raw-safe', description: 'raw', parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'null' }, render: () => [] }, isConcurrencySafe(args) { seen = args; return true }, - async execute() { return [] }, + async execute() { return null }, }) expect(ctx.tools.executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' }) expect(seen).toEqual({ anything: 1 }) @@ -118,7 +121,7 @@ describe('ToolRegistry.executionMode', () => { it('isConcurrencySafe never reaches the model-facing schemas() projection', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 'parallel-safe', parameters: { x: { type: 'string', required: true } }, diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 843aeb3837..a214f4f6bf 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -9,7 +9,6 @@ import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput import type { Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' /** Mount the registry (with its systemPrompt dependency) on a fresh context. */ @@ -37,7 +36,11 @@ function tool(name: string, reply = `ran:${name}`): ToolDefinition { name, description: `tool ${name}`, parameters: { type: 'object', properties: {} }, - execute: (): Promise => Promise.resolve([{ type: 'text', text: reply }]), + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value as string }], + }, + execute: (): Promise => Promise.resolve(reply), } } @@ -221,7 +224,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: () => { bodyCalls += 1 - return Promise.resolve([{ type: 'text', text: 'ran:t' }]) + return Promise.resolve('ran:t') }, }) const guard = (execution: Readonly): string => { @@ -253,7 +256,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: () => { bodyCalls += 1 - return Promise.resolve([]) + return Promise.resolve('ran:t') }, }) ctx.tools.guard(() => undefined) @@ -276,14 +279,14 @@ describe('scoped execution dispatch', () => { execute: (args) => { safeCalls += 1 safeArguments = args - return Promise.resolve([{ type: 'text', text: 'safe' }]) + return Promise.resolve('safe') }, }) ctx.tools.register({ ...tool('danger'), execute: () => { dangerCalls += 1 - return Promise.resolve([{ type: 'text', text: 'danger' }]) + return Promise.resolve('danger') }, }) scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined) @@ -335,7 +338,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: () => { bodyCalls += 1 - return Promise.resolve([]) + return Promise.resolve('ran:t') }, }) ctx.on('tools/pre-execute', (_exec, next) => { @@ -396,7 +399,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: (_args, exec) => { observed.push(exec.parent) - return Promise.resolve([{ type: 'text', text: 'ran:t' }]) + return Promise.resolve('ran:t') }, }) ctx.on('tools/pre-execute', (exec, next) => { @@ -511,7 +514,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: () => { bodyCalls += 1 - return Promise.resolve([]) + return Promise.resolve('ran:t') }, }) ctx.on('tools/pre-execute', (_exec, next) => { @@ -552,6 +555,7 @@ describe('scoped execution dispatch', () => { expect(result).toEqual({ content: [{ type: 'text', text: 'ran:t' }], isError: false, + value: 'ran:t', }) }) @@ -570,6 +574,7 @@ describe('scoped execution dispatch', () => { return { content: [{ type: 'text', text: 'outer failure' }], isError: true, + error: { message: 'outer failure' }, } }, { prepend: true }) scope.ctx.on('tools/result', (_exec, result) => { diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 5ddac2cd31..a8ccf384d5 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -5,9 +5,9 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Agent } from '@deepseek-ai/dsh-agent' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import ToolRegistry, { - defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, + defineContentToolFixture, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, - type ToolExecution, type ToolExecutionResult, + type ToolDefinition, type ToolExecution, type ToolExecutionResult, type ToolExecutionToken, } from '@deepseek-ai/dsh-tools' async function setup() { @@ -21,8 +21,12 @@ const echoTool = defineTool({ name: 'echo', description: 'echo arguments back', parameters: { text: { type: 'string' } }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { - return [{ type: 'text' as const, text: args.text ?? '' }] + return args.text ?? '' }, }) @@ -50,7 +54,7 @@ describe('ToolRegistry', () => { // the system-prompt assembly → the model request, so those callbacks (and // `execute`) must be stripped: a function in the JSON tool schema would // corrupt the request. schemas() is an explicit allowlist, so it can't leak. - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'present', description: 'has presenters', parameters: { x: { type: 'string', required: true } }, @@ -67,7 +71,7 @@ describe('ToolRegistry', () => { it('schemas() excludes timeoutMs — the budget must never reach the model', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'budgeted', description: 'has a budget', parameters: {}, timeoutMs: 5_000, async execute() { return [{ type: 'text' as const, text: 'ok' }] }, })) @@ -79,17 +83,24 @@ describe('ToolRegistry', () => { it('executes a tool and returns its content', async () => { const ctx = await setup() ctx.tools.register(echoTool) + let observed: ToolExecutionResult | undefined + ctx.on('tools/result', (_exec, result) => { observed = result }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) - expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false }) + expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: 'hi' }) + expect(observed).toEqual(result) }) - it('threads a tool-attached meta (object return form) onto the result', async () => { + it('projects presentation metadata from the canonical value', async () => { const ctx = await setup() ctx.tools.register({ ...echoTool, name: 'meta-tool', + output: { + ...echoTool.output, + presentationMeta: () => ({ diffs: [{ path: 'a', oldText: null, newText: 'x' }] }), + }, async execute() { - return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] } } + return 'ok' }, }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} }) @@ -97,20 +108,21 @@ describe('ToolRegistry', () => { content: [{ type: 'text', text: 'ok' }], isError: false, meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] }, + value: 'ok', }) }) - it('omits meta when the object return form supplies none', async () => { + it('omits meta when no presentation projector is declared', async () => { const ctx = await setup() ctx.tools.register({ ...echoTool, name: 'no-meta-tool', async execute() { - return { content: [{ type: 'text', text: 'ok' }] } + return 'ok' }, }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} }) - expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false }) + expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false, value: 'ok' }) expect('meta' in result).toBe(false) }) @@ -121,8 +133,12 @@ describe('ToolRegistry', () => { ctx.tools.register({ ...echoTool, name: 'bad-meta', + output: { + ...echoTool.output, + presentationMeta: () => (() => undefined) as unknown as JsonValue, + }, async execute() { - return { content: [], meta: () => undefined } + return 'ok' }, }) @@ -134,6 +150,284 @@ describe('ToolRegistry', () => { expect(observedError).toBe(true) }) + it('requires every raw registration to declare its canonical output', async () => { + const ctx = await setup() + const missingOutput = { + name: 'legacy-content-tool', + description: 'missing output', + parameters: {}, + execute: async () => [{ type: 'text', text: 'legacy' }], + } as unknown as ToolDefinition + + expect(() => ctx.tools.register(missingOutput)) + .toThrow('must declare output { schema, render, presentationMeta? }') + }) + + it('rejects lossy and schema-mismatched body values before post-execute', async () => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: 'lossy-output', + description: 'lossy', + parameters: {}, + output: { schema: { type: 'json' }, render: () => [] }, + execute: async () => (() => undefined) as unknown as JsonValue, + })) + ctx.tools.register(defineTool({ + name: 'wrong-output', + description: 'wrong schema', + parameters: {}, + output: { schema: { type: 'string' }, render: () => [] }, + execute: async () => 42 as unknown as string, + })) + + const lossy = await ctx.tools.execute({ callId: CallId('lossy'), name: 'lossy-output', arguments: {} }) + const mismatch = await ctx.tools.execute({ callId: CallId('mismatch'), name: 'wrong-output', arguments: {} }) + expect(lossy.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } }) + expect(lossy.content[0]?.type === 'text' ? lossy.content[0].text : '').toContain('not lossless JSON') + expect(mismatch.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } }) + expect(mismatch.content[0]?.type === 'text' ? mismatch.content[0].text : '').toContain('"value" must be a string') + }) + + it.each(['render', 'presentationMeta'] as const)('contains a throwing output.%s projector as one failed call', async (projector) => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: `throwing-${projector}`, + description: projector, + parameters: {}, + output: { + schema: { type: 'string' }, + render: () => { + if (projector === 'render') throw new Error('renderer exploded') + return [{ type: 'text', text: 'ok' }] + }, + presentationMeta: () => { + if (projector === 'presentationMeta') throw new Error('metadata exploded') + return null + }, + }, + execute: async () => 'ok', + })) + + const result = await ctx.tools.execute({ callId: CallId(projector), name: `throwing-${projector}`, arguments: {} }) + expect(result).toMatchObject({ + isError: true, + error: { message: projector === 'render' ? 'renderer exploded' : 'metadata exploded' }, + }) + expect('value' in result).toBe(false) + }) + + it('keeps value/meta through content replacement and recomputes both projections after value replacement', async () => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: 'projected', + description: 'projected', + parameters: {}, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { text: { type: 'string', required: true } }, + }, + render: (_args, value) => [{ type: 'text', text: `render:${value.text}` }], + presentationMeta: (_args, value) => ({ projected: value.text }), + }, + execute: async () => ({ text: 'body' }), + })) + let replacement: 'content' | 'value' = 'content' + ctx.on('tools/post-execute', async () => { + if (replacement === 'content') { + return { kind: 'accept', content: [{ type: 'text', text: 'policy content' }] } + } + return { + kind: 'accept', + value: { text: 'policy value' }, + additionalContexts: [{ content: [{ type: 'text', text: 'value context' }], source: { kind: 'plugin', plugin: 'test' } }], + } + }) + + const content = await ctx.tools.execute({ callId: CallId('content'), name: 'projected', arguments: {} }) + replacement = 'value' + const value = await ctx.tools.execute({ callId: CallId('value'), name: 'projected', arguments: {} }) + + expect(content).toEqual({ + isError: false, + value: { text: 'body' }, + content: [{ type: 'text', text: 'policy content' }], + meta: { projected: 'body' }, + }) + expect(value).toEqual({ + isError: false, + value: { text: 'policy value' }, + content: [{ type: 'text', text: 'render:policy value' }], + meta: { projected: 'policy value' }, + additionalContexts: [{ content: [{ type: 'text', text: 'value context' }], source: { kind: 'plugin', plugin: 'test' } }], + }) + }) + + it('fails a post-execute decision that replaces both projections or supplies an invalid value', async () => { + const both = await setup() + both.tools.register(echoTool) + both.on('tools/post-execute', async () => ({ + kind: 'accept', + value: 'replacement', + content: [{ type: 'text', text: 'also replacement' }], + } as unknown as PostToolDecision)) + const bothResult = await both.tools.execute({ callId: CallId('both'), name: 'echo', arguments: {} }) + expect(bothResult).toMatchObject({ + isError: true, + error: { message: 'tools/post-execute accept decision cannot replace both value and content' }, + }) + + const invalid = await setup() + invalid.tools.register(echoTool) + invalid.on('tools/post-execute', async () => ({ kind: 'accept', value: 1 })) + const invalidResult = await invalid.tools.execute({ callId: CallId('invalid'), name: 'echo', arguments: {} }) + expect(invalidResult.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } }) + expect('value' in invalidResult).toBe(false) + }) + + it('turns a post-execute block into a valueless failure', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/post-execute', async () => ({ + kind: 'block', + feedback: [{ type: 'text', text: 'blocked by policy' }], + })) + + const result = await ctx.tools.execute({ callId: CallId('block'), name: 'echo', arguments: { text: 'secret' } }) + expect(result).toEqual({ + isError: true, + error: { message: 'blocked by policy' }, + content: [{ type: 'text', text: 'blocked by policy' }], + }) + expect('value' in result).toBe(false) + }) + + it('replaces a canonical value without manufacturing additional context', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/post-execute', async () => ({ kind: 'accept', value: 'replacement' })) + + const result = await ctx.tools.execute({ callId: CallId('replace-value'), name: 'echo', arguments: {} }) + expect(result).toEqual({ + isError: false, + value: 'replacement', + content: [{ type: 'text', text: 'replacement' }], + }) + }) + + it.each([ + [[], 'tool result blocked by post-execute policy'], + [[{ type: 'reasoning', text: 'private rationale' }], '[reasoning content]'], + ] as const)('derives a stable failure message from non-text or empty block feedback', async (feedback, message) => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/post-execute', async () => ({ kind: 'block', feedback: [...feedback] })) + + const result = await ctx.tools.execute({ callId: CallId('block-message'), name: 'echo', arguments: {} }) + expect(result.error?.message).toBe(message) + }) + + it('contains a non-JSON post-execute failure projection as a safe final error', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/post-execute', async () => ({ + kind: 'block', + feedback: [{ type: 'text', text: 'blocked', invalid: () => undefined } as never], + })) + + const result = await ctx.tools.execute({ callId: CallId('invalid-block'), name: 'echo', arguments: {} }) + expect(result).toMatchObject({ + isError: true, + error: { message: 'tool result must be losslessly JSON-serializable' }, + }) + }) + + it('rejects value replacement on a failed dispatch', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'throw-before-replace', + async execute() { throw new Error('body failed') }, + }) + ctx.on('tools/post-execute', async () => ({ kind: 'accept', value: 'replacement' })) + + const result = await ctx.tools.execute({ + callId: CallId('failed-replace'), name: 'throw-before-replace', arguments: {}, + }) + expect(result.error?.message).toBe('tools/post-execute cannot replace the value of a failed result') + }) + + it('fails value replacement when the owning tool disappears before post-policy resolves', async () => { + const ctx = await setup() + const dispose = ctx.tools.register(echoTool) + ctx.on('tools/post-execute', async () => { + dispose() + return { kind: 'accept', value: 'replacement' } + }) + + const result = await ctx.tools.execute({ callId: CallId('post-disposed'), name: 'echo', arguments: {} }) + expect(result.error).toEqual({ + message: 'unknown tool "echo"', + info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }, + }) + }) + + it('normalizes wrapper-authored failure metadata and contexts', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/execute', async () => ({ + isError: true, + error: { message: 'wrapped failure' }, + content: [{ type: 'text', text: 'wrapper content' }], + meta: { wrapped: true }, + additionalContexts: [{ content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'plugin', plugin: 'test' } }], + })) + + const result = await ctx.tools.execute({ callId: CallId('wrapper-failure'), name: 'echo', arguments: {} }) + expect(result).toEqual({ + isError: true, + error: { message: 'wrapped failure' }, + content: [{ type: 'text', text: 'wrapper content' }], + meta: { wrapped: true }, + additionalContexts: [{ content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'plugin', plugin: 'test' } }], + }) + }) + + it('fails wrapper-authored success normalization when the owning tool disappears', async () => { + const ctx = await setup() + const dispose = ctx.tools.register(echoTool) + ctx.on('tools/execute', async () => { + dispose() + return { isError: false, value: 'replacement', content: [] } + }) + + const result = await ctx.tools.execute({ callId: CallId('wrapper-disposed'), name: 'echo', arguments: {} }) + expect(result.error).toEqual({ + message: 'unknown tool "echo"', + info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }, + }) + }) + + it('suppresses presentation metadata only for nested composite dispatches', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'meta-suppression', + output: { ...echoTool.output, presentationMeta: () => ({ card: true }) }, + }) + const direct = await ctx.tools.execute({ callId: CallId('direct'), name: 'meta-suppression', arguments: {} }) + const nested = await ctx.tools.execute({ + callId: CallId('nested'), + name: 'meta-suppression', + arguments: {}, + parent: Symbol('outer') as ToolExecutionToken, + }) + expect(direct.meta).toEqual({ card: true }) + expect(nested.meta).toBeUndefined() + expect(nested.isError ? undefined : nested.value).toBe('') + }) + it('returns isError results for unknown tools and throwing tools', async () => { const ctx = await setup() ctx.tools.register({ @@ -148,7 +442,10 @@ describe('ToolRegistry', () => { expect(unknown.isError).toBe(true) expect(unknown.content[0]).toMatchObject({ text: 'Error: unknown tool "nope"' }) // An unknown tool is a routable failure class, same as a tool-thrown one. - expect(unknown.error).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) + expect(unknown.error).toEqual({ + message: 'unknown tool "nope"', + info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }, + }) const thrown = await ctx.tools.execute({ callId: CallId('c2'), name: 'boom', arguments: {} }) expect(thrown.isError).toBe(true) @@ -189,15 +486,22 @@ describe('ToolRegistry', () => { it('lets a tools/pre-execute listener deny a call (permission pattern)', async () => { const ctx = await setup() ctx.tools.register(echoTool) + let postSawFrozen = false ctx.on('tools/pre-execute', async (exec, next): Promise => { if (exec.name === 'echo') return { kind: 'deny', reason: 'denied by policy' } return next() }) + ctx.on('tools/post-execute', async (_exec, result, next) => { + postSawFrozen = Object.isFrozen(result) + expect(Reflect.set(result, 'content', [])).toBe(false) + return next() + }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' }) + expect(postSawFrozen).toBe(true) }) it('an ask decision degrades to deny when no approval seam is mounted', async () => { @@ -378,7 +682,7 @@ describe('ToolRegistry', () => { it('preserves tool-deferred, execute-wrapper, and post-execute contexts in order', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'composite', description: 'composite', parameters: {}, @@ -422,7 +726,7 @@ describe('ToolRegistry', () => { it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'failing-composite', description: 'failing composite', parameters: {}, @@ -473,7 +777,7 @@ describe('ToolRegistry', () => { it('runs tools/execute after an allowed pre-execute, around dispatch, and before post-execute', async () => { const ctx = await setup() const order: string[] = [] - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'traced', description: 'echo', parameters: { text: { type: 'string' } }, @@ -493,7 +797,7 @@ describe('ToolRegistry', () => { ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } }) - expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false }) + expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: [{ type: 'text', text: 'hi' }] }) // The around seam wraps dispatch; pre gates before it, post runs over its result. expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post']) }) @@ -533,11 +837,35 @@ describe('ToolRegistry', () => { }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} }) - expect(seen).toEqual({ isError: true, error: { name: 'HarnessError', code: 'BOOM' } }) + expect(seen).toEqual({ + isError: true, + error: { message: 'kaboom', info: { name: 'HarnessError', code: 'BOOM' } }, + }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' }) }) + it('freezes core dispatch outcomes before around and post listeners can observe them', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + const mutationAttempts: boolean[] = [] + ctx.on('tools/execute', async (_exec, next) => { + const result = await next() + mutationAttempts.push(Reflect.set(result, 'value', 'around mutation')) + return result + }) + ctx.on('tools/post-execute', async (_exec, result, next) => { + mutationAttempts.push(Reflect.set(result, 'value', 'post mutation')) + return next() + }) + + const result = await ctx.tools.execute({ + callId: CallId('frozen-canonical'), name: 'echo', arguments: { text: 'original' }, + }) + expect(mutationAttempts).toEqual([false, false]) + expect(result.isError ? undefined : result.value).toBe('original') + }) + it('a thrown tool normalized inside tools/execute still reaches post-execute', async () => { const ctx = await setup() ctx.tools.register({ @@ -567,7 +895,7 @@ describe('ToolRegistry', () => { name: 'signal-probe', async execute(_args, exec) { seenSignal = exec.signal - return [{ type: 'text' as const, text: 'ok' }] + return 'ok' }, }) @@ -591,11 +919,11 @@ describe('ToolRegistry', () => { ctx.tools.register({ ...echoTool, name: 'never-runs', - async execute() { dispatched = true; return [] }, + async execute() { dispatched = true; return 'unreachable' }, }) ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise): Promise => - ({ content: [{ type: 'text', text: 'short-circuited' }], isError: false })) + ({ content: [{ type: 'text', text: 'ignored authored content' }], isError: false, value: 'short-circuited' })) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} }) expect(dispatched).toBe(false) // returning without next() skips core dispatch @@ -608,6 +936,7 @@ describe('ToolRegistry', () => { ctx.on('tools/execute', async () => ({ content: [{ type: 'text', text: 'short-circuited with context' }], isError: false, + value: 'short-circuited with context', additionalContexts: [{ content: [{ type: 'text', text: 'from around dispatch' }], source: { kind: 'plugin', plugin: 'test' }, @@ -631,6 +960,7 @@ describe('ToolRegistry', () => { const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: wrapper broke' }], + error: { message: 'wrapper broke' }, isError: true, }) }) @@ -646,6 +976,7 @@ describe('ToolRegistry', () => { expect(result).toEqual({ content: [{ type: 'text', text: 'Error: permission hook broke' }], + error: { message: 'permission hook broke' }, isError: true, }) }) @@ -661,6 +992,7 @@ describe('ToolRegistry', () => { expect(result).toEqual({ content: [{ type: 'text', text: 'Error: post hook broke' }], + error: { message: 'post hook broke' }, isError: true, }) }) @@ -676,7 +1008,7 @@ describe('ToolRegistry', () => { expect(result).toMatchObject({ isError: true, - error: { name: 'HarnessError', code: 'DENIED' }, + error: { message: 'denied', info: { name: 'HarnessError', code: 'DENIED' } }, }) }) @@ -839,10 +1171,14 @@ describe('defineTool / schema DSL', () => { text: { type: 'string', required: true }, uppercase: { type: 'boolean' }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { // args is typed: { text: string; uppercase?: boolean } const result = args.uppercase ? args.text.toUpperCase() : args.text - return [{ type: 'text', text: result }] + return result }, }) @@ -866,6 +1202,7 @@ describe('defineTool / schema DSL', () => { arguments: { text: 'hello', uppercase: true }, }) expect(result.isError).toBe(false) + expect(result.isError ? undefined : result.value).toBe('HELLO') expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }]) }) @@ -876,12 +1213,13 @@ describe('defineTool / schema DSL', () => { name: 'type-check', description: '', parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } }, + output: { schema: { type: 'string' }, render: () => [] }, async execute(args) { // Verify types at runtime via typeof expect(typeof args.a).toBe('string') // args.b should be undefined when not provided void args - return [{ type: 'text', text: args.a }] + return args.a }, }) void tool @@ -896,8 +1234,12 @@ describe('defineTool / schema DSL', () => { req: { type: 'string', required: true }, opt: { type: 'number', description: 'Optional number' }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { - return [{ type: 'text', text: `${args.req}:${args.opt ?? 'none'}` }] + return `${args.req}:${args.opt ?? 'none'}` }, })) @@ -933,9 +1275,13 @@ describe('defineTool / schema DSL', () => { properties: { path: { type: 'string' } }, required: ['path'], }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value as string }], + }, async execute(args: unknown) { const p = args as { path: string } - return [{ type: 'text', text: p.path }] + return p.path }, }) @@ -1284,7 +1630,7 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => { describe('defineTool validation (the runtime-validation Agent Note, part 1)', () => { it('returns an isError result with the violations when the model sends bad args', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'reader', description: 'reads a path', parameters: { path: { type: 'string', required: true } }, @@ -1302,7 +1648,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () it('runs execute normally when args are valid', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'reader', description: 'reads a path', parameters: { path: { type: 'string', required: true } }, @@ -1311,7 +1657,11 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }, })) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } }) - expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false }) + expect(result).toEqual({ + content: [{ type: 'text', text: 'read /x' }], + isError: false, + value: [{ type: 'text', text: 'read /x' }], + }) }) it('ToolArgsError carries a stable code and the violation list', () => { @@ -1325,7 +1675,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () it('a schema-invalid call surfaces the structured error on the result', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'reader', description: 'reads a path', parameters: { path: { type: 'string', required: true } }, @@ -1335,7 +1685,10 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () })) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} }) expect(result.isError).toBe(true) - expect(result.error).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) + expect(result.error).toEqual({ + message: 'invalid arguments: missing required property "path"', + info: { name: 'ToolArgsError', code: 'INVALID_ARGS' }, + }) }) it('a tool throwing a HarnessError surfaces its name and code', async () => { @@ -1350,11 +1703,11 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'coded', arguments: {} }) expect(result.isError).toBe(true) - expect(result.error).toEqual({ name: 'HarnessError', code: 'ENOSPC' }) + expect(result.error).toEqual({ message: 'disk full', info: { name: 'HarnessError', code: 'ENOSPC' } }) expect(result.content[0]).toMatchObject({ text: 'Error: disk full' }) }) - it('a non-HarnessError throw has no structured error (only the text)', async () => { + it('a non-HarnessError throw retains only its message', async () => { const ctx = await setup() ctx.tools.register({ ...echoTool, @@ -1365,7 +1718,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'plain', arguments: {} }) expect(result.isError).toBe(true) - expect(result.error).toBeUndefined() + expect(result.error).toEqual({ message: 'just a message' }) expect(result.content[0]).toMatchObject({ text: 'Error: just a message' }) }) @@ -1376,8 +1729,12 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () name: 'raw', description: 'raw tool', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value as string }], + }, async execute(args: unknown) { - return [{ type: 'text', text: typeof args }] + return typeof args }, }) // Missing the "required" path — but raw tools validate their own input, so @@ -1387,7 +1744,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) it('attaches a positive-finite timeoutMs to the definition', () => { - const tool = defineTool({ + const tool = defineContentToolFixture({ name: 'x', description: 'd', parameters: {}, timeoutMs: 30_000, async execute() { return [{ type: 'text' as const, text: 'ok' }] }, }) @@ -1395,7 +1752,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) it('omits timeoutMs when not declared', () => { - const tool = defineTool({ + const tool = defineContentToolFixture({ name: 'x', description: 'd', parameters: {}, async execute() { return [{ type: 'text' as const, text: 'ok' }] }, }) @@ -1403,7 +1760,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) it('throws when timeoutMs is zero or negative', () => { - const make = (ms: number) => defineTool({ + const make = (ms: number) => defineContentToolFixture({ name: 'x', description: 'd', parameters: {}, timeoutMs: ms, async execute() { return [{ type: 'text' as const, text: 'ok' }] }, }) @@ -1412,7 +1769,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) it('throws when timeoutMs is non-finite', () => { - expect(() => defineTool({ + expect(() => defineContentToolFixture({ name: 'x', description: 'd', parameters: {}, timeoutMs: Infinity, async execute() { return [{ type: 'text' as const, text: 'ok' }] }, })).toThrow('positive finite number') @@ -1421,7 +1778,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () describe('defineTool presentation (presentCall / presentResult)', () => { it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => { - const tool = defineTool({ + const tool = defineContentToolFixture({ name: 'demo', description: 'demo', parameters: { path: { type: 'string', required: true }, n: { type: 'number' } }, @@ -1441,7 +1798,7 @@ describe('defineTool presentation (presentCall / presentResult)', () => { }) it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => { - const tool = defineTool({ + const tool = defineContentToolFixture({ name: 'plain', description: 'plain', parameters: { x: { type: 'string', required: true } }, @@ -1452,7 +1809,7 @@ describe('defineTool presentation (presentCall / presentResult)', () => { }) it('presentCall/presentResult validate softly: malformed args return undefined, never throw (display runs on replay)', () => { - const tool = defineTool({ + const tool = defineContentToolFixture({ name: 'demo', description: 'demo', parameters: { path: { type: 'string', required: true } }, diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 52ad60449e..08c3927fe5 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -203,7 +203,8 @@ describe('dsh-acp-demo composition', () => { name, description: name, parameters: {}, - execute: async () => [], + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, }) } const assembly = await ctx.get('systemPrompt')!.assemble() diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index c8788ca01b..19c35b5624 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -494,7 +494,8 @@ describe('dsh-agent-spine-demo bundle', () => { name, description: name, parameters: {}, - execute: async () => [], + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, }) } const assembly = await ctx.get('systemPrompt')!.assemble() diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index c248242ad1..1433645eb5 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -95,7 +95,13 @@ describe('dsh-cli-demo app composition', () => { }) ctx.skills.register({ name: 'cli-skill', description: 'CLI skill', source: 'runtime', content: 'body' }) for (const name of ['alpha', 'zulu']) { - ctx.tools.register({ name, description: name, parameters: {}, execute: async () => [] }) + ctx.tools.register({ + name, + description: name, + parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, + }) } expect(JSON.stringify(await composePrefix(ctx))).toContain('- `cli-skill`: CLI...') expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual([ diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index f038efe67e..8de89e63e6 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -115,7 +115,11 @@ async function harness(script: readonly ScriptEntry[]): Promise { name: 'echo', description: 'Echo text.', parameters: { text: { type: 'string', required: true } }, - execute: async args => [{ type: 'text', text: `ECHO: ${(args as { text: string }).text}` }], + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value as string }], + }, + execute: async args => `ECHO: ${(args as { text: string }).text}`, }) const [agent] = ctx.agents.roots() if (agent === undefined) throw new Error('test main agent missing') diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 7b254fd59e..5d0655bcc9 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-tool-fs-search -The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. +The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `bash`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. ```ts ignore-check // Default deployment: a bash executor whose PATH includes rg, then the discovery tools. @@ -39,7 +39,7 @@ Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`c ## Two budgets, two artifacts -Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. The model-facing recovery artifact is different: when a search yields more logical results than the inline cap, the tool saves the COMPLETE formatted result through `ctx.spillStore.saveText()` (suggested names `glob-results.txt` / `grep-results.txt`, owner = the calling session, source = the tool execution identity) and appends a footer naming the returned locator and retrieval hint. This is the first tool-owned spill call in the codebase — deliberate, because retention here is item-level: the generic `@deepseek-ai/dsh-spill-policy` only sees the final text on `tools/post-execute`, by which point a capped search has already omitted later paths/matches. A missing spill backend, a call with no session owner, or a `saveText()` failure keeps the inline page and reports that the complete result could not be saved — never an `isError`. +Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps every acquired path in `{ paths }`; `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with a head page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`. ## Errors diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index a3e803fb50..6d42acee66 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -12,7 +12,6 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { ItemRetainer } from '@deepseek-ai/dsh-retention' import type { RetainedItems } from '@deepseek-ai/dsh-retention' import type { SpillRef } from '@deepseek-ai/dsh-spill' @@ -20,6 +19,7 @@ import type {} from '@deepseek-ai/dsh-bash' import type {} from '@deepseek-ai/dsh-system-prompt' import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' import { singleQuote } from './shell-quote.ts' +import { acceptedSurfaceValue } from './surface.ts' /** * Default cap on paths retained inline by one `glob` call (the `globMaxResults` @@ -117,6 +117,14 @@ export function formatGlobOutput(retained: RetainedItems, spillRef: Spil return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})` } +/** Retain and format one canonical path list for the Native surface. */ +function renderGlobPaths(paths: string[], maxResults: number, spillRef?: SpillRef): string { + if (paths.length === 0) return 'No files found' + const retainer = new ItemRetainer({ kind: 'head', maxItems: maxResults }) + for (const path of paths) retainer.push(path) + return formatGlobOutput(retainer.finish(), spillRef) +} + /** * Pending-call presentation: a search card titled by the pattern (and root). * @@ -142,7 +150,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { text: 'Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.', }) - ctx.tools.register(defineTool({ + const tool = defineTool({ name: 'glob', description: 'Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, ' + 'including hidden and ignored files (VCS metadata directories are excluded). ' @@ -152,28 +160,44 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { path: { type: 'string', description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.' }, }, timeoutMs: caps.timeoutMs, - async execute(args, exec): Promise { + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + paths: { type: 'array', required: true, items: { type: 'string' } }, + }, + }, + render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults) }], + }, + async execute(args, exec) { const input = parseGlobArgs(args) const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes) - if (run.noMatches) return [{ type: 'text', text: 'No files found' }] + if (run.noMatches) return { paths: [] } - const retainer = new ItemRetainer({ kind: 'head', maxItems: caps.maxResults }) const all: string[] = [] for (const line of run.stdout.split('\n')) { if (line.length === 0) continue const displayPath = toWorkdirRelative(line, run.workdir) all.push(displayPath) - retainer.push(displayPath) } - const retained = retainer.finish() - - // The complete sorted list is the recovery artifact; save it only when - // the inline page omitted paths (an uncapped result needs no spill file). - const spillRef = retained.truncated - ? await trySaveFormattedResult(ctx, exec, 'glob-results.txt', all.join('\n')) - : undefined - return [{ type: 'text', text: formatGlobOutput(retained, spillRef) }] + return { paths: all } }, presentCall: presentGlobCall, - })) + }) + ctx.tools.register(tool) + + ctx.on('tools/post-execute', async (exec, result, next) => { + const decision = await next() + const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { paths: string[] } | undefined + if (value === undefined) return decision + const paths = value.paths + if (paths.length <= caps.maxResults) return decision + const spillRef = await trySaveFormattedResult(ctx, exec, 'glob-results.txt', paths.join('\n')) + return { + kind: 'accept', + content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, spillRef) }], + ...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {}, + } + }) } diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index 3935513b73..aa82749f3f 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -13,7 +13,6 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention' import type { RetainedItems } from '@deepseek-ai/dsh-retention' import type { SpillRef } from '@deepseek-ai/dsh-spill' @@ -21,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-bash' import type {} from '@deepseek-ai/dsh-system-prompt' import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' import { singleQuote } from './shell-quote.ts' +import { acceptedSurfaceValue } from './surface.ts' /** * Default cap on flat matches retained inline by one `grep` call (the @@ -241,6 +241,20 @@ export function formatGrepOutput(retained: RetainedItems, spillRef: S return `${header}\n\n${body}\n\n(${recovery})` } +/** Apply the Native per-line preview budget without changing the canonical matches. */ +function previewGrepMatches(matches: GrepMatch[], maxLineBytes: number): GrepMatch[] { + return matches.map(match => ({ ...match, line: previewLine(match.line, maxLineBytes) })) +} + +/** Retain and format one canonical match list for the Native surface. */ +function renderGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number, spillRef?: SpillRef): string { + if (matches.length === 0) return 'No matches found' + const previewed = previewGrepMatches(matches, maxLineBytes) + const retainer = new ItemRetainer({ kind: 'head', maxItems: maxMatches }) + for (const match of previewed) retainer.push(match) + return formatGrepOutput(retainer.finish(), spillRef) +} + /** * Pending-call presentation: a search card titled by the pattern (and target / * include filter). @@ -268,7 +282,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { text: 'Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.', }) - ctx.tools.register(defineTool({ + const tool = defineTool({ name: 'grep', description: 'Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. ' + `Returns the first ${caps.maxMatches} matches inline; a capped result reports where the complete match list was saved. ` @@ -279,37 +293,70 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { include: { type: 'string', description: 'One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported.' }, }, timeoutMs: caps.timeoutMs, - async execute(args, exec): Promise { + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + matches: { + type: 'array', + required: true, + items: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + lineNumber: { type: 'integer', required: true }, + line: { type: 'string', required: true }, + }, + }, + }, + }, + }, + render: (_args, value) => [{ + type: 'text', + text: renderGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes), + }], + }, + async execute(args, exec) { const input = parseGrepArgs(args) const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes) - if (run.noMatches) return [{ type: 'text', text: 'No matches found' }] + if (run.noMatches) return { matches: [] } - const retainer = new ItemRetainer({ kind: 'head', maxItems: caps.maxMatches }) const all: GrepMatch[] = [] for (const raw of parseGrepMatches(run.stdout)) { const match: GrepMatch = { path: toWorkdirRelative(raw.path, run.workdir), lineNumber: raw.lineNumber, - line: previewLine(raw.line, caps.maxLineBytes), + line: raw.line, } all.push(match) - retainer.push(match) } - const retained = retainer.finish() - - // The spill file stores the FULL formatted match list (same grouped, - // per-line-previewed shape the model saw), so read offset/limit pages the - // same logical result; save only when the inline page omitted matches. - const spillRef = retained.truncated - ? await trySaveFormattedResult( - ctx, - exec, - 'grep-results.txt', - `Found ${all.length} ${matchNoun(all.length)}\n\n${formatGrepMatches(all)}`, - ) - : undefined - return [{ type: 'text', text: formatGrepOutput(retained, spillRef) }] + return { matches: all } }, presentCall: presentGrepCall, - })) + }) + ctx.tools.register(tool) + + ctx.on('tools/post-execute', async (exec, result, next) => { + const decision = await next() + const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { matches: GrepMatch[] } | undefined + if (value === undefined) return decision + const matches = value.matches + if (matches.length <= caps.maxMatches) return decision + const spillRef = await trySaveFormattedResult( + ctx, + exec, + 'grep-results.txt', + `Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewGrepMatches(matches, caps.maxLineBytes))}`, + ) + return { + kind: 'accept', + content: [{ + type: 'text', + text: renderGrepMatches(matches, caps.maxMatches, caps.maxLineBytes, spillRef), + }], + ...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {}, + } + }) } diff --git a/packages/fs/tool-fs-search/src/surface.ts b/packages/fs/tool-fs-search/src/surface.ts new file mode 100644 index 0000000000..78bdd6cc42 --- /dev/null +++ b/packages/fs/tool-fs-search/src/surface.ts @@ -0,0 +1,27 @@ +/** Shared surface-only post-policy selection for search result spill. @module dsh-tool-fs-search/surface */ + +import type { Context } from 'cordis' +import type { JsonValue, PostToolDecision, ToolDefinition, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' + +/** + * Return the accepted canonical value only when this tool still owns a direct + * successful surface call and no downstream policy replaced either projection. + * @param ctx - the tool plugin context used to resolve the live scoped owner. + * @param tool - the exact registered definition whose value may be projected. + * @param exec - the completed execution identity. + * @param result - the canonical result before post-policy decisions are applied. + * @param decision - the composed downstream post-policy decision. + * @returns the canonical value to project, or `undefined` when spill must defer. + */ +export function acceptedSurfaceValue( + ctx: Context, + tool: ToolDefinition, + exec: ToolExecution, + result: ToolExecutionResult, + decision: PostToolDecision, +): JsonValue | undefined { + if (decision.kind !== 'accept' || decision.content !== undefined || Object.hasOwn(decision, 'value') + || exec.parent !== undefined || exec.name !== tool.name || result.isError + || ctx.tools.get(exec.name, exec.agent) !== tool) return undefined + return result.value +} diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index 36fb2c28e6..31c30d5b15 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -96,7 +96,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => { const result = await call('glob', { pattern: '[' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } }) }) }) @@ -139,13 +139,13 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () it('classifies a real rg regex error as SEARCH_INVALID_PATTERN', async () => { const result = await call('grep', { pattern: '(unclosed' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) }) it('classifies a missing target as SEARCH_FAILED', async () => { const result = await call('grep', { pattern: 'x', path: 'no-such-dir' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) }) }) @@ -176,14 +176,14 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () signal: controller.signal, }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) }) it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => { const gone = join(dir, 'deleted-session-dir') const result = await call('glob', { pattern: '*' }, { session: { header: { id: 'session-int', cwd: gone } } }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('could not start') }) }) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index c11b10556a..146a0e1166 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { type ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' @@ -145,13 +145,19 @@ async function expectSetupRejects(options: SetupOptions, message: RegExp): Promi const agent = (cwd?: string) => ({ session: { header: { id: 'session-1', ...cwd !== undefined ? { cwd } : {} } } }) let callCounter = 0 -function call(ctx: Context, name: string, args: unknown, options: { agent?: object; signal?: AbortSignal } = {}) { +function call( + ctx: Context, + name: string, + args: unknown, + options: { agent?: object; signal?: AbortSignal; parent?: ToolExecutionToken } = {}, +) { return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...options.agent ? { agent: options.agent as never } : {}, ...options.signal ? { signal: options.signal } : {}, + ...options.parent ? { parent: options.parent } : {}, }) } @@ -310,7 +316,7 @@ describe('workdir derivation and signal forwarding', () => { const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) expect(bash.specs[0]?.signal).toBe(controller.signal) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) expect(text(result)).toContain('aborted') }) @@ -319,7 +325,7 @@ describe('workdir derivation and signal forwarding', () => { bash.handler = () => runResult('', { timedOut: true, timeoutMs: 1234, exitCode: null, signal: 'SIGTERM' }) const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'SEARCH_ABORTED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_ABORTED' } }) expect(text(result)).toContain('timed out after 1234ms') }) @@ -332,7 +338,7 @@ describe('workdir derivation and signal forwarding', () => { bash.handler = () => { throw new Error('aborted before spawn') } const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) }) it('translates a run() rejection without an abort (unusable workdir) into SEARCH_FAILED', async () => { @@ -340,7 +346,7 @@ describe('workdir derivation and signal forwarding', () => { bash.handler = () => { throw new Error('spawn bash ENOENT') } const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('could not start') }) }) @@ -361,7 +367,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: regex parse error:\n (\nerror: unclosed group', truncated: false } }) const result = await call(ctx, 'grep', { pattern: '(' }) - expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) expect(text(result)).toContain('regex parse error') }) @@ -369,14 +375,14 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: error parsing glob \'[\': unclosed character class', truncated: false } }) const result = await call(ctx, 'glob', { pattern: '[' }) - expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) }) it('a missing rg binary classifies as SEARCH_FAILED naming ripgrep', async () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 127, stderr: { text: 'bash: line 1: rg: command not found', truncated: false } }) const result = await call(ctx, 'glob', { pattern: '*' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('requires ripgrep (rg)') // The same classification holds from either evidence alone: the 127 exit // with silent stderr, or a shell's command-not-found text on another exit. @@ -390,7 +396,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory', truncated: false } }) const result = await call(ctx, 'grep', { pattern: 'x', path: 'missing.dir' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('IO error') }) @@ -398,7 +404,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 3 }) const result = await call(ctx, 'glob', { pattern: '*' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('exit 3') }) @@ -416,7 +422,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: null, signal: 'SIGKILL' }) const result = await call(ctx, 'grep', { pattern: 'x' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('SIGKILL') }) @@ -424,7 +430,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: null, signal: null }) const result = await call(ctx, 'glob', { pattern: '*' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) }) }) @@ -442,7 +448,7 @@ describe('raw output acquisition', () => { const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } }) bash.handler = () => runResult('', { stdout: { text: 'x', truncated: true, spillPath: '/does/not/get-read' } }) const result = await call(ctx, 'glob', { pattern: '*' }) - expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) expect(text(result)).toContain('narrow pattern, path, or include') }) @@ -453,7 +459,7 @@ describe('raw output acquisition', () => { const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } }) bash.handler = () => runResult(`${'x'.repeat(64)}\n`) const result = await call(ctx, 'grep', { pattern: 'x' }) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) expect(text(result)).toContain('narrow pattern, path, or include') }) @@ -461,7 +467,7 @@ describe('raw output acquisition', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { stdout: { text: 'partial', truncated: true } }) const result = await call(ctx, 'grep', { pattern: 'x' }) - expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) }) }) @@ -470,6 +476,8 @@ describe('glob results', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n') const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') }) + if (result.isError) throw new Error('expected glob success') + expect(result.value).toEqual({ paths: ['src/a.ts', '/elsewhere/b.ts', 'rel/c.ts'] }) expect(text(result)).toBe('src/a.ts\n/elsewhere/b.ts\nrel/c.ts') }) @@ -489,9 +497,15 @@ describe('glob results', () => { it('caps at globMaxResults and saves the FULL sorted list through spillStore', async () => { const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept', + additionalContexts: [{ content: [{ type: 'text', text: 'glob context' }], source: { kind: 'plugin', plugin: 'test' } }], + })) bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected glob success') + expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] }) expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)') expect(spill?.saves).toHaveLength(1) expect(spill?.saves[0]).toMatchObject({ @@ -501,6 +515,7 @@ describe('glob results', () => { content: 'a.ts\nb.ts\nc.ts\nd.ts', }) expect(spill?.saves[0]?.source.callId).toBeDefined() + expect(result.additionalContexts?.[0]?.content).toEqual([{ type: 'text', text: 'glob context' }]) }) it('does not create a spill file when the result fits inline', async () => { @@ -511,6 +526,36 @@ describe('glob results', () => { expect(spill?.saves).toHaveLength(0) }) + it('preserves a downstream canonical value replacement instead of spilling the old value', async () => { + const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: true }) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept' as const, + value: { paths: ['replacement-a.ts', 'replacement-b.ts'] }, + })) + bash.handler = () => runResult('old-a.ts\nold-b.ts\n') + + const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) + + if (result.isError) throw new Error('expected glob replacement success') + expect(result.value).toEqual({ paths: ['replacement-a.ts', 'replacement-b.ts'] }) + expect(text(result)).toContain('replacement-a.ts') + expect(text(result)).not.toContain('old-a.ts') + expect(spill?.saves).toHaveLength(0) + }) + + it('keeps the full nested Code value without creating a surface spill', async () => { + const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) + bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') + const result = await call(ctx, 'glob', { pattern: '*.ts' }, { + agent: agent('/w'), + parent: Symbol('run_code') as ToolExecutionToken, + }) + if (result.isError) throw new Error('expected glob success') + expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] }) + expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. The complete result could not be saved; narrow pattern or path to see more.)') + expect(spill?.saves).toHaveLength(0) + }) + it.each([ ['no spill backend loaded', { fail: false, spill: false, ownerless: false }], ['saveText fails', { fail: true, spill: true, ownerless: false }], @@ -539,6 +584,14 @@ describe('grep results', () => { ].join('\n')) const result = await call(ctx, 'grep', { pattern: 'const' }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected grep success') + expect(result.value).toEqual({ + matches: [ + { path: 'a.ts', lineNumber: 3, line: 'const x = 1' }, + { path: 'a.ts', lineNumber: 9, line: 'const y = 2' }, + { path: 'b.ts', lineNumber: 1, line: 'const z = 3' }, + ], + }) expect(text(result)).toBe('Found 3 matches\n\na.ts\nLine 3: const x = 1\nLine 9: const y = 2\n\nb.ts\nLine 1: const z = 3') }) @@ -561,6 +614,8 @@ describe('grep results', () => { // Use a multibyte straddle instead: 'aé' repeated — cut at 7 bytes: a(1)é(2)a(1)é(2)=6 +a(1)=7 → next é straddles: trimmed. bash.handler = () => runResult(`${matchLine('a.txt', 1, 'aéaéaéaé')}\n`) const result = await call(ctx, 'grep', { pattern: 'a' }) + if (result.isError) throw new Error('expected grep success') + expect(result.value).toEqual({ matches: [{ path: 'a.txt', lineNumber: 1, line: 'aéaéaéaé' }] }) expect(text(result)).toContain('Line 1: aéaéa (line truncated)') }) @@ -578,6 +633,10 @@ describe('grep results', () => { it('caps at grepMaxMatches and spills the full formatted match list', async () => { const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 2 }, spill: true }) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept', + additionalContexts: [{ content: [{ type: 'text', text: 'grep context' }], source: { kind: 'plugin', plugin: 'test' } }], + })) bash.handler = () => runResult([ matchLine('a.ts', 1, 'one'), matchLine('a.ts', 2, 'two'), @@ -585,12 +644,66 @@ describe('grep results', () => { '', ].join('\n')) const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') }) + if (result.isError) throw new Error('expected grep success') + expect(result.value).toEqual({ + matches: [ + { path: 'a.ts', lineNumber: 1, line: 'one' }, + { path: 'a.ts', lineNumber: 2, line: 'two' }, + { path: 'b.ts', lineNumber: 3, line: 'three' }, + ], + }) expect(text(result)).toBe('Found 2 of 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\n(Full grep result stored at: /spill/grep-results.txt. Use the fake retrieval hint.)') expect(spill?.saves[0]).toMatchObject({ source: { toolName: 'grep', label: 'result' }, suggestedName: 'grep-results.txt', content: 'Found 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\nb.ts\nLine 3: three', }) + expect(result.additionalContexts?.[0]?.content).toEqual([{ type: 'text', text: 'grep context' }]) + }) + + it('preserves a downstream canonical value replacement instead of spilling the old matches', async () => { + const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true }) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept' as const, + value: { + matches: [ + { path: 'replacement.ts', lineNumber: 7, line: 'first' }, + { path: 'replacement.ts', lineNumber: 8, line: 'second' }, + ], + }, + })) + bash.handler = () => runResult(`${matchLine('old.ts', 1, 'old')}\n`) + + const result = await call(ctx, 'grep', { pattern: 'old' }, { agent: agent('/w') }) + + if (result.isError) throw new Error('expected grep replacement success') + expect(result.value).toEqual({ + matches: [ + { path: 'replacement.ts', lineNumber: 7, line: 'first' }, + { path: 'replacement.ts', lineNumber: 8, line: 'second' }, + ], + }) + expect(text(result)).toContain('replacement.ts') + expect(text(result)).not.toContain('old.ts') + expect(spill?.saves).toHaveLength(0) + }) + + it('keeps every nested Code match in the value without creating a surface spill', async () => { + const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true }) + bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('b.ts', 2, 'two')}\n`) + const result = await call(ctx, 'grep', { pattern: 'o' }, { + agent: agent('/w'), + parent: Symbol('run_code') as ToolExecutionToken, + }) + if (result.isError) throw new Error('expected grep success') + expect(result.value).toEqual({ + matches: [ + { path: 'a.ts', lineNumber: 1, line: 'one' }, + { path: 'b.ts', lineNumber: 2, line: 'two' }, + ], + }) + expect(text(result)).toBe('Found 1 of 2 matches\n\na.ts\nLine 1: one\n\n(The complete result could not be saved; narrow pattern, path, or include to see more.)') + expect(spill?.saves).toHaveLength(0) }) it('reports the unsaved remainder when capped with no spill backend', async () => { @@ -633,7 +746,7 @@ describe('rg --json transport failures (SEARCH_FAILED)', () => { bash.handler = () => runResult(`${line}\n`) const result = await call(ctx, 'grep', { pattern: 'x' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) }) }) diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 6dd22d384f..a99316c181 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -32,6 +32,8 @@ All keys are optional; the defaults are the shipped read caps. Field names are snake_case to match Claude Code and existing harness tool schemas. +Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`. + ## The tool is the executor; policy is an event gate The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash`, and forwarding tool cancellation through resolution (see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then: diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 46655f80b7..e2895c5cf4 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -8,10 +8,9 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' -import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' +import { computeHunkDiffs, diffsFromMeta } from './diff.ts' import { sessionResolveOptions } from './session-cwd.ts' import type { FsSandboxSurface } from './sandbox.ts' @@ -90,7 +89,26 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' }, ...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}, }, - async execute(args: EditToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + before: { type: 'string', required: true }, + after: { type: 'string', required: true }, + }, + }, + render: (args, value) => [{ + type: 'text', + text: formatEditOutput(value.path, args.replace_all ?? false), + }], + presentationMeta: (args, value) => ({ + diffs: computeHunkDiffs(args.file_path, value.before, value.after) + .map(({ path, oldText, newText }) => ({ path, oldText, newText })), + }), + }, + async execute(args: EditToolArgs, exec) { const input = parseEditArgs(args) // Resolve the per-call sandbox mode (escalation grant > session override // > backend default) BEFORE anything executes. @@ -115,11 +133,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) - // An edit necessarily changes content, so result metadata carries at least one applied hunk. - const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after) return { - content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }], - meta: { diffs }, + path: target.displayPath, + before: outcome.before, + after: outcome.after, } }, // Pure display: a diff card of the literal replacement (old_string → new_string), derived diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 943ff98f61..de9e530a13 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -77,6 +77,7 @@ function lineByteSize(line: string, currentLineCount: number): number { function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void { acc.totalLines += 1 + if (acc.done) return if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return const text = truncateLine(rawLine, request.maxLineLength) @@ -137,7 +138,6 @@ export async function buildWindow( appendToLineBuffer(chunk.slice(startPos, newlinePos)) flushLine() startPos = newlinePos + 1 - if (acc.done) return finish(acc, request, displayPath) } appendToLineBuffer(chunk.slice(startPos)) } diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index a19b073514..ea709f1172 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -7,12 +7,10 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { FsError } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { buildWindow, formatReadOutput } from './read-render.ts' -import type { FileReadOutcome } from './read-render.ts' import { sessionResolveOptions } from './session-cwd.ts' /** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */ @@ -84,9 +82,46 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' }, limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + offset: { type: 'integer', required: true }, + lines: { + type: 'array', + required: true, + items: { + type: 'object', + additionalProperties: false, + properties: { + number: { type: 'integer', required: true }, + text: { type: 'string', required: true }, + }, + }, + }, + totalLines: { type: 'integer', required: true }, + }, + }, + render: (args, value) => { + const input = parseReadArgs(args, caps.limit) + const endLine = value.lines.at(-1)?.number ?? Math.max(0, value.offset - 1) + const truncatedByBytes = value.lines.length < input.limit && endLine < value.totalLines + return [{ + type: 'text', + text: formatReadOutput(value.path, { + offset: value.offset, + lines: value.lines, + totalLines: value.totalLines, + ...truncatedByBytes ? { truncatedByBytes: true } : {}, + }), + }] + }, + }, // Observation races fail closed because guarded mutations re-check the version in-lock. isConcurrencySafe: () => true, - async execute(args, exec): Promise { + async execute(args, exec) { const input = parseReadArgs(args, caps.limit) const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec)) @@ -107,17 +142,17 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { target.displayPath, ) - const outcome: FileReadOutcome = { + const outcome = { + path: target.displayPath, offset: input.offset, lines: window.lines, totalLines: window.totalLines, - ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, } // Record the observed version (a no-op when no policy plugin listens). The // read already succeeded; an fs/observed listener is contractually a // synchronous, side-effect-only recorder. ctx.emit('fs/observed', target, info.version, exec) - return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }] + return outcome }, // Pure display: a generic card titled by the file with the read window appended (`Read // foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 3f23e9b5ea..b541c1c2e0 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -8,11 +8,10 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' -import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' +import { computeHunkDiffs, diffsFromMeta } from './diff.ts' import { sessionResolveOptions } from './session-cwd.ts' import type { FsSandboxSurface } from './sandbox.ts' @@ -33,7 +32,7 @@ export function parseWriteArgs(args: { file_path: string; content: string }): { * @param outcome - the write outcome; its `operation` selects the Created/Updated wording. * @returns the model-facing confirmation envelope (no file content is echoed back). */ -export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string { +export function formatWriteOutput(displayPath: string, outcome: Pick): string { const verb = outcome.operation === 'create' ? 'Created' : 'Updated' return `${displayPath} file @@ -74,7 +73,32 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void { content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' }, ...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}, }, - async execute(args: WriteToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + operation: { type: 'string', required: true, enum: ['create', 'update'] }, + before: { + required: true, + oneOf: [ + { type: 'string' }, + { type: 'null' }, + ], + }, + after: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: formatWriteOutput(value.path, value) }], + presentationMeta: (args, value) => ({ + diffs: value.before === null + ? [] + : computeHunkDiffs(args.file_path, value.before, value.after) + .map(({ path, oldText, newText }) => ({ path, oldText, newText })), + }), + }, + async execute(args: WriteToolArgs, exec) { const input = parseWriteArgs(args) // Resolve the per-call sandbox mode (escalation grant > session override // > backend default) BEFORE anything executes; an escalating call @@ -94,12 +118,11 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void { } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) - // Overwrites carry applied hunks. Creates have no prior text, so result presentation uses - // the args-derived whole-file diff instead. - const diffs = outcome.before !== null ? computeHunkDiffs(input.filePath, outcome.before, outcome.after) : [] return { - content: [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }], - ...diffs.length > 0 ? { meta: { diffs } } : {}, + path: target.displayPath, + operation: outcome.operation, + before: outcome.before, + after: outcome.after, } }, // Pure display: a diff card (an editor renders write as a new-file / full- replace diff). diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 6a9e6f3568..05056cada2 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -67,7 +67,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'a.txt'), 'original') const result = await call('write', { file_path: 'a.txt', content: 'clobber' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original') }) @@ -85,7 +85,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change const result = await call('write', { file_path: 'a.txt', content: 'replaced' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) }) }) @@ -102,7 +102,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02])) const result = await call('read', { file_path: 'bin' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_TEXT' } }) }) it('paginates a multi-line file with offset/limit', async () => { @@ -127,7 +127,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'a.txt'), 'hello world') const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') }) @@ -151,7 +151,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world' const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) }) it('rejects an ambiguous match without replace_all', async () => { @@ -159,7 +159,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await call('read', { file_path: 'a.txt' }) const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' }) + expect(result.error).toMatchObject({ info: { code: 'FS_AMBIGUOUS_EDIT' } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a') }) @@ -187,7 +187,7 @@ describe('default deployment (with dsh-fs-policy)', () => { // The model-facing edit still rejects: the read did not emit fs/observed. const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) }) }) @@ -260,14 +260,14 @@ describe('bare provider (no dsh-fs-policy)', () => { it('edit of a MISSING target reports FS_STALE_VERSION even on the unguarded path', async () => { const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) }) it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => { await writeFile(join(dir, 'a.txt'), 'hello world') const result = await call('edit', { file_path: 'a.txt', old_string: 'absent', new_string: 'x' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) + expect(result.error).toMatchObject({ info: { code: 'FS_EDIT_NOT_FOUND' } }) }) it('neither write nor edit stats in the tool on the bare path', async () => { @@ -350,11 +350,11 @@ describe('signal, concurrency, and the fs/observed contract', () => { await writeFile(join(dir, 'a.txt'), 'hello') const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' }) expect(read.isError).toBe(true) - expect(read.error).toMatchObject({ code: 'FS_ABORTED' }) + expect(read.error).toMatchObject({ info: { code: 'FS_ABORTED' } }) const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' }) expect(write.isError).toBe(true) - expect(write.error).toMatchObject({ code: 'FS_ABORTED' }) + expect(write.error).toMatchObject({ info: { code: 'FS_ABORTED' } }) await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) // Read first (un-aborted, SAME session owner) so the edit clears the @@ -363,7 +363,7 @@ describe('signal, concurrency, and the fs/observed contract', () => { expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false) const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }) expect(edit.isError).toBe(true) - expect(edit.error).toMatchObject({ code: 'FS_ABORTED' }) + expect(edit.error).toMatchObject({ info: { code: 'FS_ABORTED' } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged }) @@ -378,7 +378,7 @@ describe('signal, concurrency, and the fs/observed contract', () => { ]) const errors = [one, two].filter(r => r.isError) expect(errors).toHaveLength(1) - expect(errors[0]?.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(errors[0]?.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) // The world is consistent: exactly one edit landed. const onDisk = await readFile(join(dir, 'a.txt'), 'utf8') expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true) @@ -407,7 +407,7 @@ describe('signal, concurrency, and the fs/observed contract', () => { new_string: 'edited', }) expect(edit.isError).toBe(true) - expect(edit.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('newer current content\n') }) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 4d80061584..b94bcb9486 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -161,6 +161,13 @@ describe('read tool', () => { fs.files.set('key:a.txt', 'hello\nworld') const result = await call(ctx, 'read', { file_path: 'a.txt' }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected read success') + expect(result.value).toEqual({ + path: '/abs/a.txt', + offset: 1, + lines: [{ number: 1, text: 'hello' }, { number: 2, text: 'world' }], + totalLines: 2, + }) expect(text(result)).toBe(`/abs/a.txt file @@ -171,6 +178,15 @@ describe('read tool', () => { `) }) + it('returns an explicit empty canonical line window for an empty file', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:empty.txt', '') + const result = await call(ctx, 'read', { file_path: 'empty.txt' }) + if (result.isError) throw new Error('expected empty read success') + expect(result.value).toEqual({ path: '/abs/empty.txt', offset: 1, lines: [], totalLines: 0 }) + expect(text(result)).toContain('(End of file - total 0 lines)') + }) + it('rejects a non-positive offset via arg validation', async () => { const { ctx } = await setup() const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 }) @@ -226,7 +242,7 @@ describe('read tool', () => { const { ctx } = await setup() const result = await call(ctx, 'read', { file_path: 'missing.txt' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } }) }) it('rejects a non-regular target', async () => { @@ -235,7 +251,7 @@ describe('read tool', () => { fs.stat = async () => ({ version: FsVersion('v1'), type: 'directory' }) const result = await call(ctx, 'read', { file_path: 'd' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) }) it('streams a large file (size at/above the cap) instead of reading whole', async () => { @@ -301,6 +317,8 @@ describe('write tool', () => { const { ctx, fs } = await setup() const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: { header: {} } }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected write success') + expect(result.value).toEqual({ path: '/abs/a.txt', operation: 'create', before: null, after: 'hi' }) expect(text(result)).toContain('Created file') expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }]) }) @@ -317,7 +335,7 @@ describe('write tool', () => { fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION') const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'FsError', code: 'FS_STALE_VERSION' }) + expect(result.error).toMatchObject({ info: { name: 'FsError', code: 'FS_STALE_VERSION' } }) }) }) @@ -328,6 +346,8 @@ describe('edit tool', () => { fs.files.set('key:a.txt', 'a') await call(ctx, 'read', { file_path: 'a.txt' }, { session }) const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session }) + if (result.isError) throw new Error('expected edit success') + expect(result.value).toEqual({ path: '/abs/a.txt', before: 'a', after: 'b' }) expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.') }) @@ -366,7 +386,7 @@ describe('edit tool', () => { fs.files.set('key:a.txt', 'hello') const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: { header: {} } }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) }) }) @@ -464,27 +484,27 @@ describe('result-time contextual diff (meta + presentResult)', () => { expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] }) }) - it('write CREATE: no before-version → no meta, but presentResult still renders a whole-file diff card', async () => { - // A create has no prior content (no `meta`), yet the completed card must be a `diff` — an + it('write CREATE: an empty applied-diff projection still falls back to the whole-file diff card', async () => { + // A create has no prior content, yet the completed card must be a `diff` — an // ACP tool_call_update.content REPLACES the call's content, so a non-diff result would // clobber the pending new-file diff. const { ctx } = await setup() const session = { header: {} } const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session }) expect(result.isError).toBe(false) - expect(result.meta).toBeUndefined() + expect(result.meta).toEqual({ diffs: [] }) const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result) expect(view).toEqual({ card: 'diff', title: 'Write new.txt', diffs: [{ path: 'new.txt', oldText: null, newText: 'fresh\n' }] }) }) - it('write OVERWRITE with identical content: a before exists but yields no hunk → no meta, presentResult falls back to a whole-file diff', async () => { + it('write OVERWRITE with identical content: an empty applied-diff projection falls back to a whole-file diff', async () => { const { ctx, fs } = await setup() const session = { header: {} } fs.files.set('key:a.txt', 'same\n') await call(ctx, 'read', { file_path: 'a.txt' }, { session }) const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session }) expect(result.isError).toBe(false) - expect(result.meta).toBeUndefined() + expect(result.meta).toEqual({ diffs: [] }) const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'same\n' }, result) expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'same\n' }] }) }) diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 6b900e2a05..3f286f8ec3 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -10,6 +10,8 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. +All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON. + An autonomous goal round that successfully reports `complete` or `blocked` contributes the existing terminal `agent/turn-stop` decision for that physical turn. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop. ## Authority diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 075264f93e..009a00376f 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -54,6 +54,62 @@ const GET_DESCRIPTION = + 'continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. ' + 'Call this before updating a goal.' +/** Canonical goal-tool output, matching the existing compact Native JSON. */ +type GoalToolValue = + | { goal: null } + | { + goal: { + id: string + revision: number + objective: string + phase: GoalView['phase'] + roundsStarted: number + maxGoalRounds: number + blockedReason?: { code: string; message: string } + } + activation: GoalView['activation'] + } + +const GOAL_VALUE_SCHEMA = { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + goal: { type: 'null', required: true }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + goal: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + id: { type: 'string', required: true }, + revision: { type: 'integer', required: true }, + objective: { type: 'string', required: true }, + phase: { type: 'string', required: true, enum: ['active', 'paused', 'blocked', 'complete'] }, + roundsStarted: { type: 'integer', required: true }, + maxGoalRounds: { type: 'integer', required: true }, + blockedReason: { + type: 'object', + additionalProperties: false, + properties: { + code: { type: 'string', required: true }, + message: { type: 'string', required: true }, + }, + }, + }, + }, + activation: { type: 'string', required: true, enum: ['armed', 'disarmed'] }, + }, + }, + ], +} as const + /** Render policy guidance with its deployment-selected blocked threshold. */ function guidance(blockedAfter: number): string { return 'Use goal tools for one long-running completion objective in the current session. ' @@ -89,9 +145,9 @@ function goalRef(goalId: string, revision: number): GoalRef { } /** Stable compact model result; activation is an observation, not replay state. */ -function renderGoal(goal: GoalView | undefined): string { - if (goal === undefined) return JSON.stringify({ goal: null }) - return JSON.stringify({ +function goalValue(goal: GoalView | undefined): GoalToolValue { + if (goal === undefined) return { goal: null } + return { goal: { id: goal.id, revision: goal.revision, @@ -99,10 +155,18 @@ function renderGoal(goal: GoalView | undefined): string { phase: goal.phase, roundsStarted: goal.roundsStarted, maxGoalRounds: goal.maxGoalRounds, - ...goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason }, + ...goal.blockedReason === undefined ? {} : { + blockedReason: { code: goal.blockedReason.code, message: goal.blockedReason.message }, + }, }, activation: goal.activation, - }) + } +} + +/** Reusable canonical output declaration for all three goal controls. */ +const GOAL_OUTPUT = { + schema: GOAL_VALUE_SCHEMA, + render: (_args: unknown, value: GoalToolValue) => [{ type: 'text' as const, text: JSON.stringify(value) }], } /** Generic, args-only pending presentation shared by the goal tools. */ @@ -144,12 +208,10 @@ export function apply(ctx: Context, config: Config): void { name: 'get_goal', description: GET_DESCRIPTION, parameters: {}, + output: GOAL_OUTPUT, execute(_args, exec) { const execution = goalToolExecution(ctx, exec) - return Promise.resolve([{ - type: 'text', - text: renderGoal(ctx.goals.get(execution.agent)), - }]) + return Promise.resolve(goalValue(ctx.goals.get(execution.agent))) }, presentCall: () => present('Read current goal', 'read'), })) @@ -168,6 +230,7 @@ export function apply(ctx: Context, config: Config): void { description: 'Optional positive safe-integer limit on automatic continuation rounds.', }, }, + output: GOAL_OUTPUT, execute(args, exec) { const execution = goalToolExecution(ctx, exec) requireDirectHuman(ctx, execution) @@ -176,7 +239,7 @@ export function apply(ctx: Context, config: Config): void { ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, }) observeMutation(terminalTurns, execution, false) - return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + return Promise.resolve(goalValue(goal)) }, presentCall: args => present('Create goal', 'other', args.objective), })) @@ -203,6 +266,7 @@ export function apply(ctx: Context, config: Config): void { description: 'Concrete blocking condition; required only with action blocked.', }, }, + output: GOAL_OUTPUT, execute(args, exec) { const execution = goalToolExecution(ctx, exec) const ref = goalRef(args.goal_id, args.revision) @@ -217,10 +281,7 @@ export function apply(ctx: Context, config: Config): void { } const goal = ctx.goals.edit(execution.agent, ref, replacements) observeMutation(terminalTurns, execution, false) - return Promise.resolve([{ - type: 'text', - text: renderGoal(goal), - }]) + return Promise.resolve(goalValue(goal)) } if (args.action === 'pause' || args.action === 'resume') { requireDirectHuman(ctx, execution) @@ -234,7 +295,7 @@ export function apply(ctx: Context, config: Config): void { ? ctx.goals.pause(execution.agent, ref) : ctx.goals.resume(execution.agent, ref) observeMutation(terminalTurns, execution, false) - return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + return Promise.resolve(goalValue(goal)) } const authority = completionAuthority(ctx, execution) if (args.objective !== undefined || args.max_goal_rounds !== undefined) { @@ -265,7 +326,7 @@ export function apply(ctx: Context, config: Config): void { message: args.blocked_reason as string, }) observeMutation(terminalTurns, execution, authority.kind === 'goal-round') - return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + return Promise.resolve(goalValue(goal)) }, presentCall: args => present( `${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 2065bac23e..8acd304bca 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -95,9 +95,12 @@ async function execute( /** Parse the compact JSON returned by a successful goal tool. */ function resultJson(result: ToolExecutionResult): Record { expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected goal tool success') const block = result.content[0] if (block?.type !== 'text') throw new Error('expected text tool result') - return JSON.parse(block.text) as Record + const parsed = JSON.parse(block.text) as Record + expect(result.value).toEqual(parsed) + return parsed } /** Read the returned goal sub-object. */ @@ -193,7 +196,7 @@ describe('goal tool execution authority', () => { it('rejects agentless, driverless, non-human, and live-child creation', async () => { const { ctx, root } = await harness() const agentless = await execute(ctx, 'get_goal', {}) - expect(agentless.error?.code).toBe('GOAL_TOOL_AGENT_REQUIRED') + expect(agentless.error?.info?.code).toBe('GOAL_TOOL_AGENT_REQUIRED') openTurn(root, { kind: 'user' }) const driverless = await ctx.tools.execute({ @@ -202,12 +205,12 @@ describe('goal tool execution authority', () => { arguments: {}, agent: root.agent, }) - expect(driverless.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(driverless.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') closeTurn(root, 1) openTurn(root, { kind: 'plugin', plugin: 'test' }) const nonHuman = await execute(ctx, 'create_goal', { objective: 'forged' }, root.agent) - expect(nonHuman.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(nonHuman.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') closeTurn(root, 2) const child = stubAgent('goal-tool-child') @@ -215,7 +218,7 @@ describe('goal tool execution authority', () => { ctx.agents.announce(child.agent) openTurn(child, { kind: 'user' }) const childResult = await execute(ctx, 'create_goal', { objective: 'child goal' }, child.agent) - expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(childResult.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) it('rejects stale agent objects and agents outside running status through the executor', async () => { @@ -223,11 +226,11 @@ describe('goal tool execution authority', () => { openTurn(root, { kind: 'user' }) const stale = { ...root.agent } const staleResult = await execute(ctx, 'get_goal', {}, stale, stale) - expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(staleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') root.setStatus('idle') const idleResult = await execute(ctx, 'get_goal', {}, root.agent) - expect(idleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(idleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') }) it('treats a fork resumed as a runtime root as direct-human authority', async () => { @@ -257,12 +260,12 @@ describe('goal tool execution authority', () => { it('rejects calls before a turn and after its end boundary', async () => { const { ctx, root } = await harness() const before = await execute(ctx, 'get_goal', {}, root.agent) - expect(before.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(before.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') const turn = openTurn(root, { kind: 'user' }) closeTurn(root, turn) const after = await execute(ctx, 'get_goal', {}, root.agent) - expect(after.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(after.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') }) it('rejects terminal reporting without human input or a current goal round', async () => { @@ -271,11 +274,11 @@ describe('goal tool execution authority', () => { const result = await execute(ctx, 'update_goal', { goal_id: 'goal-missing', revision: 1, action: 'complete', }, root.agent) - expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(result.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') const malformed = await execute(ctx, 'update_goal', { goal_id: 'goal-missing', revision: 1, action: 'pause', objective: 'probe', }, root.agent) - expect(malformed.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(malformed.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) it('accepts direct human steering in a goal-sourced root turn', async () => { @@ -303,7 +306,7 @@ describe('goal tool execution authority', () => { ctx.agents.register(other.agent) openTurn(other, { kind: 'user' }) const result = await execute(ctx, 'get_goal', {}, other.agent, root.agent) - expect(result.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(result.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') }) }) @@ -374,7 +377,7 @@ describe('goal tool state transitions', () => { const { ctx, root } = await harness() openTurn(root, { kind: 'user' }) const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent) - expect(invalidCreate.error?.code).toBe('GOAL_INVALID_OBJECTIVE') + expect(invalidCreate.error?.info?.code).toBe('GOAL_INVALID_OBJECTIVE') const created = ctx.goals.create(root.agent, { objective: 'valid' }) const replacement = await execute(ctx, 'update_goal', { goal_id: created.id, @@ -382,26 +385,26 @@ describe('goal tool state transitions', () => { action: 'pause', objective: 'not valid for pause', }, root.agent) - expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(replacement.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const terminalUpdate = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'complete', max_goal_rounds: 2, }, root.agent) - expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(terminalUpdate.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const blockedWithoutReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'blocked', }, root.agent) - expect(blockedWithoutReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(blockedWithoutReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const blockedWithEmptyReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ', }, root.agent) - expect(blockedWithEmptyReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(blockedWithEmptyReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const completeWithReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.', }, root.agent) - expect(completeWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(completeWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const editWithReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, @@ -409,11 +412,11 @@ describe('goal tool state transitions', () => { objective: 'still valid', blocked_reason: 'Not valid for edit.', }, root.agent) - expect(editWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(editWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const malformedRef = await execute(ctx, 'update_goal', { goal_id: '', revision: 0, action: 'edit', objective: 'x', }, root.agent) - expect(malformedRef.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') }) it('allows exact goal rounds to complete but not edit or pause', async () => { @@ -425,7 +428,7 @@ describe('goal tool state transitions', () => { const edit = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'edit', objective: 'forbidden', }, root.agent) - expect(edit.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(edit.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') const complete = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'complete', }, root.agent) @@ -447,7 +450,7 @@ describe('goal tool state transitions', () => { action: 'blocked', blocked_reason: 'The required credential is still unavailable.', }, root.agent) - expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD') + expect(result.error?.info?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD') closeTurn(root, turn) } openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 }) diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index 0c630686b4..0a4e3b417f 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -213,8 +213,7 @@ export function apply(ctx: Context, config: Config): void { return { kind: 'block', feedback: downstream.feedback, additionalContexts: prependContext(reminder, downstream.additionalContexts) } } return { - kind: 'accept', - ...downstream.content !== undefined ? { content: downstream.content } : {}, + ...downstream, additionalContexts: prependContext(reminder, downstream.additionalContexts), } }) diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 101c542d5a..54aacc6073 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -24,8 +24,8 @@ async function harness(config: Config = {}): Promise { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(RepeatToolGuard, config) - ctx.tools.register(defineTool({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - ctx.tools.register(defineTool({ name: 'other', description: 'o', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'other', description: 'o', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) return ctx } @@ -332,11 +332,11 @@ describe('fold onto the downstream decision', () => { expect(results[1]!.data.content).toEqual([{ type: 'text', text: 'nope' }]) }) - it('preserves a downstream accept content replacement while folding', async () => { + it('preserves a downstream canonical value replacement while folding', async () => { const ctx = await harness({ thresholds: [2] }) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, - content: [{ type: 'text' as const, text: 'replaced' }], + value: [{ type: 'text' as const, text: 'replaced' }], })) const adapter = new MockAdapter([ toolCallResponse('c1', 'probe', { q: 1 }), diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 03ac21fea6..4f672ea416 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -253,8 +253,7 @@ export function apply(ctx: Context, config: Config): void { return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) } } return { - kind: 'accept', - ...downstream.content !== undefined ? { content: downstream.content } : {}, + ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts), } }) diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 65dd29d146..fd2b018a51 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { Context, type Fiber } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -138,7 +138,7 @@ describe('hooks-claude bridge — PreToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('done')]) const ctx = await harness(dir, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'use danger' }]) await waitForIdle(ctx, agent) @@ -161,7 +161,7 @@ describe('hooks-claude bridge — PreToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'safe', {}), textResponse('done')]) const ctx = await harness(dir, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'use safe' }]) await waitForIdle(ctx, agent) @@ -183,7 +183,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -204,7 +204,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -228,7 +228,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 870d369784..ae6347e31d 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { Context } from 'cordis' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -65,7 +65,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -95,7 +95,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d }) ctx.logger.warn = warn as never - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -111,7 +111,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.logger.warn = warn as never let sawArgs: unknown - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -142,7 +142,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) // Call execute() directly with NO agent — the bridge's no-agent/no-turn path. const { CallId } = await import('@deepseek-ai/dsh-llm') const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} }) @@ -157,7 +157,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -182,7 +182,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -262,7 +262,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -276,7 +276,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -320,7 +320,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -335,7 +335,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -375,7 +375,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -390,7 +390,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -409,7 +409,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -426,7 +426,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -446,7 +446,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -529,16 +529,16 @@ export function defineCoverageCases(group: CoverageGroup): void { expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) - it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { + it('folds the bridge PostToolUse context onto a downstream canonical value replacement', async () => { // The bridge hook adds context; a later post-execute listener accepts with a - // content rewrite. Both the rewrite and the bridge context survive. + // canonical replacement. Both the replacement and the bridge context survive. const d = dir() const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -553,7 +553,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, additionalContexts: [{ @@ -583,7 +583,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) @@ -608,7 +608,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // yields a HookOutput with exitCode undefined → the `exitCode` spread false arm. const bash = ctx.bash bash.run = (() => Promise.reject(new Error('executor down'))) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -656,7 +656,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 75c33e2d92..41529c64d6 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -226,8 +226,7 @@ export function apply(ctx: Context, config: Config): void { return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) } } return { - kind: 'accept', - ...downstream.content !== undefined ? { content: downstream.content } : {}, + ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts), } }) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 2cb4cb0bc5..9ce955630b 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -75,7 +75,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(dir, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'run ls' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index e02ea52df1..5c8ddb4487 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { Context } from 'cordis' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -61,7 +61,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -144,13 +144,13 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro }) if (selected.has('post-tool')) describe('hooks-codex coverage — post-tool and session context mapping', () => { - it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { + it('folds the bridge PostToolUse context onto a downstream canonical value replacement', async () => { const d = dir() hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -163,7 +163,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, additionalContexts: [{ @@ -188,7 +188,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) @@ -215,7 +215,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'p.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') @@ -228,7 +228,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"post-ctx"}}\'\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) @@ -242,7 +242,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', {}), textResponse('done')]) // no command arg const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' @@ -253,7 +253,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -266,7 +266,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -289,7 +289,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -325,7 +325,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) @@ -366,7 +366,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) @@ -379,7 +379,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded @@ -395,7 +395,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -408,7 +408,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') @@ -420,7 +420,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'bc.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"ctx too"}}\'\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') @@ -437,7 +437,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } @@ -449,7 +449,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const { CallId } = await import('@deepseek-ai/dsh-llm') const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) expect(ran).toBe(false) // denied @@ -460,7 +460,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const d = dir() hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"x"}}\'\n') }] }] }) const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { CallId } = await import('@deepseek-ai/dsh-llm') const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) expect(result.isError).toBeFalsy() @@ -473,7 +473,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.bash.run = (() => Promise.reject(new Error('executor down'))) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -570,7 +570,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } @@ -586,7 +586,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false - ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(false) // the matcher fired → the hook denied the tool @@ -620,7 +620,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' }) ctx.llm.registerAdapter(['mock'], adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 8e2f086e97..18669920e0 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -6,7 +6,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -166,7 +166,7 @@ describe('bounded transient retry policy', () => { ]) ;({ ctx: context } = await harness(adapter)) let toolExecutions = 0 - context.tools.register(defineTool({ + context.tools.register(defineContentToolFixture({ name: 'danger', description: 'must not run for a failed provider attempt', parameters: {}, diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index ebcf29fad5..252cd27bfc 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -56,8 +56,9 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` - On connect: `listTools()` → registers each tool via `ctx.tools.register()` under its public name. - Listens for `notifications/tools/list_changed` → re-syncs; a failed re-sync keeps the previous generation registered. -- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support — the public name is never sent to the server. -- Image content in results is discarded with a placeholder (the harness has no image block type). +- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server. +- Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`. +- Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders. - On disconnect/crash: all tools are unregistered; no auto-reconnect. ## Services consumed @@ -86,7 +87,7 @@ Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync #### What the model sees -The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained text result; image, audio, resource, and unsupported blocks become short placeholders, and MCP `isError` results follow the registry's model-visible error path. +The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained Native text result; image, audio, resource, and unsupported blocks become short placeholders there. Their full JSON blocks and optional structured content remain in the execution-local canonical value, and MCP `isError` rejects the call through the registry's error path. #### Token effect @@ -101,4 +102,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **Initial discovery is asynchronous** — plugin load does not wait for connection and `listTools()`, so a turn started immediately after boot or HMR can assemble before the MCP tools are registered. - **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred. - **Crash recovery is manual** — transport closure unregisters the server's tools, but reconnect requires an HMR reload or harness restart. -- **Non-text results are lossy** — image, audio, and resource payloads are replaced with placeholders, and a structured-only result has no model-visible structured representation. +- **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred. +- **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset. diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index 4a18f85ff5..2e16e33b44 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -22,6 +22,8 @@ import { syncTools } from './tools.ts' // Side-effect type import: declaration-merges `ctx.tools` onto Context. import type {} from '@deepseek-ai/dsh-tools' +export type { McpResult } from './tools.ts' + /** Cordis plugin name used by loader diagnostics. */ export const name = 'mcp-client' diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts index 7b9217e814..1a8eabf416 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -16,6 +16,8 @@ import { createHash } from 'node:crypto' import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import type { Context } from 'cordis' import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' +import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools' +import type { JsonSchemaNode, JsonValue } from '@deepseek-ai/dsh-tools' /** Resolved options relevant to tool bridging. */ export interface ToolBridgeOptions { @@ -26,6 +28,12 @@ export interface ToolBridgeOptions { /** State for one sync generation: the current set of disposers keyed by public name. */ export type ToolDisposers = Map void> +/** Canonical MCP result exposed to Code Mode without discarding protocol blocks. */ +export type McpResult = { + content: JsonValue[] + structuredContent?: Structured +} + /** * DeepSeek function-name contract: at most 64 characters. Wire-protocol * constant, not configuration. @@ -105,6 +113,7 @@ export async function syncTools( name: publicName, description: tool.description ?? '', parameters: tool.inputSchema, + output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)), execute: createExecutor(client, tool.name, opts), }) } @@ -141,6 +150,36 @@ interface McpContentBlock { mimeType?: string } +/** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */ +function supportedOutputSchema(candidate: unknown): JsonSchemaNode | undefined { + if (candidate === undefined) return undefined + try { + assertSupportedJsonSchema(candidate) + return candidate + } catch { + return undefined + } +} + +/** Build the canonical result schema and existing Native text projection. */ +function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefined): ToolDefinition['output'] { + return { + schema: { + type: 'object', + properties: { + content: { type: 'array', items: {} }, + structuredContent: structuredSchema ?? {}, + }, + required: ['content'], + additionalProperties: false, + }, + render(_args, value) { + const result = value as unknown as McpResult + return [{ type: 'text', text: extractText(result.content, rawName) }] + }, + } +} + /** * Create an execute function for one MCP tool. The executor closes over the * raw MCP tool name and calls `client.callTool` with it (never the public @@ -172,18 +211,24 @@ function createExecutor( // The SDK may return a legacy `toolResult` shape; normalize to content array. if (!('content' in result) || !Array.isArray(result.content)) { - const text = 'toolResult' in result + const rendered: unknown = 'toolResult' in result ? JSON.stringify(result.toolResult) : '(no output)' - return [{ type: 'text' as const, text }] + const text = typeof rendered === 'string' ? rendered : '(no output)' + if ('isError' in result && result.isError === true) throw new Error(text) + return { + content: [{ type: 'text', text }], + ...'structuredContent' in result && result.structuredContent !== undefined + ? { structuredContent: result.structuredContent as JsonValue } + : {}, + } } // Trust boundary: the SDK's return type erases to `any[]` due to the // union of CallToolResult | CompatibilityCallToolResult. We process each // element defensively in extractText (reading only .type/.text/.mimeType // with optional fallbacks). - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const content: McpContentBlock[] = result.content + const content = result.content as unknown as JsonValue[] const text = extractText(content, rawName) // MCP isError → throw so ToolRegistry produces an isError result for the model. @@ -191,7 +236,12 @@ function createExecutor( throw new Error(text) } - return [{ type: 'text', text }] + return { + content, + ...'structuredContent' in result && result.structuredContent !== undefined + ? { structuredContent: result.structuredContent as JsonValue } + : {}, + } } } @@ -203,10 +253,15 @@ function createExecutor( * Defensive: fields that the MCP spec declares required (mimeType, text) are * guarded with fallbacks because this is a network trust boundary. */ -function extractText(mcpContent: McpContentBlock[], toolName: string): string { +function extractText(mcpContent: JsonValue[], toolName: string): string { const parts: string[] = [] - for (const block of mcpContent) { + for (const value of mcpContent) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + parts.push('[unsupported content type: unknown]') + continue + } + const block = value as unknown as McpContentBlock switch (block.type) { case 'text': if (block.text !== undefined) parts.push(block.text) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 8fff832434..30e7a6297c 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { type JsonValue } from '@deepseek-ai/dsh-tools' import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts' import type { Config } from '@deepseek-ai/dsh-mcp-client' @@ -13,10 +13,12 @@ interface MockTool { name: string description?: string inputSchema: Record + outputSchema?: Record } interface MockCallResult { - content: Array<{ type: string; text?: string; mimeType?: string }> + content: JsonValue[] + structuredContent?: JsonValue isError?: boolean } @@ -114,7 +116,8 @@ describe('syncTools', () => { name: 'search', description: 'Native search', parameters: { type: 'object' }, - execute: async () => [{ type: 'text', text: 'native' }], + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value as string }] }, + execute: async () => 'native', }) const client = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }]) @@ -156,7 +159,8 @@ describe('syncTools', () => { name: 'mcp__srv__taken', description: 'Squatter', parameters: { type: 'object' }, - execute: async () => [{ type: 'text', text: 'squatter' }], + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value as string }] }, + execute: async () => 'squatter', }) const client = createMockClient([ { name: 'free', inputSchema: { type: 'object' } }, @@ -221,6 +225,8 @@ describe('tool execution', () => { expect(result.isError).toBe(false) expect(result.content).toEqual([{ type: 'text', text: 'hello world' }]) + if (result.isError) throw new Error('expected MCP success') + expect(result.value).toEqual({ content: [{ type: 'text', text: 'hello world' }] }) // The wire sees the raw MCP name, never the public name. expect(client.callTool).toHaveBeenCalledWith( { name: 'echo', arguments: { msg: 'hi' } }, @@ -259,16 +265,85 @@ describe('tool execution', () => { expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }]) }) - it('discards image content with placeholder', async () => { + it('preserves full JSON MCP blocks while Native rendering uses placeholders', async () => { + const blocks = [ + { type: 'text', text: 'before' }, + { type: 'image', mimeType: 'image/png', data: 'base64-data', annotations: { audience: ['assistant'] } }, + ] satisfies JsonValue[] const client = createMockClient( [{ name: 'img', inputSchema: { type: 'object' } }], - { content: [{ type: 'text', text: 'before' }, { type: 'image', mimeType: 'image/png' }] }, + { content: blocks }, ) await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' }) + if (result.isError) throw new Error('expected MCP success') + expect(result.value).toEqual({ content: blocks }) + }) + + it('preserves primitive JSON MCP blocks while Native rendering marks them unsupported', async () => { + const blocks = [42, null, ['nested']] satisfies JsonValue[] + const client = createMockClient( + [{ name: 'primitive-blocks', inputSchema: { type: 'object' } }], + { content: blocks }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ + callId: CallId('primitive'), name: 'mcp__srv__primitive-blocks', arguments: {}, + }) + + expect(result.content[0]).toEqual({ + type: 'text', + text: '[unsupported content type: unknown]\n[unsupported content type: unknown]\n[unsupported content type: unknown]', + }) + if (result.isError) throw new Error('expected primitive MCP blocks to remain a successful JSON value') + expect(result.value).toEqual({ content: blocks }) + }) + + it('validates structuredContent when the advertised output schema is supported', async () => { + const outputSchema = { + type: 'object', + additionalProperties: false, + properties: { answer: { type: 'integer' } }, + required: ['answer'], + } + const valid = createMockClient( + [{ name: 'structured', inputSchema: { type: 'object' }, outputSchema }], + { content: [{ type: 'text', text: '42' }], structuredContent: { answer: 42 } }, + ) + await syncTools(valid as never, ctx, defaultOpts, new Map()) + const success = await ctx.tools.execute({ callId: CallId('valid'), name: 'mcp__srv__structured', arguments: {} }) + if (success.isError) throw new Error('expected supported structuredContent to validate') + expect(success.value).toEqual({ content: [{ type: 'text', text: '42' }], structuredContent: { answer: 42 } }) + + const invalidCtx = await mountRegistry() + const invalid = createMockClient( + [{ name: 'structured', inputSchema: { type: 'object' }, outputSchema }], + { content: [{ type: 'text', text: 'wrong' }], structuredContent: { answer: 'forty-two' } }, + ) + await syncTools(invalid as never, invalidCtx, defaultOpts, new Map()) + const failure = await invalidCtx.tools.execute({ callId: CallId('invalid'), name: 'mcp__srv__structured', arguments: {} }) + expect(failure.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } }) + expect(failure.content[0]?.type === 'text' ? failure.content[0].text : '') + .toContain('value.structuredContent.answer') + }) + + it('falls back to JsonValue for unsupported advertised output schemas', async () => { + const client = createMockClient( + [{ + name: 'future-schema', + inputSchema: { type: 'object' }, + outputSchema: { type: 'object', patternProperties: { '^x-': { type: 'string' } } }, + }], + { content: [], structuredContent: ['kept', { nested: true }] }, + ) + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('fallback'), name: 'mcp__srv__future-schema', arguments: {} }) + if (result.isError) throw new Error('unsupported MCP output schemas must fall back') + expect(result.value).toEqual({ content: [], structuredContent: ['kept', { nested: true }] }) }) it('maps isError to an error result via throw', async () => { @@ -282,6 +357,7 @@ describe('tool execution', () => { expect(result.isError).toBe(true) expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' }) + expect('value' in result).toBe(false) }) it('passes abort signal to callTool', async () => { @@ -313,6 +389,38 @@ describe('tool execution', () => { expect(result.isError).toBe(false) expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' }) }) + + it('preserves structuredContent on a successful legacy result', async () => { + const client = createMockClient([{ name: 'legacy-structured', inputSchema: { type: 'object' } }]) + client.callTool.mockResolvedValue({ + toolResult: 'legacy', + structuredContent: { answer: 42 }, + }) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ + callId: CallId('legacy-structured'), name: 'mcp__srv__legacy-structured', arguments: {}, + }) + + if (result.isError) throw new Error('expected legacy structured result success') + expect(result.value).toEqual({ + content: [{ type: 'text', text: '"legacy"' }], + structuredContent: { answer: 42 }, + }) + }) + + it('maps a legacy isError reply to failure', async () => { + const client = createMockClient([{ name: 'legacy-error', inputSchema: { type: 'object' } }]) + client.callTool.mockResolvedValue({ toolResult: { reason: 'nope' }, isError: true }) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ + callId: CallId('legacy-error'), name: 'mcp__srv__legacy-error', arguments: {}, + }) + + expect(result.isError).toBe(true) + expect(result.error?.message).toBe('{"reason":"nope"}') + }) }) describe('tool execution edge cases', () => { @@ -423,7 +531,7 @@ describe('tool execution edge cases', () => { const client = createMockClient( [{ name: 'legacy2', inputSchema: { type: 'object' } }], ) - client.callTool.mockResolvedValue({}) + client.callTool.mockResolvedValue({ toolResult: undefined, structuredContent: undefined }) await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} }) @@ -431,6 +539,18 @@ describe('tool execution edge cases', () => { expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' }) }) + it('handles a legacy result with neither content nor toolResult', async () => { + const client = createMockClient( + [{ name: 'legacy-empty', inputSchema: { type: 'object' } }], + ) + client.callTool.mockResolvedValue({}) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('legacy-empty'), name: 'mcp__srv__legacy-empty', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' }) + }) + it('handles isError with non-text content (fallback error message)', async () => { const client = createMockClient( [{ name: 'err_notext', inputSchema: { type: 'object' } }], diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 84386c016b..d36ed98234 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -149,7 +149,7 @@ export function runPersistenceContract(name: string, make: () => Promise e.type === 'tool/result') expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({ - callId: CallId('call-x'), isError: true, error: { code: 'interrupted' }, + callId: CallId('call-x'), isError: true, error: { info: { code: 'interrupted' } }, }) // The synthetic result carries the SAME callId as the orphaned tool-call, // so deriveMessages() pairs them — no provider-invalid dangling call. diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 89a6e843ab..578c6e7300 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -16,7 +16,7 @@ The plugin contributes one user-role `` catalog through `agent/ |---|---|---| | `name` | string (required) | Exact kebab-case skill name from the available skills listing. | -Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers resolve the winning skill. A successful call returns one text result containing ``, ``, and ``. +Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers resolve the winning skill. A successful call returns canonical `{ name, provider, resourceBase?, content }`, excluding catalog ranking and provider-internal machinery; its Native renderer produces one text result containing ``, ``, and ``. Resource guidance resolves only paths or URLs explicitly referenced by the instructions against `resourceBase`; scripts, references, and assets load on demand, and the result does not enumerate a skill directory. Local providers may supply a directory, while remote or embedded providers may supply a URL or opaque loading guidance. diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 50c4b0db74..f5fbd1dff5 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -42,6 +42,46 @@ export function apply(ctx: Context, config: Config = {}): void { parameters: { name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + name: { type: 'string', required: true }, + provider: { type: 'string', required: true }, + resourceBase: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'directory' }, + path: { type: 'string', required: true }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'url' }, + url: { type: 'string', required: true }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'opaque' }, + description: { type: 'string', required: true }, + }, + }, + ], + }, + content: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: renderSkillContent(value) }], + }, async execute(args, exec) { if (!isSkillName(args.name)) { throw new Error(`invalid skill name "${args.name}"`) @@ -53,7 +93,14 @@ export function apply(ctx: Context, config: Config = {}): void { if (skill.disableModelInvocation === true) { throw new Error(`skill "${args.name}" is not available for model invocation`) } - return [{ type: 'text', text: renderSkillContent(skill) }] + return { + name: skill.name, + provider: skill.provider, + ...skill.resourceBase !== undefined ? { + resourceBase: { ...skill.resourceBase }, + } : {}, + content: skill.content, + } }, presentCall(args) { return { card: 'generic', title: `Load skill ${args.name}`, kind: 'read', rawInput: args.name } @@ -77,7 +124,7 @@ export function apply(ctx: Context, config: Config = {}): void { }) } -function renderSkillContent(skill: SkillDefinition): string { +function renderSkillContent(skill: Pick): string { const resourceHint = renderResourceHint(skill) return [ ``, @@ -92,7 +139,7 @@ function renderSkillContent(skill: SkillDefinition): string { ].join('\n') } -function renderResourceHint(skill: SkillDefinition): string[] { +function renderResourceHint(skill: Pick): string[] { const base = skill.resourceBase if (base === undefined) { return [ @@ -116,8 +163,10 @@ function renderResourceHint(skill: SkillDefinition): string[] { `Resources for this skill: ${escapeText(base.description)}`, 'Load referenced resources only as needed.', ] + /* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */ default: return assertNever(base, 'SkillResourceBase.kind') + /* v8 ignore stop */ } } diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index cab7f4aa02..843c14c93f 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import { CallId, type Message } from '@deepseek-ai/dsh-llm' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' @@ -185,7 +185,7 @@ describe('dsh-tool-skill', () => { const ctx = await setup(home) ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' }) const { agent, scope } = await mintAgentScope(ctx, '/workspace') - scope.ctx.tools.register(defineTool({ + scope.ctx.tools.register(defineContentToolFixture({ name: 'skill', description: 'A scoped tool with unrelated semantics.', parameters: {}, @@ -226,6 +226,13 @@ describe('dsh-tool-skill', () => { }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected skill success') + expect(result.value).toEqual({ + name: 'project-skill', + provider: 'local', + resourceBase: { kind: 'directory', path: join(project, '.dsh/skills/project-skill') }, + content: 'Project instructions.', + }) const block = result.content[0] expect(block?.type).toBe('text') if (block?.type !== 'text') throw new Error('expected text skill result') @@ -283,7 +290,7 @@ describe('dsh-tool-skill', () => { expect(provider.content[0].text).toContain('\nResources for this skill are managed by provider "runtime".\nLoad referenced resources only as needed.\n') }) - it('fails loud on an unknown resource base kind', async () => { + it('rejects an unknown resource-base kind at the canonical output boundary', async () => { const home = await tempDir('tool-resource-assert-never') const ctx = await setup(home) ctx.skills.register({ @@ -298,9 +305,10 @@ describe('dsh-tool-skill', () => { const result = await ctx.tools.execute({ callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } }) expect(result.isError).toBe(true) + expect(result.error?.info?.code).toBe('INVALID_TOOL_OUTPUT') const block = result.content[0] if (block?.type !== 'text') throw new Error('expected text tool result') - expect(block.text).toContain('unreachable variant') + expect(block.text).toContain('value.resourceBase') }) it('returns isError for unknown, invalid, and model-disabled skills', async () => { diff --git a/packages/spill/spill-policy/README.md b/packages/spill/spill-policy/README.md index 936f254d6a..9b746f27bc 100644 --- a/packages/spill/spill-policy/README.md +++ b/packages/spill/spill-policy/README.md @@ -13,7 +13,7 @@ This plugin registers **no service** and owns no storage or preview mechanics: p ## Behavior 1. Let the tool run (delegates via `next()`, so it bounds whatever a downstream hook accepted). -2. Skip `read` (avoids a `read → spill → read again` loop) and any non-`accept` decision (a `block`'s corrective feedback passes through). +2. Skip nested executions (`exec.parent` is present), accepted value replacements (the registry must revalidate and rerender them), `read` (avoids a `read → spill → read again` loop), and any non-`accept` decision (a `block`'s corrective feedback passes through). 3. Flatten the accepted content only when it is **plain text** (all `text` blocks); a result with any non-text block is left untouched. 4. If its UTF-8 size is `≤ maxInlineBytes`, leave it unchanged. 5. Otherwise save the full text and replace the result with a preview + this notice, sized so the whole replacement (preview + blank line + notice) stays within `maxInlineBytes` — the notice's byte cost is reserved out of the budget, so the preview shrinks to fit and the model-facing result never exceeds the cap: @@ -26,11 +26,11 @@ This plugin registers **no service** and owns no storage or preview mechanics: p When the notice alone fills the budget (a tiny cap or a long locator) the preview is empty and only the notice is returned. If even that notice-only replacement would exceed `maxInlineBytes`, the policy keeps the inline result — it never emits a replacement over the cap (and a within-cap replacement is always smaller than the original, so this also means spilling never adds bytes). -**Best-effort:** no session owner, no `ctx.spillStore` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result. +**Best-effort:** no session owner, no `ctx.spillStore` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result. A successful replacement changes only `content`; the canonical programmatic value is preserved. ## Scope -The policy sees only the FINAL formatted tool result — not a tool's internal resource. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill artifact holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. Tool-owned early spill (bash streams, subagent rollouts) is future work — see the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md). +The policy sees only the FINAL formatted surface result—not a tool's internal resource or canonical value. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill artifact holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. `glob`/`grep` own item-level surface spill because their complete acquired values still exist before rendering; bash streams own acquisition-time spill. See the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md). ## Model Experience @@ -38,7 +38,7 @@ The policy sees only the FINAL formatted tool result — not a tool's internal r #### What the model sees -Results at or below `maxInlineBytes`, `read` results, blocked decisions, and results containing non-text blocks are unchanged. An oversized plain-text result becomes a bounded head/tail preview followed by `(Omitted bytes. Full formatted result stored at: . )`; storage or ownership failures leave the original result visible. +Results at or below `maxInlineBytes`, nested results, `read` results, blocked decisions, and results containing non-text blocks are unchanged. An oversized plain-text surface result becomes a bounded head/tail preview followed by `(Omitted bytes. Full formatted result stored at: . )`; storage or ownership failures leave the original result visible. #### Token effect diff --git a/packages/spill/spill-policy/src/index.ts b/packages/spill/spill-policy/src/index.ts index b7ac4a31fc..c2abd094ed 100644 --- a/packages/spill/spill-policy/src/index.ts +++ b/packages/spill/spill-policy/src/index.ts @@ -16,15 +16,20 @@ * - Plain-text results only: a result carrying any non-text block is left * untouched (the policy knows only the final formatted text, not tool * internals). + * - Nested composite calls are skipped; only their outer surface result may + * become model-facing and spillable. + * - Accepted value replacements pass through for registry revalidation and + * rendering; this presentation policy cannot also replace content in the + * same mutually exclusive decision. * - `read` is skipped to avoid a `read → spill → read again` loop. * - Best-effort: no session owner, no `ctx.spillStore` backend, or a save * failure ⇒ log and return the original result. A spill failure must NEVER * turn a successful tool call into an `isError` or hide the inline result. * * It COMPOSES with other post-execute listeners: it delegates via `next()` and - * bounds the resulting `accept` content, so a hook that replaced the content - * still has its replacement bounded, and a `block` decision passes through - * unchanged. + * bounds the resulting content projection, so a hook that replaced content + * still has its replacement bounded, while value replacements and `block` + * decisions pass through unchanged. * * @module @deepseek-ai/dsh-spill-policy */ @@ -109,7 +114,8 @@ export function apply(ctx: Context, config: Config): void { // accepted plain-text results, never corrective feedback. const decision = await next() // Skip `read` to avoid a read → spill → read again loop. - if (decision.kind !== 'accept' || exec.name === 'read') return decision + if (decision.kind !== 'accept' || Object.hasOwn(decision, 'value') + || exec.parent !== undefined || exec.name === 'read') return decision const content = decision.content ?? result.content const text = flattenPlainText(content) diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index 2449f26a8c..e01703525f 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -15,8 +15,8 @@ import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy' @@ -39,7 +39,7 @@ class StubStore extends SpillStore { /** A tool returning `text` verbatim (name configurable so we can register `read`). */ function textTool(name: string, text: string) { - return defineTool({ + return defineContentToolFixture({ name, description: name, parameters: {}, @@ -159,7 +159,7 @@ describe('oversized plain-text replacement', () => { it('leaves a result with a non-text block unchanged', async () => { const { ctx, spill } = await setup({ maxInlineBytes: 5 }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'mixed', description: 'mixed', parameters: {}, @@ -183,6 +183,21 @@ describe('read skip', () => { }) }) +describe('nested-call skip', () => { + it('leaves nested composite results complete and spillable only through their outer call', async () => { + const { ctx, spill } = await setup({ maxInlineBytes: 10 }) + const body = 'x'.repeat(1000) + ctx.tools.register(textTool('nested', body)) + const nested = { + ...exec('nested'), + parent: Symbol('outer') as ToolExecutionToken, + } + const result = await ctx.tools.execute(nested) + expect(textOf(result.content)).toBe(body) + expect(spill?.saves).toHaveLength(0) + }) +}) + describe('best-effort fallback', () => { it('keeps the original result when saveText fails', async () => { const { ctx, spill } = await setup({ maxInlineBytes: 10 }) @@ -238,6 +253,21 @@ describe('composition', () => { expect(textOf(result.content)).toContain('Full formatted result stored at') expect(result.additionalContexts).toEqual([context]) }) + + it('passes a downstream value replacement through for registry rendering', async () => { + const { ctx, spill } = await setup({ maxInlineBytes: 10 }) + const replacement = [{ type: 'text' as const, text: 'z'.repeat(500) }] + ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: replacement })) + ctx.tools.register(textTool('small', 'tiny')) + + const result = await ctx.tools.execute(exec('small')) + + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected replacement success') + expect(result.value).toEqual(replacement) + expect(textOf(result.content)).toBe('z'.repeat(500)) + expect(spill?.saves).toHaveLength(0) + }) }) describe('cap invariant', () => { diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index d51fc53e5d..194ffeea47 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -60,7 +60,7 @@ Independent of the parent request cache. The child's later history is append-onl #### What the model sees -A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact description `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Success returns `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `` is not executed``. +A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact description `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Its canonical acknowledgement is `{ recorded: true }`, rendered as `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `` is not executed``. ##### Structured-output instruction diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 9d2cce1570..9adf5899fe 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -12,7 +12,7 @@ import type { Context } from 'cordis' import type { ContinuationStop } from '@deepseek-ai/dsh-agent' -import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { ToolSchema } from '@deepseek-ai/dsh-llm' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools' @@ -74,7 +74,16 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch childCtx.tools.register({ ...schemaEntry, - execute(args: unknown, exec: ToolExecution): Promise { + output: { + schema: { + type: 'object', + properties: { recorded: { type: 'boolean', const: true } }, + required: ['recorded'], + additionalProperties: false, + }, + render: () => [{ type: 'text', text: 'Structured output recorded.' }], + }, + execute(args: unknown, exec: ToolExecution): Promise<{ recorded: true }> { const violations = validateJsonSchemaValue(schema, args) // ToolArgsError → isError result with INVALID_ARGS: the model retries // within the same turn, exactly like a schema-validated defineTool call. @@ -83,7 +92,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch // waterfalls may still turn the success into an error. ToolRegistry has // already frozen model-bound arguments at the actual input boundary. staged.set(exec, { value: args }) - return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }]) + return Promise.resolve({ recorded: true }) }, }) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 56a78a225f..a41ede8b86 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -8,7 +8,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools' -import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' import { @@ -85,10 +85,15 @@ describe('in-process structured output', () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }), ]) + let acknowledgement: unknown + ctx.on('tools/result', (exec, toolResult) => { + if (exec.name === STRUCTURED_OUTPUT_TOOL && !toolResult.isError) acknowledgement = toolResult.value + }) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(result.stopReason).toBe('completed') expect(result.structured).toEqual({ answer: 42, note: 'done' }) + expect(acknowledgement).toEqual({ recorded: true }) await run.dispose() }) @@ -119,15 +124,15 @@ describe('in-process structured output', () => { ] as Script[number] const { ctx, parent } = await setup([response]) let sideEffectRan = false - ctx.tools.register({ + ctx.tools.register(defineContentToolFixture({ name: 'side_effect', description: 'probe', - parameters: { type: 'object', properties: {} }, + parameters: {}, execute(): Promise { sideEffectRan = true return Promise.resolve([{ type: 'text', text: 'ran' }]) }, - }) + })) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(result.stopReason).toBe('completed') @@ -147,15 +152,15 @@ describe('in-process structured output', () => { ] as Script[number] const { ctx, parent } = await setup([response]) let sideEffectRan = false - ctx.tools.register({ + ctx.tools.register(defineContentToolFixture({ name: 'side_effect', description: 'probe', - parameters: { type: 'object', properties: {} }, + parameters: {}, execute(): Promise { sideEffectRan = true return Promise.resolve([{ type: 'text', text: 'ran' }]) }, - }) + })) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) // Registered after the child and prepended: this listener returns allow // after every downstream pre-execute decision. The service-owned guard @@ -184,15 +189,15 @@ describe('in-process structured output', () => { ] as Script[number] const { ctx, parent } = await setup([response]) let sideEffectRan = false - ctx.tools.register({ + ctx.tools.register(defineContentToolFixture({ name: 'side_effect', description: 'probe', - parameters: { type: 'object', properties: {} }, + parameters: {}, execute(): Promise { sideEffectRan = true return Promise.resolve([{ type: 'text', text: 'ran' }]) }, - }) + })) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result // The call ran BEFORE captured was set: the deny gate only guards the @@ -589,12 +594,12 @@ describe('in-process structured output', () => { ]) // A global tool sorts lexicographically after structured_output, while a // global section above the 190 band follows the capture instruction. - ctx.tools.register({ + ctx.tools.register(defineContentToolFixture({ name: 'zz_probe', description: 'probe', - parameters: { type: 'object', properties: {} }, + parameters: {}, execute: () => Promise.resolve([{ type: 'text', text: 'x' }]), - }) + })) ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' }) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) await run.result @@ -646,7 +651,7 @@ describe('in-process structured output', () => { agent: parent, }) expect(result.isError).toBe(true) - expect(result.error?.code).toBe('UNKNOWN_TOOL') + expect(result.error?.info?.code).toBe('UNKNOWN_TOOL') }) it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => { @@ -657,7 +662,7 @@ describe('in-process structured output', () => { arguments: { answer: 1 }, }) expect(result.isError).toBe(true) - expect(result.error?.code).toBe('UNKNOWN_TOOL') + expect(result.error?.info?.code).toBe('UNKNOWN_TOOL') }) it('a failed execution stage is discarded and never promoted by a later call', async () => { diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 7de5f6f4d6..368e3700c9 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -10,6 +10,7 @@ import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-sub import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as spawn from '../src/index.ts' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' type Script = ConstructorParameters[0] @@ -383,10 +384,10 @@ describe('dsh-subagent-spawn', () => { toolCallResponse('c1', 'forbidden_tool', {}), textResponse('done'), ]) - ctx.tools.register({ + ctx.tools.register(defineContentToolFixture({ name: 'forbidden_tool', description: 'global', parameters: {}, execute: () => Promise.resolve([{ type: 'text', text: 'ran' }]), - }) + })) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent, diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index e2af5ac552..08e143f94d 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -6,9 +6,9 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns. -A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns final text; abort, refusal, token limit, and other failures become errored tool results without partial output. +A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output. -With `run_in_background: true`, the tool registers the parent-owned task before starting the provider. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md). +With `run_in_background: true`, the tool registers the parent-owned task before starting the provider and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md). `toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index bcd28c6ab1..2af2c58dc6 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -12,6 +12,7 @@ import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { JsonValue } from '@deepseek-ai/dsh-session' import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' @@ -95,6 +96,16 @@ function outputText(blocks: ContentBlock[]): string { .join('') } +/** Render text blocks from the canonical JSON block array without trusting arbitrary values. */ +function outputValueText(values: JsonValue[]): string { + return values + .filter((value): value is { type: 'text'; text: string } => + typeof value === 'object' && value !== null && !Array.isArray(value) + && value.type === 'text' && typeof value.text === 'string') + .map(value => value.text) + .join('') +} + /** A non-`completed` stop reason means the child did not finish cleanly. */ function stopReasonError(result: SubagentResult): string | undefined { switch (result.stopReason) { @@ -268,7 +279,36 @@ export function apply(ctx: Context, config: Config): void { }, } : {}, }, - async execute(args, exec): Promise { + output: { + schema: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'background' }, + taskId: { type: 'string', required: true }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'foreground' }, + runId: { type: 'string', required: true }, + output: { type: 'array', required: true, items: { type: 'json' } }, + }, + }, + ], + }, + render: (_args, value) => [{ + type: 'text', + text: value.kind === 'background' + ? `started background subagent task ${value.taskId}` + : outputValueText(value.output), + }], + }, + async execute(args, exec) { const parent = exec.agent if (!parent) { // Non-agent callers provide no parent for delegation ownership. @@ -308,7 +348,7 @@ export function apply(ctx: Context, config: Config): void { } }, }) - return [{ type: 'text', text: `started background subagent task ${id}` }] + return { kind: 'background' as const, taskId: id } } const request = startRequest( @@ -327,7 +367,13 @@ export function apply(ctx: Context, config: Config): void { // The registry converts this throw to isError; partial output is not success. throw new Error(error) } - return [{ type: 'text', text: outputText(result.output) }] + return { + kind: 'foreground' as const, + runId: run.id, + // Content blocks already cross durable JSON boundaries elsewhere; + // the registry performs the authoritative lossless snapshot here. + output: result.output as unknown as JsonValue[], + } } finally { // Dispose before returning so no child session outlives the call. await run.dispose() diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 9e4d68c08c..a679b236fc 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -62,6 +62,12 @@ describe('dsh-tool-subagent', () => { const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' }) const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected subagent success') + expect(result.value).toEqual({ + kind: 'foreground', + runId: 'scripted-subagent:mock:parent-1', + output: [{ type: 'text', text: 'child says hi' }], + }) expect(text(result)).toBe('child says hi') }) @@ -637,6 +643,8 @@ describe('dsh-tool-subagent background mode', () => { const start = await callSubagent(ctx, { description: 'deep research', prompt: 'dig in', run_in_background: true }, { agent: parent }) expect(start.isError).toBe(false) + if (start.isError) throw new Error('expected background subagent success') + expect(start.value).toEqual({ kind: 'background', taskId: 'subagent-1' }) expect(text(start)).toBe('started background subagent task subagent-1') const collected = await ctx.tools.execute({ diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 91b4da4566..4a6b39be72 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -165,7 +165,7 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr // A result needs a prior matching call in the same step. (The converse // does NOT hold: a call may have no result — a throwing tool-execution // pipeline step ends the turn with no tool/result, which is legal.) - const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted' + const syntheticInterrupted = event.data.isError && event.data.error?.info?.code === 'interrupted' if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) { throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`) } diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 4f066eff8c..e9a09ea356 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -217,7 +217,7 @@ describe('session-log invariants', () => { callId: CallId('crashed'), content: [{ type: 'text', text: 'interrupted' }], isError: true, - error: { name: 'InterruptedError', code: 'interrupted' }, + error: { message: 'interrupted', info: { name: 'InterruptedError', code: 'interrupted' } }, }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) @@ -500,7 +500,7 @@ describe('surface contract under the invariants composition', () => { callId: CallId('rewrite'), content: [{ type: 'text' as const, text: 'original' }], isError: true, - error: { name: 'ExitError', code: 'EXIT_1' }, + error: { message: 'exit 1', info: { name: 'ExitError', code: 'EXIT_1' } }, meta: { presentation: { kind: 'terminal', output: 'full output' } }, futureField: { nested: ['preserve', 1] }, } @@ -585,7 +585,7 @@ describe('surface contract under the invariants composition', () => { ['callId', { callId: CallId('forged') }], ['turn', { turn: 2 }], ['step', { step: 2 }], - ['error', { error: { name: 'ExitError', code: 'DIFFERENT' } }], + ['error', { error: { message: 'exit 1', info: { name: 'ExitError', code: 'DIFFERENT' } } }], ['meta', { meta: { presentation: { kind: 'generic' } } }], ['future data', { futureField: { nested: ['changed'] } }], ])('rejects a content rewrite with altered %s', async (_label, altered) => { diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 7e1ab82971..69063b681c 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -10,6 +10,8 @@ The model-facing control surface for `ctx.tasks`: three kind-independent tools, All three use generic ACP cards: `read` for output and list, `execute` for kill. +Their canonical values are `{ text, task }`, `PublicTaskSnapshot[]`, and `{ outcome: 'cancellation-requested' | 'already-finished', task }`. A public snapshot carries id, kind, label, status/detail, and start/finish times; it deliberately omits `ownerSession` and the internal `reported` notice bit. Native renderers preserve the status and acknowledgement text above. + ## Completion notices An unreported completion injects `background task (: