From 0cdd6de3db842bff8e66c92b1daebc0fa15e70e6 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 19 Aug 2026 06:17:36 +0000 Subject: [PATCH] feat(agentic): a dsh adapter beside the pi one @agentic-kit/dsh binds the neutral HarnessTools to DeepSeek Harness (toDshTool, a zod->dsh JSON Schema narrowing, a plugin wiring the confirm gate to tools/pre-execute) and normalizes dsh session events into neutral transcript events. The Constructive gate deps move into db-tools so both adapters share them. --- .github/workflows/run-tests.yaml | 2 +- agentic/db-tools/README.md | 1 + agentic/db-tools/__tests__/gate.test.ts | 62 +++++ agentic/db-tools/src/gate.ts | 58 +++++ agentic/db-tools/src/index.ts | 1 + agentic/dsh/README.md | 58 +++++ agentic/dsh/__tests__/dsh-tool.test.ts | 218 ++++++++++++++++ agentic/dsh/__tests__/plugin.test.ts | 136 ++++++++++ agentic/dsh/__tests__/transcript.test.ts | 210 +++++++++++++++ agentic/dsh/jest.config.js | 21 ++ agentic/dsh/package.json | 45 ++++ agentic/dsh/src/dsh-tool.ts | 113 ++++++++ agentic/dsh/src/dsh-types.ts | 126 +++++++++ agentic/dsh/src/index.ts | 57 ++++ agentic/dsh/src/plugin.ts | 114 ++++++++ agentic/dsh/src/schema.ts | 189 ++++++++++++++ agentic/dsh/src/transcript.ts | 319 +++++++++++++++++++++++ agentic/dsh/tsconfig.esm.json | 7 + agentic/dsh/tsconfig.json | 8 + agentic/pi/src/confirm-gate.ts | 33 +-- pnpm-lock.yaml | 16 ++ 21 files changed, 1765 insertions(+), 29 deletions(-) create mode 100644 agentic/db-tools/__tests__/gate.test.ts create mode 100644 agentic/db-tools/src/gate.ts create mode 100644 agentic/dsh/README.md create mode 100644 agentic/dsh/__tests__/dsh-tool.test.ts create mode 100644 agentic/dsh/__tests__/plugin.test.ts create mode 100644 agentic/dsh/__tests__/transcript.test.ts create mode 100644 agentic/dsh/jest.config.js create mode 100644 agentic/dsh/package.json create mode 100644 agentic/dsh/src/dsh-tool.ts create mode 100644 agentic/dsh/src/dsh-types.ts create mode 100644 agentic/dsh/src/index.ts create mode 100644 agentic/dsh/src/plugin.ts create mode 100644 agentic/dsh/src/schema.ts create mode 100644 agentic/dsh/src/transcript.ts create mode 100644 agentic/dsh/tsconfig.esm.json create mode 100644 agentic/dsh/tsconfig.json diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index c5c3f866fa..1eb06359dc 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -115,7 +115,7 @@ jobs: - batch: graphile-unit packages: 'graphile/graphile-plugin-utils graphile/graphile-realtime-subscriptions graphile/graphile-sql-expression-validator graphile/graphile-upload-plugin graphile/graphile-storage-registry' - batch: agentic - packages: 'agentic/protocol agentic/agentic-kit agentic/agent agentic/harness agentic/chat agentic/cli agentic/db-tools agentic/pi agentic/react agentic/agentic-server agentic/anthropic agentic/openai agentic/ollama agentic/run-log agentic/metering' + packages: 'agentic/protocol agentic/agentic-kit agentic/agent agentic/harness agentic/chat agentic/cli agentic/db-tools agentic/pi agentic/dsh agentic/react agentic/agentic-server agentic/anthropic agentic/openai agentic/ollama agentic/run-log agentic/metering' - batch: pgpm-unit packages: 'pgpm/types pgpm/naming-spec pgpm/diff pgpm/import pgpm/slice pgpm/transform' - batch: pglite diff --git a/agentic/db-tools/README.md b/agentic/db-tools/README.md index e64d87695f..bf7f79a2a1 100644 --- a/agentic/db-tools/README.md +++ b/agentic/db-tools/README.md @@ -31,6 +31,7 @@ npm install @agentic-kit/db-tools - **Host injection** — credentials, backend endpoints and data-plane tokens come from the host application, not from the package. Call `configureHost()` once at startup; the tools read it lazily per call. - **Project context** — `resolveProjectContext` / `resolveDataToken` resolve the database a tool acts on from the cwd's `.env` plus the host's session, and `deriveSubdomainEndpoint` derives its per-database endpoints. - **Provisioning model** — the pinned `node-type-registry` presets, the provision manifest, and overlay resolution (`resolveProvisionModules`). +- **`constructiveGateDeps`** — the confirm gate's host capabilities (is the project runnable, is there a data token, what tables would a template copy) answered by these resolvers, so every adapter gates the same tools against the same project state instead of restating it. - **`toolSchema`** — a tool's zod parameters as plain JSON Schema, for adapters whose harness wants JSON Schema rather than zod. ## Usage diff --git a/agentic/db-tools/__tests__/gate.test.ts b/agentic/db-tools/__tests__/gate.test.ts new file mode 100644 index 0000000000..ea0db740c4 --- /dev/null +++ b/agentic/db-tools/__tests__/gate.test.ts @@ -0,0 +1,62 @@ +import type { ConfirmPreviewTable } from '@agentic-kit/harness'; + +import type { ProjectContext } from '../src/context'; +import { constructiveGateDeps, type ConstructiveGateResolvers } from '../src/gate'; + +const context = { databaseId: 'db-1' } as ProjectContext; +const table: ConfirmPreviewTable = { + name: 'contact', + fields: [], + policies: [], + relationCount: 0, +}; + +const resolvers = ( + overrides: Partial = {} +): Partial => ({ + resolveProjectContext: async () => ({ context, reason: '' }), + resolveDataToken: async () => ({ token: 'tok' }), + createTemplatePreviewTables: async () => ({ blueprintName: 'crm', tables: [table] }), + ...overrides, +}); + +describe('constructiveGateDeps', () => { + it('is runnable only with a resolved project', async () => { + await expect(constructiveGateDeps(resolvers()).isProjectRunnable('/p')).resolves.toBe(true); + await expect( + constructiveGateDeps( + resolvers({ resolveProjectContext: async () => ({ context: null, reason: 'no project' }) }) + ).isProjectRunnable('/p') + ).resolves.toBe(false); + }); + + it('has a data token only when one resolves for that project', async () => { + await expect(constructiveGateDeps(resolvers()).hasDataToken('/p')).resolves.toBe(true); + await expect( + constructiveGateDeps( + resolvers({ resolveDataToken: async () => ({ reason: 'signed out' }) }) + ).hasDataToken('/p') + ).resolves.toBe(false); + }); + + it('previews the tables a template would copy', async () => { + await expect( + constructiveGateDeps(resolvers()).resolveTemplatePreview('/p', 'crm', 'CRM') + ).resolves.toEqual({ + kind: 'template', + displayName: 'CRM', + blueprintName: 'crm', + tables: [table], + }); + }); + + it('has no preview when the blueprint contributes no table', async () => { + await expect( + constructiveGateDeps( + resolvers({ + createTemplatePreviewTables: async () => ({ blueprintName: '', tables: [] }), + }) + ).resolveTemplatePreview('/p', undefined, 'CRM') + ).resolves.toBeUndefined(); + }); +}); diff --git a/agentic/db-tools/src/gate.ts b/agentic/db-tools/src/gate.ts new file mode 100644 index 0000000000..34e25edf30 --- /dev/null +++ b/agentic/db-tools/src/gate.ts @@ -0,0 +1,58 @@ +import type { ConstructiveGateDeps } from '@agentic-kit/harness'; + +import { + type ProjectContext, + resolveDataToken as defaultResolveDataToken, + resolveProjectContext as defaultResolveProjectContext, +} from './context'; +import { createTemplatePreviewTables as defaultCreateTemplatePreviewTables } from './tools/templates'; + +/** The resolvers the deps are built from, so a test can substitute fakes. */ +export type ConstructiveGateResolvers = { + resolveProjectContext: typeof defaultResolveProjectContext; + resolveDataToken: typeof defaultResolveDataToken; + createTemplatePreviewTables: typeof defaultCreateTemplatePreviewTables; +}; + +/** + * The Constructive gate's host capabilities, answered by this package's own + * project/token/template resolvers. + * + * Every adapter gates the same tools against the same project state, so the + * mapping lives here rather than once per harness — an adapter's job is only + * to hand its harness's confirm surface to the gate. + */ +export function constructiveGateDeps( + resolvers: Partial = {} +): ConstructiveGateDeps { + const resolveProjectContext = resolvers.resolveProjectContext ?? defaultResolveProjectContext; + const resolveDataToken = resolvers.resolveDataToken ?? defaultResolveDataToken; + const createTemplatePreviewTables = + resolvers.createTemplatePreviewTables ?? defaultCreateTemplatePreviewTables; + + const context = async (cwd: string): Promise => + (await resolveProjectContext(cwd)).context; + + return { + isProjectRunnable: async (cwd) => (await context(cwd)) !== null, + + hasDataToken: async (cwd) => { + const resolved = await context(cwd); + if (!resolved) return false; + return Boolean((await resolveDataToken(resolved)).token); + }, + + resolveTemplatePreview: async (cwd, blueprintName, displayName) => { + const resolved = await context(cwd); + if (!resolved) return undefined; + const result = await createTemplatePreviewTables(resolved, blueprintName); + if (result.tables.length === 0) return undefined; + return { + kind: 'template', + displayName, + blueprintName: result.blueprintName || undefined, + tables: result.tables, + }; + }, + }; +} diff --git a/agentic/db-tools/src/index.ts b/agentic/db-tools/src/index.ts index 41a4d45768..ac85c3e8d6 100644 --- a/agentic/db-tools/src/index.ts +++ b/agentic/db-tools/src/index.ts @@ -67,6 +67,7 @@ export { resolveDataToken, resolveProjectContext, } from './context'; +export { constructiveGateDeps, type ConstructiveGateResolvers } from './gate'; export { type ActiveDataToken, configureHost, diff --git a/agentic/dsh/README.md b/agentic/dsh/README.md new file mode 100644 index 0000000000..e7a1623509 --- /dev/null +++ b/agentic/dsh/README.md @@ -0,0 +1,58 @@ +# @agentic-kit/dsh + +

+ +

+ +

+ + + + + +

+ +The **DeepSeek Harness (dsh)** adapter — the sibling of [`@agentic-kit/pi`](https://www.npmjs.com/package/@agentic-kit/pi), and the reason the harness contracts are neutral. The same 18 [`@agentic-kit/db-tools`](https://www.npmjs.com/package/@agentic-kit/db-tools), the same confirm gate and the same run-log vocabulary, bound to a second harness without any of them changing. + +``` +neutral contracts: HarnessTool ConfirmGate TranscriptEvent + │ │ ▲ + @agentic-kit/pi ────┼───────────────┼─────────────────┤ pi's ToolDefinition, tool_call, pi session + @agentic-kit/dsh ───┴───────────────┴─────────────────┘ dsh's ToolDefinition, tools/pre-execute, dsh events +``` + +```bash +npm install @agentic-kit/dsh +``` + +## What's inside + +- **`toDshTool` / `toDshTools`** — a neutral `HarnessTool` as dsh's `ToolDefinition`: parameters in dsh's JSON Schema subset, a declared canonical output whose value *is* the neutral `HarnessToolResult` (so dsh's durable log keeps the tool's structured `details`), and the caller's `AbortSignal` threaded to the tool. +- **`createConstructivePlugin`** — the tools as a dsh plugin, with Constructive's confirm gate on dsh's `tools/pre-execute` waterfall. A gated call asks dsh's approval service; a host with no approval service composed gets a `deny`, never a silent mutation. +- **`toDshParameters` / `convertDshParameters`** — zod → dsh's subset (`type`, `properties`, `required`, `items`, `oneOf`, `enum`, `const`, boolean `additionalProperties`). Constraints outside it are dropped from the *model-facing* schema and reported in `dropped`; they still hold, because a bound tool parses arguments with its own zod schema before the body runs. Structure that cannot degrade safely — a non-object root, a `$ref` — throws. +- **`dshTranscriptReader`** — dsh's session-event log as neutral `TranscriptEvent`s, so a Constructive surface renders a dsh run through the same projectors as a pi one. Import it from `@agentic-kit/dsh/transcript` in a browser: that entry point has no node dependency. + +## Usage + +```ts +import { configureHost } from '@agentic-kit/db-tools'; +import { createConstructivePlugin } from '@agentic-kit/dsh'; + +configureHost(host); + +export default createConstructivePlugin({ cwd: () => projectDir }); +``` + +Reading a dsh run back: + +```ts +import { dshTranscriptReader } from '@agentic-kit/dsh/transcript'; +import { TranscriptReaderRegistry } from '@agentic-kit/run-log'; + +const readers = new TranscriptReaderRegistry([piTranscriptReader, dshTranscriptReader]); +const events = readers.require(record.transcriptFormat).toEvents(record.entry); +``` + +## No dsh dependency + +dsh is a developer preview whose packages promise breaking changes, and whose published rc's trail its own source. So this adapter binds to the *shape* dsh asks for — a tool definition, a tool run context, a content block, a plugin's `apply` — declared structurally in `dsh-types.ts`, and has no `@deepseek-ai/*` dependency. A host on any rc registers the plugin; dsh's ESM-only graph never reaches a CJS consumer of this package. diff --git a/agentic/dsh/__tests__/dsh-tool.test.ts b/agentic/dsh/__tests__/dsh-tool.test.ts new file mode 100644 index 0000000000..f940749b1a --- /dev/null +++ b/agentic/dsh/__tests__/dsh-tool.test.ts @@ -0,0 +1,218 @@ +import { constructiveDbTools } from '@agentic-kit/db-tools'; +import { defineHarnessTool } from '@agentic-kit/harness'; +import { z } from 'zod'; + +import type { DshJsonSchema, DshToolRunContext } from '../src'; +import { convertDshParameters, toDshTool, toDshTools } from '../src'; + +/** dsh's enforced keyword set, so a drift in the narrowing is caught here. */ +const DSH_KEYWORDS = new Set([ + 'type', + 'oneOf', + 'properties', + 'required', + 'additionalProperties', + 'items', + 'enum', + 'const', + 'description', + 'title', + 'default', + 'examples' +]); +const DSH_TYPES = new Set(['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']); + +/** Every keyword or type dsh would reject, as a path. */ +function inSubset(node: DshJsonSchema, path = ''): string[] { + const violations: string[] = []; + for (const [key, value] of Object.entries(node)) { + const at = path === '' ? key : `${path}.${key}`; + if (!DSH_KEYWORDS.has(key)) violations.push(at); + if (key === 'type' && typeof value === 'string' && !DSH_TYPES.has(value)) { + violations.push(at); + } + if (key === 'properties') { + for (const [name, sub] of Object.entries(value as Record)) { + violations.push(...inSubset(sub, `${at}.${name}`)); + } + } + if (key === 'items') violations.push(...inSubset(value as DshJsonSchema, at)); + if (key === 'oneOf') { + (value as DshJsonSchema[]).forEach((sub, index) => { + violations.push(...inSubset(sub, `${at}[${index}]`)); + }); + } + } + return violations; +} + +const runContext = (signal = new AbortController().signal): DshToolRunContext => ({ + callId: 'call-1', + name: 'echo', + signal +}); + +const echo = defineHarnessTool({ + name: 'echo', + label: 'Echo', + description: 'Echo a message.', + promptSnippet: 'echo(message)', + promptGuidelines: ['Use it to prove the seam.'], + parameters: z.object({ + message: z.string().describe('Text to echo'), + times: z.number().int().min(1).max(5).optional() + }), + async execute(params, ctx) { + return { + content: [{ type: 'text' as const, text: `${params.message} @ ${ctx.cwd}` }], + details: { echoed: params.message, aborted: ctx.signal?.aborted ?? null } + }; + } +}); + +describe('toDshTool', () => { + it('carries the name, folds the prompt fields into dsh’s one description', () => { + const tool = toDshTool(echo); + expect(tool.name).toBe('echo'); + expect(tool.description).toBe( + 'Echo a message.\n\necho(message)\n\n- Use it to prove the seam.' + ); + }); + + it('declares parameters in dsh’s subset', () => { + const { parameters } = toDshTool(echo); + expect(parameters.type).toBe('object'); + expect(parameters.required).toEqual(['message']); + expect(parameters.properties?.message).toEqual({ + type: 'string', + description: 'Text to echo' + }); + // min/max are zod's to enforce, not dsh's to read. + expect(parameters.properties?.times).toEqual({ type: 'integer' }); + }); + + it('returns the neutral result as the canonical value and renders its content', async () => { + const tool = toDshTool(echo, { cwd: () => '/tmp/project' }); + const value = await tool.execute({ message: 'hi' }, runContext()); + + expect(value).toEqual({ + content: [{ type: 'text', text: 'hi @ /tmp/project' }], + details: { echoed: 'hi', aborted: false } + }); + expect(tool.output.render({ message: 'hi' }, value)).toEqual([ + { type: 'text', text: 'hi @ /tmp/project' } + ]); + }); + + it('parses arguments with the tool’s own schema, so dropped constraints still hold', async () => { + const tool = toDshTool(echo); + await expect(tool.execute({ message: 'hi', times: 9 }, runContext())).rejects.toThrow(); + await expect(tool.execute({}, runContext())).rejects.toThrow(); + }); + + it('forwards the caller’s cancellation to the tool', async () => { + const controller = new AbortController(); + controller.abort(); + const value = (await toDshTool(echo).execute( + { message: 'hi' }, + runContext(controller.signal) + )) as { details: { aborted: boolean } }; + expect(value.details.aborted).toBe(true); + }); + + it('renders an image block as its honest text form', () => { + const image = defineHarnessTool({ + name: 'shot', + label: 'Shot', + description: 'Screenshot.', + parameters: z.object({}), + async execute() { + return { + content: [{ type: 'image' as const, data: 'aGk=', mimeType: 'image/png' }], + details: null + }; + } + }); + const tool = toDshTool(image); + const rendered = tool.output.render( + {}, + { content: [{ type: 'image', data: 'aGk=', mimeType: 'image/png' }] } + ); + expect(rendered).toEqual([ + { type: 'text', text: '{"type":"image","data":"aGk=","mimeType":"image/png"}' } + ]); + }); + + it('binds a whole tool set in order', () => { + expect(toDshTools([echo, echo]).map((tool) => tool.name)).toEqual(['echo', 'echo']); + }); +}); + +describe('convertDshParameters', () => { + it('reports the constraints it dropped', () => { + const { dropped } = convertDshParameters( + z.object({ id: z.string().uuid(), rows: z.array(z.string()).min(1) }) + ); + expect(dropped).toContain('properties.id.format'); + expect(dropped).toContain('properties.rows.minItems'); + }); + + it('keeps enums, consts, nested objects and arrays', () => { + const { parameters } = convertDshParameters( + z.object({ + action: z.enum(['list', 'create']), + table: z.object({ name: z.string(), columns: z.array(z.string()) }) + }) + ); + expect(parameters.properties?.action?.enum).toEqual(['list', 'create']); + expect(parameters.properties?.table?.properties?.columns).toEqual({ + type: 'array', + items: { type: 'string' } + }); + }); + + it('never leaves `required` naming a property it dropped', () => { + const { parameters } = convertDshParameters(z.object({ id: z.string() }).catchall(z.string())); + for (const name of parameters.required ?? []) { + expect(Object.keys(parameters.properties ?? {})).toContain(name); + } + }); + + it('converts a disjoint union to dsh’s oneOf', () => { + const { parameters } = convertDshParameters( + z.object({ value: z.union([z.string(), z.number()]), note: z.string().nullable() }) + ); + expect(parameters.properties?.value?.oneOf).toEqual([{ type: 'string' }, { type: 'number' }]); + expect(parameters.properties?.note?.oneOf).toEqual([{ type: 'string' }, { type: 'null' }]); + }); + + it('opens an overlapping union rather than one dsh would reject', () => { + const { parameters, dropped } = convertDshParameters( + z.object({ + target: z.union([z.object({ table: z.string() }), z.object({ view: z.string() })]) + }) + ); + expect(parameters.properties?.target).toEqual({}); + expect(dropped).toContain('properties.target.anyOf'); + }); + + it('converts every Constructive db tool into dsh’s subset', () => { + for (const tool of constructiveDbTools) { + const { parameters } = convertDshParameters(tool.parameters); + expect(parameters.type).toBe('object'); + expect(inSubset(parameters)).toEqual([]); + } + }); + + it('refuses a non-object root rather than opening the schema', () => { + expect(() => convertDshParameters(z.string())).toThrow(/must be an object schema/); + }); + + it('refuses a $ref rather than handing dsh a reference it cannot read', () => { + type Node = { name: string; children: Node[] }; + const node: z.ZodType = z.lazy(() => + z.object({ name: z.string(), children: z.array(node) }) + ); + expect(() => convertDshParameters(z.object({ root: node }))).toThrow(/\$ref/); + }); +}); diff --git a/agentic/dsh/__tests__/plugin.test.ts b/agentic/dsh/__tests__/plugin.test.ts new file mode 100644 index 0000000000..d1da625d99 --- /dev/null +++ b/agentic/dsh/__tests__/plugin.test.ts @@ -0,0 +1,136 @@ +import { constructiveDbTools } from '@agentic-kit/db-tools'; +import { defineHarnessTool } from '@agentic-kit/harness'; +import { z } from 'zod'; + +import type { + DshApprovalOutcome, + DshPluginContext, + DshPreToolDecision, + DshToolDefinition, + DshToolExecution +} from '../src'; +import { createConstructivePlugin, DSH_PLUGIN_NAME } from '../src'; + +type PreExecuteListener = ( + exec: DshToolExecution, + next: () => Promise +) => Promise; + +class FakeContext implements DshPluginContext { + registered: DshToolDefinition[] = []; + listeners: PreExecuteListener[] = []; + asked: { toolName: string; callId?: string }[] = []; + outcome: DshApprovalOutcome = 'allowed-once'; + approval: DshPluginContext['approval']; + + constructor(withApproval = true) { + if (withApproval) { + this.approval = { + request: async (request) => { + this.asked.push({ toolName: request.toolName, callId: request.callId }); + return this.outcome; + } + }; + } + } + + tools = { + register: (definition: DshToolDefinition) => { + this.registered.push(definition); + return (): void => undefined; + } + }; + + on(_event: 'tools/pre-execute', listener: PreExecuteListener) { + this.listeners.push(listener); + return this; + } + + decide(exec: DshToolExecution): Promise { + return this.listeners[0](exec, async () => ({ kind: 'allow' })); + } +} + +const mutating = defineHarnessTool({ + name: 'delete_table', + label: 'Delete table', + description: 'Delete a table.', + parameters: z.object({ table: z.string() }), + async execute() { + return { content: [{ type: 'text' as const, text: 'deleted' }], details: null }; + } +}); + +const gate = { + isProjectRunnable: async () => true, + hasDataToken: async () => true, + resolveTemplatePreview: async (): Promise => undefined +}; + +describe('createConstructivePlugin', () => { + it('registers every Constructive db tool into dsh’s registry', () => { + const ctx = new FakeContext(); + const plugin = createConstructivePlugin({ gate: false }); + expect(plugin.name).toBe(DSH_PLUGIN_NAME); + expect(plugin.inject).toEqual(['tools']); + + plugin.apply(ctx); + + expect(ctx.registered).toHaveLength(constructiveDbTools.length); + expect(ctx.registered.map((tool) => tool.name)).toEqual( + constructiveDbTools.map((tool) => tool.name) + ); + expect(ctx.listeners).toHaveLength(0); + }); + + it('lets an ungated call through the pre-execute waterfall', async () => { + const ctx = new FakeContext(); + createConstructivePlugin({ tools: [mutating], gate }).apply(ctx); + + const decision = await ctx.decide({ callId: 'c1', name: 'describe_schema', arguments: {} }); + expect(decision).toEqual({ kind: 'allow' }); + expect(ctx.asked).toEqual([]); + }); + + it('asks dsh’s approval service for a gated call, and allows what it approves', async () => { + const ctx = new FakeContext(); + createConstructivePlugin({ tools: [mutating], gate }).apply(ctx); + + const decision = await ctx.decide({ + callId: 'c1', + name: 'delete_table', + arguments: { table: 'posts' } + }); + + expect(ctx.asked).toEqual([{ toolName: 'delete_table', callId: 'c1' }]); + expect(decision).toEqual({ kind: 'allow' }); + }); + + it('denies what the approval service rejects, with a reason the model reads', async () => { + const ctx = new FakeContext(); + ctx.outcome = 'rejected'; + createConstructivePlugin({ tools: [mutating], gate }).apply(ctx); + + const decision = await ctx.decide({ + callId: 'c1', + name: 'delete_table', + arguments: { table: 'posts' } + }); + + expect(decision.kind).toBe('deny'); + expect((decision as { reason: string }).reason).toMatch(/.+/); + }); + + it('fails closed on a host with no approval service composed', async () => { + const ctx = new FakeContext(false); + createConstructivePlugin({ tools: [mutating], gate }).apply(ctx); + + const decision = await ctx.decide({ + callId: 'c1', + name: 'delete_table', + arguments: { table: 'posts' } + }); + + expect(decision.kind).toBe('deny'); + }); +}); diff --git a/agentic/dsh/__tests__/transcript.test.ts b/agentic/dsh/__tests__/transcript.test.ts new file mode 100644 index 0000000000..23ff143d77 --- /dev/null +++ b/agentic/dsh/__tests__/transcript.test.ts @@ -0,0 +1,210 @@ +import { APPROVAL_REQUEST_TYPE, TranscriptReaderRegistry } from '@agentic-kit/run-log'; + +import { + assertDshSessionEvent, + DSH_TRANSCRIPT_FORMAT, + dshEventToEvents, + dshTranscriptReader +} from '../src/transcript'; + +const event = (type: string, data: Record, seq = 1) => ({ + type, + seq, + time: Date.UTC(2026, 0, 2, 3, 4, 5), + data +}); + +const at = '2026-01-02T03:04:05.000Z'; + +describe('assertDshSessionEvent', () => { + it('requires dsh’s envelope', () => { + expect(() => assertDshSessionEvent({ type: 'turn/start', time: 1 })).toThrow(/seq/); + expect(() => assertDshSessionEvent({ type: 'turn/start', seq: 1 })).toThrow(/time/); + expect(() => assertDshSessionEvent({ type: 'x', seq: 1, time: 1, data: 'no' })).toThrow(/data/); + expect(assertDshSessionEvent(event('turn/start', {})).seq).toBe(1); + }); +}); + +describe('dshEventToEvents', () => { + it('projects a human prompt as a user turn', () => { + expect( + dshEventToEvents( + event('user/message', { + source: { kind: 'user' }, + content: [{ type: 'text', text: 'provision a db' }] + }) + ) + ).toEqual([{ kind: 'text', role: 'user', text: 'provision a db', entryId: '1', at }]); + }); + + it('keeps dsh’s injected context out of the conversation', () => { + const [projected] = dshEventToEvents( + event('user/message', { + source: { kind: 'plugin', plugin: 'file-watch' }, + content: [{ type: 'text', text: 'src/index.ts changed' }] + }) + ); + expect(projected).toMatchObject({ + kind: 'custom', + customType: 'dsh.context.plugin', + display: false, + details: { plugin: 'file-watch' } + }); + }); + + it('splits an assistant message into a response, its text and its reasoning', () => { + const events = dshEventToEvents( + event('assistant/message', { + message: { + source: { provider: 'deepseek', model: 'deepseek-chat' }, + content: [ + { type: 'reasoning', text: 'the user wants a table' }, + { type: 'text', text: 'Creating it now.' }, + { type: 'tool-call', callId: 'c1', name: 'create_field' } + ] + }, + usage: { inputTokens: 100, outputTokens: 20, cacheReadTokens: 5 } + }) + ); + + expect(events).toEqual([ + { + kind: 'model-response', + model: 'deepseek-chat', + provider: 'deepseek', + usage: { input: 100, output: 20, cacheRead: 5, totalTokens: 125 }, + entryId: '1', + at + }, + { + kind: 'thinking', + text: 'the user wants a table', + entryId: '1', + at + }, + { + kind: 'text', + role: 'assistant', + text: 'Creating it now.', + model: 'deepseek-chat', + provider: 'deepseek', + entryId: '1', + at + } + ]); + }); + + it('parses a call’s argument string, and keeps a malformed one', () => { + expect( + dshEventToEvents( + event('tool/call', { callId: 'c1', name: 'add_records', arguments: '{"table":"posts"}' }) + ) + ).toEqual([ + { + kind: 'tool-call', + toolCallId: 'c1', + name: 'add_records', + arguments: { table: 'posts' }, + entryId: '1', + at + } + ]); + + const [broken] = dshEventToEvents( + event('tool/call', { callId: 'c2', name: 'add_records', arguments: '{"table":' }) + ); + expect(broken).toMatchObject({ arguments: { raw: '{"table":' } }); + }); + + it('projects a tool result, and marks a failed one', () => { + expect( + dshEventToEvents( + event('tool/result', { + message: { + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + content: [{ type: 'text', text: 'created 3 rows' }] + } + ] + }, + meta: { rows: 3 } + }) + ) + ).toEqual([ + { + kind: 'tool-result', + toolCallId: 'c1', + name: '', + output: 'created 3 rows', + failed: false, + details: { rows: 3 }, + entryId: '1', + at + } + ]); + + const [failed] = dshEventToEvents( + event('tool/result', { + message: { content: [{ type: 'tool-result', toolCallId: 'c1', isError: true }] }, + error: { name: 'Error', code: 'denied' } + }) + ); + expect(failed).toMatchObject({ failed: true }); + }); + + it('projects an approval ask into the neutral approval event a surface already renders', () => { + const [asked] = dshEventToEvents( + event('approval/asked', { id: 'a1', toolName: 'delete_table', callId: 'c1', reason: 'Drop posts?' }) + ); + expect(asked).toMatchObject({ + kind: 'custom', + customType: APPROVAL_REQUEST_TYPE, + text: 'Drop posts?', + details: { toolCallId: 'c1' } + }); + }); + + it('keeps an approval ask with no call to attach to readable', () => { + const [asked] = dshEventToEvents(event('approval/asked', { id: 'a1', toolName: 'bash' })); + expect(asked).toMatchObject({ kind: 'unknown', entryType: 'approval/asked' }); + }); + + it('drops the token chunks that an assistant message repeats in full', () => { + expect(dshEventToEvents(event('assistant/chunk', { delta: 'Cre' }))).toEqual([]); + }); + + it('keeps an event type it does not know rather than losing it', () => { + expect(dshEventToEvents(event('turn/start', { turn: 1 }))).toEqual([ + { + kind: 'unknown', + entryType: 'turn/start', + entry: event('turn/start', { turn: 1 }), + entryId: '1', + at + } + ]); + }); +}); + +describe('dshTranscriptReader', () => { + it('registers as the reader for the dsh format', () => { + const registry = new TranscriptReaderRegistry([dshTranscriptReader]); + expect(registry.require(DSH_TRANSCRIPT_FORMAT)).toBe(dshTranscriptReader); + expect(registry.formats()).toEqual(['dsh']); + }); + + it('reads a whole session in order', () => { + const entries = [ + event('turn/start', { turn: 1 }, 1), + event('user/message', { source: { kind: 'user' }, content: [{ type: 'text', text: 'hi' }] }, 2), + event('tool/call', { callId: 'c1', name: 'echo', arguments: '{}' }, 3) + ]; + const kinds = entries + .map((entry) => dshTranscriptReader.assertEntry(entry)) + .flatMap((entry) => dshTranscriptReader.toEvents(entry)) + .map((projected) => projected.kind); + expect(kinds).toEqual(['unknown', 'text', 'tool-call']); + }); +}); diff --git a/agentic/dsh/jest.config.js b/agentic/dsh/jest.config.js new file mode 100644 index 0000000000..8a26efd6d0 --- /dev/null +++ b/agentic/dsh/jest.config.js @@ -0,0 +1,21 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json', + }, + ], + }, + transformIgnorePatterns: [`/node_modules/*`], + testRegex: '(/__tests__/.*\\.(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, +}; diff --git a/agentic/dsh/package.json b/agentic/dsh/package.json new file mode 100644 index 0000000000..6b7a6d9db7 --- /dev/null +++ b/agentic/dsh/package.json @@ -0,0 +1,45 @@ +{ + "name": "@agentic-kit/dsh", + "version": "0.1.0", + "author": "Dan Lynch ", + "description": "the DeepSeek Harness adapter for agentic-kit — Constructive's typed db tools and confirm gate as a dsh plugin, plus a dsh transcript reader for the run log", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "homepage": "https://github.com/constructive-io/constructive", + "license": "SEE LICENSE IN LICENSE", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/constructive" + }, + "bugs": { + "url": "https://github.com/constructive-io/constructive/issues" + }, + "scripts": { + "clean": "makage clean", + "prepack": "npm run build", + "build": "makage build", + "build:dev": "makage build --dev", + "lint": "eslint . --fix", + "test": "jest", + "test:watch": "jest --watch" + }, + "dependencies": { + "@agentic-kit/db-tools": "workspace:^", + "@agentic-kit/harness": "workspace:^", + "@agentic-kit/run-log": "workspace:^", + "zod": "^4.4.3" + }, + "keywords": [ + "agentic-kit", + "harness", + "adapter", + "dsh", + "deepseek", + "constructive" + ] +} diff --git a/agentic/dsh/src/dsh-tool.ts b/agentic/dsh/src/dsh-tool.ts new file mode 100644 index 0000000000..42df515e67 --- /dev/null +++ b/agentic/dsh/src/dsh-tool.ts @@ -0,0 +1,113 @@ +import type { AnyHarnessTool, HarnessTool, HarnessToolResult } from '@agentic-kit/harness'; +import type { z } from 'zod'; + +import type { + DshContentBlock, + DshJsonSchema, + DshToolDefinition, + DshToolRunContext +} from './dsh-types'; +import { toDshParameters } from './schema'; + +/** What a bound tool needs that a `HarnessTool` does not state. */ +export interface ToDshToolOptions { + /** + * The working directory a tool resolves project context from. dsh keeps the + * directory on the session header and its filesystem service rather than on a + * tool's execution context, so the adapter is told once instead of guessing + * per call. Defaults to `process.cwd()`. + */ + cwd?: () => string; +} + +/** The canonical value a bound tool returns: the neutral result, as JSON. */ +const OUTPUT_SCHEMA: DshJsonSchema = { + type: 'object', + properties: { + content: { type: 'array', items: { type: 'object' } }, + details: {}, + terminate: { type: 'boolean' } + }, + required: ['content'] +}; + +/** + * Bind a neutral `HarnessTool` to dsh's `ToolDefinition`. + * + * The sibling of `toPiTool` — same tools, a second harness's shape. dsh asks + * for three things pi does not: parameters in its JSON Schema subset (see + * `./schema`), a *declared canonical output* with a pure projection from that + * value to model-facing content, and a body that returns the value rather than + * the content. So the neutral `HarnessToolResult` becomes the canonical value + * verbatim and `output.render` projects its `content` — which means dsh's + * durable log keeps the tool's structured `details`, and a Constructive + * renderer reads the same detail out of a dsh transcript as out of a pi one. + * + * Arguments are parsed with the tool's own zod schema before the body runs: + * dsh validates against the narrowed subset schema, and this restores every + * constraint that narrowing dropped. + */ +export function toDshTool( + tool: HarnessTool, + options: ToDshToolOptions = {} +): DshToolDefinition { + const cwd = options.cwd ?? (() => process.cwd()); + + return { + name: tool.name, + description: describe(tool), + parameters: toDshParameters(tool.parameters), + output: { + schema: OUTPUT_SCHEMA, + render: (_args, value) => renderContent(value) + }, + async execute(args: unknown, exec: DshToolRunContext): Promise { + const params = tool.parameters.parse(args) as z.output; + const result: HarnessToolResult = await tool.execute(params, { + cwd: cwd(), + signal: exec.signal + }); + return { + content: result.content, + details: result.details === undefined ? null : result.details, + ...(result.terminate === undefined ? {} : { terminate: result.terminate }) + }; + } + }; +} + +/** Bind a whole tool set, in registration order. */ +export function toDshTools( + tools: readonly AnyHarnessTool[], + options: ToDshToolOptions = {} +): DshToolDefinition[] { + return tools.map((tool) => toDshTool(tool, options)); +} + +/** + * dsh has one description field where pi has three, so a tool's prompt snippet + * and guidelines — the parts that tell a model *when* to reach for it — are + * folded in rather than dropped. + */ +function describe(tool: AnyHarnessTool): string { + const parts = [tool.description]; + if (tool.promptSnippet) parts.push(tool.promptSnippet); + if (tool.promptGuidelines?.length) { + parts.push(tool.promptGuidelines.map((line) => `- ${line}`).join('\n')); + } + return parts.join('\n\n'); +} + +/** The neutral result's content blocks, in dsh's block vocabulary. */ +function renderContent(value: unknown): DshContentBlock[] { + const content = (value as { content?: unknown } | null)?.content; + if (!Array.isArray(content)) return []; + + return content.map((block) => { + const typed = block as { type?: string; text?: unknown }; + if (typed.type === 'text') return { type: 'text', text: String(typed.text ?? '') }; + // dsh's image block references an attachment the attachment service owns, so + // an inline image cannot be handed over as one; its text form is honest. + return { type: 'text', text: JSON.stringify(block) }; + }); +} diff --git a/agentic/dsh/src/dsh-types.ts b/agentic/dsh/src/dsh-types.ts new file mode 100644 index 0000000000..dbcd3338fa --- /dev/null +++ b/agentic/dsh/src/dsh-types.ts @@ -0,0 +1,126 @@ +/** + * The DeepSeek Harness surface this adapter binds to, declared structurally. + * + * Nothing here imports `@deepseek-ai/dsh-*`, and the package has no dependency + * on it. That is deliberate rather than lazy: dsh is a developer preview whose + * packages promise breaking changes, and its published rc's trail its own + * source. A structural declaration of the four things we actually touch — a + * tool definition, a tool's run context, a content block, a plugin's `apply` — + * binds to the *shape* dsh asks for, so a host on any rc can register our tools + * without this package tracking their release train. It also keeps the adapter + * free of dsh's ESM-only graph: a CJS consumer imports it like any other + * agentic-kit package. + * + * Mirrored from dsh `0.1.0-rc.7` (`packages/core/tools`, `packages/core/session`, + * `packages/llm/llm`). Where dsh brands a string (`CallId`, `SessionId`) this + * uses `string` — a brand is theirs to enforce, and ours to carry. + */ + +/** dsh's supported JSON Schema subset, as a tool declares its parameters. */ +export interface DshJsonSchema { + type?: string; + properties?: Record; + required?: string[]; + items?: DshJsonSchema; + oneOf?: DshJsonSchema[]; + enum?: unknown[]; + const?: unknown; + additionalProperties?: boolean; + description?: string; + title?: string; + default?: unknown; + examples?: unknown[]; +} + +/** A model-facing content block. dsh names reasoning `reasoning`, not `thinking`. */ +export type DshContentBlock = + | { type: 'text'; text: string } + | { type: 'reasoning'; text: string } + | { type: string; [key: string]: unknown }; + +/** + * What dsh hands a tool body: call identity, the caller's cancellation, and + * the agent the call runs for. Notably *not* a working directory — dsh keeps + * that on the session header and its filesystem service — so an adapter has to + * supply one (see `ConstructivePluginOptions.cwd`). + */ +export interface DshToolRunContext { + readonly callId: string; + readonly name: string; + readonly signal: AbortSignal; + readonly agent?: unknown; +} + +/** A tool's canonical-output contract: a schema for the value, and its rendering. */ +export interface DshToolOutputDefinition { + readonly schema: DshJsonSchema; + render(args: unknown, value: unknown): DshContentBlock[]; +} + +/** A dsh tool, as `tools.register()` takes it. */ +export interface DshToolDefinition { + readonly name: string; + readonly description: string; + readonly parameters: DshJsonSchema; + readonly output: DshToolOutputDefinition; + execute(args: unknown, exec: DshToolRunContext): Promise; +} + +/** The pending call a `tools/pre-execute` listener decides on. */ +export interface DshToolExecution { + readonly callId: string; + readonly name: string; + readonly arguments: unknown; + readonly agent?: unknown; +} + +/** dsh's pre-dispatch decision. `ask` defers to its approval answerers. */ +export type DshPreToolDecision = + | { kind: 'allow' } + | { kind: 'deny'; reason: string } + | { kind: 'ask'; reason?: string }; + +/** dsh's approval outcomes; only `allowed-once` is an approval. */ +export type DshApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'; + +/** + * dsh's approval service, as much of it as a gate needs. Present on the plugin + * context only when the host composed `@deepseek-ai/dsh-user-approval`. + */ +export interface DshApprovalService { + request(request: { + toolName: string; + callId?: string; + reason?: string; + agent?: unknown; + }): Promise; +} + +/** The tool registry service (`ctx.tools`). */ +export interface DshToolRuntime { + register(definition: DshToolDefinition): () => void; +} + +/** + * The plugin context, narrowed to the services this adapter uses. dsh's own + * `Context` carries every composed service and a cordis event bus; a plugin + * only ever needs the parts it declared. + */ +export interface DshPluginContext { + tools: DshToolRuntime; + approval?: DshApprovalService; + on( + event: 'tools/pre-execute', + listener: ( + exec: DshToolExecution, + next: () => Promise + ) => Promise + ): unknown; +} + +/** A cordis plugin in its object form, which is how dsh bundles load one. */ +export interface DshPlugin { + readonly name: string; + readonly inject?: readonly string[]; + apply(ctx: DshPluginContext): void; +} diff --git a/agentic/dsh/src/index.ts b/agentic/dsh/src/index.ts new file mode 100644 index 0000000000..2cb00c0185 --- /dev/null +++ b/agentic/dsh/src/index.ts @@ -0,0 +1,57 @@ +/** + * `@agentic-kit/dsh` — the DeepSeek Harness adapter. + * + * The sibling of `@agentic-kit/pi`, and the reason the harness contracts are + * neutral: the same 18 Constructive tools, the same confirm gate and the same + * run-log vocabulary, bound to a second harness without any of them changing. + * Everything dsh-specific is here — its tool shape, its JSON Schema subset, its + * plugin surface, its session-event log — and nothing here reaches back into + * the neutral packages' internals. + * + * dsh is a developer preview whose packages promise breaking changes, so this + * adapter binds to its *shape* rather than its types (see `./dsh-types`): the + * package has no `@deepseek-ai/*` dependency, which also keeps dsh's ESM-only + * graph out of a CJS consumer's way. + */ + +export { toDshTool, type ToDshToolOptions,toDshTools } from './dsh-tool'; +export { + type DshApprovalOutcome, + type DshApprovalService, + type DshContentBlock, + type DshJsonSchema, + type DshPlugin, + type DshPluginContext, + type DshPreToolDecision, + type DshToolDefinition, + type DshToolExecution, + type DshToolOutputDefinition, + type DshToolRunContext, + type DshToolRuntime +} from './dsh-types'; +export { + type ConstructivePluginOptions, + createConstructivePlugin, + DSH_PLUGIN_NAME +} from './plugin'; +export { + convertDshParameters, + type DshSchemaConversion, + toDshParameters +} from './schema'; +/** + * The transcript reader, re-exported for a node host. A renderer imports + * `@agentic-kit/dsh/transcript` instead: that entry point pulls neither the db + * tools nor anything else a browser cannot load. + */ +export { + assertDshSessionEvent, + DSH_TRANSCRIPT_FORMAT, + dshEventToEvents, + type DshSessionEvent, + dshTranscriptReader, + SUPPORTED_DSH_TRANSCRIPT_VERSION +} from './transcript'; + +/** Stable adapter id, matching the transcript format its runs are logged under. */ +export const DSH_HARNESS_ID = 'dsh'; diff --git a/agentic/dsh/src/plugin.ts b/agentic/dsh/src/plugin.ts new file mode 100644 index 0000000000..6e019e314a --- /dev/null +++ b/agentic/dsh/src/plugin.ts @@ -0,0 +1,114 @@ +import { + configureHost, + constructiveDbTools, + constructiveGateDeps, + type ToolsHost +} from '@agentic-kit/db-tools'; +import type { AnyHarnessTool, ConfirmGateOptions, GateHost } from '@agentic-kit/harness'; +import { createConfirmGate } from '@agentic-kit/harness'; + +import { toDshTools } from './dsh-tool'; +import type { + DshApprovalService, + DshPlugin, + DshPluginContext, + DshPreToolDecision +} from './dsh-types'; + +export interface ConstructivePluginOptions { + /** Tools to register. Defaults to the whole `constructiveDbTools` set. */ + tools?: readonly AnyHarnessTool[]; + /** + * The directory the run is rooted at — the project a tool resolves its + * context and credentials from. Defaults to `process.cwd()`. + */ + cwd?: () => string; + /** + * The gate in front of a mutating call. Defaults to Constructive's database + * policy; pass `false` for a host that gates elsewhere (its own + * `tools/pre-execute` listener, a hook, an approval preset). + */ + gate?: ConfirmGateOptions | false; + /** The db tools' host contract, when it is not configured already. */ + host?: ToolsHost; +} + +export const DSH_PLUGIN_NAME = 'constructive-tools'; + +/** + * Constructive's tools as a dsh plugin. + * + * The sibling of `@agentic-kit/pi`'s `dbTools` extension: the same neutral + * tools, registered through dsh's own registry, with the same host-neutral + * confirm gate wired to dsh's `tools/pre-execute` waterfall instead of pi's + * `tool_call` event. Nothing Constructive-specific is duplicated — the tools, + * the gate policy and the decline memory all come from the neutral packages. + * + * A `deny` decision is dsh's own vocabulary for "this call does not run, and + * here is what to tell the model", which is exactly what the gate returns; an + * approval question goes to dsh's approval service when the host composed one, + * so a headless dsh run refuses a gated call rather than performing it + * unasked. + */ +export function createConstructivePlugin(options: ConstructivePluginOptions = {}): DshPlugin { + const cwd = options.cwd ?? (() => process.cwd()); + const tools = options.tools ?? constructiveDbTools; + + if (options.host) configureHost(options.host); + + return { + name: DSH_PLUGIN_NAME, + inject: ['tools'], + apply(ctx: DshPluginContext): void { + for (const definition of toDshTools(tools, { cwd })) { + ctx.tools.register(definition); + } + + if (options.gate === false) return; + + const gate = createConfirmGate(options.gate ?? constructiveGateDeps()); + + ctx.on('tools/pre-execute', async (exec, next): Promise => { + const result = await gate.onToolCall( + { + toolName: exec.name, + toolCallId: exec.callId, + input: (exec.arguments ?? undefined) as Record | undefined + }, + approvalHost(ctx.approval, exec), + cwd() + ); + if (result?.block) return { kind: 'deny', reason: result.reason }; + return next(); + }); + } + }; +} + +/** + * dsh's approval service as the gate's host. `allowed-once` is the only + * outcome that approves — `rejected`, `cancelled` and the fail-closed + * `unavailable` all decline — and a host with no approval service composed has + * no confirm surface at all, which the gate answers by blocking. + */ +function approvalHost( + approval: DshApprovalService | undefined, + exec: { callId: string; name: string; agent?: unknown } +): GateHost { + return { + hasUI: approval !== undefined, + confirmTool: async (_toolCallId, title, message) => { + if (!approval) return false; + const outcome = await approval.request({ + toolName: exec.name, + callId: exec.callId, + reason: `${title}\n\n${message}`, + ...(exec.agent === undefined ? {} : { agent: exec.agent }) + }); + return outcome === 'allowed-once'; + }, + // dsh records the ask and its outcome itself (`approval/asked`, + // `approval/decided`), so an auto-skipped retry needs no separate notice. + notifyToolSkipped: () => undefined + }; +} diff --git a/agentic/dsh/src/schema.ts b/agentic/dsh/src/schema.ts new file mode 100644 index 0000000000..b10c83c69c --- /dev/null +++ b/agentic/dsh/src/schema.ts @@ -0,0 +1,189 @@ +import { z } from 'zod'; + +import type { DshJsonSchema } from './dsh-types'; + +/** + * A neutral tool's zod parameters, as dsh's JSON Schema subset. + * + * dsh enforces a deliberately small schema vocabulary on every registered tool: + * `type`, `properties`, `required`, `items`, `oneOf`, `enum`, `const`, a boolean + * `additionalProperties`, and the annotations. A zod schema routinely produces + * more than that — `format` from `.uuid()`, `minimum`, `minItems`, `anyOf` from + * a union, an object-valued `additionalProperties` from a record, `$defs`/`$ref` + * from a reused sub-schema — and dsh rejects a tool carrying any of them. + * + * So this narrows: unsupported *constraints* are dropped, and unsupported + * *structure* throws. Dropping a constraint is safe here and only here, because + * the schema dsh receives is a hint to the model, not the enforcement: + * `toDshTool` parses the model's arguments with the tool's own zod schema + * before the body runs, so every constraint this drops is still applied — by + * the party that owns it. What cannot degrade is the shape a caller has to + * satisfy, which is why a non-object root or an unresolvable `$ref` is an error + * rather than an open schema. + */ + +const CONSTRAINTS = new Set([ + 'type', + 'properties', + 'required', + 'items', + 'oneOf', + 'enum', + 'const', + 'additionalProperties' +]); + +const ANNOTATIONS = new Set(['description', 'title', 'default', 'examples']); + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** What a conversion dropped, so a caller can log it rather than wonder. */ +export interface DshSchemaConversion { + parameters: DshJsonSchema; + /** + * Keywords dropped from the model-facing schema, as JSON-pointer-ish paths + * (`properties.rows.minItems`). Still enforced by zod at execute time. + */ + dropped: string[]; +} + +function narrow(node: unknown, path: string, dropped: string[]): DshJsonSchema { + if (!isRecord(node)) return {}; + + if (typeof node.$ref === 'string') { + throw new Error( + `tool parameters use a JSON Schema $ref ("${node.$ref}") at ${path || 'the root'}; ` + + 'dsh reads no references, so the schema must be inlined' + ); + } + + const out: Record = {}; + const source = widenUnions(node, path, dropped); + + for (const [key, value] of Object.entries(source)) { + const at = path === '' ? key : `${path}.${key}`; + + if (key === '$schema' || key === '$defs' || key === 'definitions') continue; + if (ANNOTATIONS.has(key)) { + out[key] = value; + continue; + } + if (!CONSTRAINTS.has(key)) { + dropped.push(at); + continue; + } + + switch (key) { + case 'properties': { + const properties: Record = {}; + for (const [name, sub] of Object.entries(isRecord(value) ? value : {})) { + properties[name] = narrow(sub, `${at}.${name}`, dropped); + } + out.properties = properties; + break; + } + case 'items': + out.items = narrow(value, at, dropped); + break; + case 'oneOf': + out.oneOf = (Array.isArray(value) ? value : []).map((sub, index) => + narrow(sub, `${at}[${index}]`, dropped) + ); + break; + case 'additionalProperties': + // Only the boolean form exists in dsh's subset; a zod record's + // object-valued form degrades to the open default. + if (typeof value === 'boolean') out.additionalProperties = value; + else dropped.push(at); + break; + default: + out[key] = value; + } + } + + // `required` may not name a property the narrowed schema no longer declares. + if (Array.isArray(out.required)) { + const declared = new Set(Object.keys((out.properties as Record) ?? {})); + const kept = out.required.filter((name) => typeof name === 'string' && declared.has(name)); + if (kept.length === 0) delete out.required; + else out.required = kept; + } + + return out as DshJsonSchema; +} + +/** + * `anyOf` and a type array as dsh's `oneOf`, where that is sound. + * + * zod emits `anyOf` for a union and `type: ['string', 'null']` for a nullable; + * dsh has neither, only `oneOf` — which validates *exactly one* branch. That is + * the same thing as `anyOf` only when the branches are disjoint, which is true + * of the shapes zod actually produces here (a nullable, a union of distinct + * scalar types) and not true in general. So a disjoint union converts, and an + * overlapping one degrades to unconstrained rather than becoming a schema that + * rejects a legitimate argument. + */ +function widenUnions( + node: Record, + path: string, + dropped: string[] +): Record { + const rest = { ...node }; + let branches: unknown[] | undefined; + + if (Array.isArray(rest.anyOf)) { + branches = rest.anyOf; + delete rest.anyOf; + } else if (Array.isArray(rest.type)) { + branches = rest.type.map((type) => ({ type })); + delete rest.type; + } + + if (!branches || rest.oneOf !== undefined) return node; + + const types = branches.map((branch) => + isRecord(branch) && typeof branch.type === 'string' ? branch.type : undefined + ); + const disjoint = + branches.length >= 2 && + types.every((type) => type !== undefined) && + new Set(types).size === types.length; + + if (!disjoint) { + dropped.push(path === '' ? 'anyOf' : `${path}.anyOf`); + return rest; + } + + return { ...rest, oneOf: branches }; +} + +/** Convert and report, for a host that wants to see what degraded. */ +export function convertDshParameters(schema: z.ZodType): DshSchemaConversion { + const dropped: string[] = []; + // zod's own emitter, in input mode (what a *caller* must send) against + // draft-7 — the dialect dsh's subset is carved out of. + const jsonSchema = z.toJSONSchema(schema, { target: 'draft-7', io: 'input' }); + const narrowed = narrow(jsonSchema, '', dropped); + + if (narrowed.type !== 'object') { + throw new Error( + `tool parameters must be an object schema; received ${String(narrowed.type ?? 'no type')}. ` + + 'dsh names every argument, so a tool cannot take a bare value or a top-level union' + ); + } + + return { + parameters: { + type: 'object', + properties: narrowed.properties ?? {}, + ...(narrowed.required ? { required: narrowed.required } : {}), + ...(narrowed.description ? { description: narrowed.description } : {}) + }, + dropped + }; +} + +/** The dsh-subset JSON Schema for a tool's parameters. */ +export const toDshParameters = (schema: z.ZodType): DshJsonSchema => + convertDshParameters(schema).parameters; diff --git a/agentic/dsh/src/transcript.ts b/agentic/dsh/src/transcript.ts new file mode 100644 index 0000000000..422f7de255 --- /dev/null +++ b/agentic/dsh/src/transcript.ts @@ -0,0 +1,319 @@ +/** + * DeepSeek Harness's transcript reader: dsh session events → neutral events. + * + * The read half of the adapter, and deliberately the only file in this package + * a renderer imports (`@agentic-kit/dsh/transcript`): it is browser-safe, has + * no dsh dependency and no db-tools dependency, so a dashboard can project a + * dsh run without pulling a node graph. `@agentic-kit/run-log` owns the neutral + * vocabulary and the registry; the format's meaning lives here, beside the + * adapter that produces it. + * + * dsh's log differs from pi's in three ways that matter: + * - it is an *event* log, not a message log: a tool call and its result are + * separate events with their own sequence numbers, and a step boundary is an + * event of its own; + * - `time` is epoch milliseconds, not an ISO string; + * - assistant reasoning is a `reasoning` content block, and a tool call's + * arguments arrive as the raw JSON string the model produced. + * + * Register the reader once at host startup, e.g. + * `transcriptReaders.register(dshTranscriptReader)`. + */ + +import { + APPROVAL_REQUEST_TYPE, + APPROVAL_RESOLUTION_TYPE, + assertTranscriptEntry, + type TranscriptEntry, + type TranscriptEvent, + type TranscriptReader, + type TranscriptUsage +} from '@agentic-kit/run-log'; + +/** dsh's session-event log (`@deepseek-ai/dsh-session`). */ +export const DSH_TRANSCRIPT_FORMAT = 'dsh'; + +/** + * dsh's `SESSION_FORMAT_VERSION` as of `0.1.0-rc.7`. It bumps only when the + * event envelope or the surface mechanism changes — a new event *type* does + * not bump it, which is why an unrecognized type here becomes an `unknown` + * event rather than a refusal. + */ +export const SUPPORTED_DSH_TRANSCRIPT_VERSION = 0; + +/** One entry of a dsh session log, structurally. */ +export interface DshSessionEvent extends TranscriptEntry { + type: string; + seq?: number; + time?: number; + data?: Record; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Narrow an untrusted dsh event. `seq` and `time` are part of dsh's envelope + * rather than optional decoration, so an entry missing them is not a dsh event + * and must not be stored as one. + */ +export function assertDshSessionEvent(value: unknown): DshSessionEvent { + const entry = assertTranscriptEntry(value); + if (typeof entry.seq !== 'number' || !Number.isFinite(entry.seq)) { + throw new TypeError('dsh session event must carry a numeric `seq`'); + } + if (typeof entry.time !== 'number' || !Number.isFinite(entry.time)) { + throw new TypeError('dsh session event must carry a numeric `time` (epoch ms)'); + } + if (entry.data !== undefined && !isRecord(entry.data)) { + throw new TypeError('dsh session event `data` must be an object when present'); + } + return entry as DshSessionEvent; +} + +/** What a single dsh event means, in order. */ +export function dshEventToEvents(entry: TranscriptEntry): TranscriptEvent[] { + const event = entry as DshSessionEvent; + const data = isRecord(event.data) ? event.data : {}; + const base = { + ...(typeof event.seq === 'number' ? { entryId: String(event.seq) } : {}), + ...(typeof event.time === 'number' ? { at: new Date(event.time).toISOString() } : {}) + }; + + switch (event.type) { + case 'user/message': { + // A user-role event covers a human prompt and dsh's own injected context + // (file-change notices, skill content); `source.kind` tells them apart, + // and only a human one belongs in the conversation as a user turn. + const source = isRecord(data.source) ? data.source : {}; + const text = blockText(data.content); + if (source.kind === 'user') { + return [{ kind: 'text', role: 'user', text, ...base }]; + } + return [ + { + kind: 'custom', + customType: `dsh.context.${String(source.kind ?? 'unknown')}`, + text, + display: false, + ...(source.plugin === undefined ? {} : { details: { plugin: source.plugin } }), + ...base + } + ]; + } + + case 'assistant/message': { + const message = isRecord(data.message) ? data.message : {}; + const source = isRecord(message.source) ? message.source : {}; + const model = typeof source.model === 'string' ? source.model : undefined; + const provider = typeof source.provider === 'string' ? source.provider : undefined; + const usage = tokenUsage(data.usage); + + const events: TranscriptEvent[] = [ + { + kind: 'model-response', + ...(model ? { model } : {}), + ...(provider ? { provider } : {}), + ...(usage ? { usage } : {}), + ...base + } + ]; + + for (const block of Array.isArray(message.content) ? message.content : []) { + if (!isRecord(block)) continue; + if (block.type === 'text' && typeof block.text === 'string' && block.text.length > 0) { + events.push({ + kind: 'text', + role: 'assistant', + text: block.text, + ...(model ? { model } : {}), + ...(provider ? { provider } : {}), + ...base + }); + } else if (block.type === 'reasoning' && typeof block.text === 'string') { + events.push({ kind: 'thinking', text: block.text, ...base }); + } + // A `tool-call` block is also logged as its own `tool/call` event, which + // is the one this reader projects — projecting both would double every + // call in a trace. + } + + return events; + } + + case 'tool/call': + return [ + { + kind: 'tool-call', + toolCallId: String(data.callId ?? ''), + name: String(data.name ?? ''), + arguments: parseArguments(data.arguments), + ...base + } + ]; + + case 'tool/result': { + const message = isRecord(data.message) ? data.message : {}; + const block = (Array.isArray(message.content) ? message.content : []).find( + (candidate): candidate is Record => + isRecord(candidate) && candidate.type === 'tool-result' + ); + const source = isRecord(message.source) ? message.source : {}; + const error = isRecord(data.error) ? data.error : undefined; + return [ + { + kind: 'tool-result', + toolCallId: String(block?.toolCallId ?? source.callId ?? ''), + // dsh's result carries the call id, not the tool name; a projector + // pairs it with the `tool/call` that named it. + name: '', + output: blockText(block?.content), + failed: block?.isError === true || error !== undefined, + ...(data.meta === undefined ? {} : { details: data.meta }), + ...base + } + ]; + } + + case 'approval/asked': { + const callId = typeof data.callId === 'string' ? data.callId : undefined; + const reason = typeof data.reason === 'string' ? data.reason : ''; + if (!callId) break; + return [ + { + kind: 'custom', + customType: APPROVAL_REQUEST_TYPE, + text: reason || `Approve ${String(data.toolName ?? 'tool call')}?`, + display: true, + details: { toolCallId: callId }, + ...base + } + ]; + } + + case 'approval/decided': { + const outcome = String(data.outcome ?? ''); + return [ + { + kind: 'custom', + customType: APPROVAL_RESOLUTION_TYPE, + text: outcome, + display: true, + details: { + // dsh pairs a decision with its ask by approval id; the request + // carried the call id, so a projector joins through the ask. + approvalId: data.id, + decision: outcome === 'allowed-once' ? 'approved' : 'rejected', + reason: outcome + }, + ...base + } + ]; + } + + case 'compaction/summary': + return [ + { + kind: 'summary', + reason: 'compaction', + summary: typeof data.summary === 'string' ? data.summary : blockText(data.content), + ...base + } + ]; + + case 'command/run': + return [ + { + kind: 'bash', + command: String(data.command ?? ''), + output: '', + ...base + } + ]; + + case 'command/done': + return [ + { + kind: 'bash', + command: String(data.command ?? ''), + output: blockText(data.content) || String(data.output ?? ''), + ...(typeof data.exitCode === 'number' ? { exitCode: data.exitCode } : {}), + ...base + } + ]; + + // Token-level replay of an `assistant/message` that is projected in full. + case 'assistant/chunk': + return []; + + default: + break; + } + + return [{ kind: 'unknown', entryType: event.type, entry, ...base }]; +} + +/** dsh's session-event log, as a registrable reader. */ +export const dshTranscriptReader: TranscriptReader = { + format: DSH_TRANSCRIPT_FORMAT, + version: SUPPORTED_DSH_TRANSCRIPT_VERSION, + assertEntry: assertDshSessionEvent, + toEvents: dshEventToEvents +}; + +/** The text of a dsh content-block array. */ +function blockText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .map((block) => { + if (!isRecord(block)) return ''; + if (typeof block.text === 'string') return block.text; + if (Array.isArray(block.content)) return blockText(block.content); + return ''; + }) + .filter((text) => text.length > 0) + .join('\n'); +} + +/** + * A tool call's arguments. dsh logs the raw JSON string the model produced, so + * a malformed call is *in* the log — it becomes the string it was rather than + * failing the whole entry. + */ +function parseArguments(value: unknown): Record { + if (isRecord(value)) return value; + if (typeof value !== 'string' || value.length === 0) return {}; + try { + const parsed: unknown = JSON.parse(value); + return isRecord(parsed) ? parsed : { value: parsed }; + } catch { + return { raw: value }; + } +} + +/** + * dsh's `TokenUsage` in the neutral vocabulary. Its input counts are disjoint + * — cached input is reported apart from `inputTokens` — so a total is the sum + * rather than the input field. + */ +function tokenUsage(value: unknown): TranscriptUsage | undefined { + if (!isRecord(value)) return undefined; + const input = numeric(value.inputTokens); + const output = numeric(value.outputTokens); + const cacheRead = numeric(value.cacheReadTokens); + const cacheWrite = numeric(value.cacheWriteTokens); + const usage: TranscriptUsage = { + ...(input === undefined ? {} : { input }), + ...(output === undefined ? {} : { output }), + ...(cacheRead === undefined ? {} : { cacheRead }), + ...(cacheWrite === undefined ? {} : { cacheWrite }) + }; + if (Object.keys(usage).length === 0) return undefined; + usage.totalTokens = + (input ?? 0) + (output ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0); + return usage; +} + +const numeric = (value: unknown): number | undefined => + typeof value === 'number' && Number.isFinite(value) ? value : undefined; diff --git a/agentic/dsh/tsconfig.esm.json b/agentic/dsh/tsconfig.esm.json new file mode 100644 index 0000000000..624ab17cfa --- /dev/null +++ b/agentic/dsh/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "es2022", + "outDir": "dist/esm" + } +} diff --git a/agentic/dsh/tsconfig.json b/agentic/dsh/tsconfig.json new file mode 100644 index 0000000000..df063b5ee3 --- /dev/null +++ b/agentic/dsh/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/agentic/pi/src/confirm-gate.ts b/agentic/pi/src/confirm-gate.ts index 08dc438cc4..0283489f2a 100644 --- a/agentic/pi/src/confirm-gate.ts +++ b/agentic/pi/src/confirm-gate.ts @@ -3,6 +3,7 @@ import type { resolveDataToken, resolveProjectContext } from '@agentic-kit/db-tools'; +import { constructiveGateDeps } from '@agentic-kit/db-tools'; import type { ConfirmPreview } from '@agentic-kit/harness'; import { type ConfirmGate as HarnessConfirmGate, @@ -31,9 +32,9 @@ type RichConfirmUi = { // Thin pi adapter over the host-neutral gate in @agentic-kit/harness: pi's // ToolCallEvent/ExtensionContext are mapped onto the package's -// GateToolCallEvent/GateHost, and the host-backed resolvers below are -// wrapped into its ConfirmGateDeps. index.ts wires the real resolvers, tests -// substitute fakes. +// GateToolCallEvent/GateHost, and the host-backed resolvers below become its +// deps through db-tools' `constructiveGateDeps`. index.ts wires the real +// resolvers, tests substitute fakes. export type ConfirmGateDeps = { resolveProjectContext: typeof resolveProjectContext; resolveDataToken: typeof resolveDataToken; @@ -61,31 +62,7 @@ export function createConfirmGate(options: ConfirmGateOptions): ConfirmGate { const gate: HarnessConfirmGate = createHarnessConfirmGate( 'policy' in options ? options - : { - gatedTools: options.gatedTools, - isProjectRunnable: async (cwd) => { - const resolved = await options.resolveProjectContext(cwd); - return resolved.context !== null; - }, - hasDataToken: async (cwd) => { - const resolved = await options.resolveProjectContext(cwd); - if (!resolved.context) return false; - const token = await options.resolveDataToken(resolved.context); - return Boolean(token.token); - }, - resolveTemplatePreview: async (cwd, blueprintName, displayName) => { - const resolved = await options.resolveProjectContext(cwd); - if (!resolved.context) return undefined; - const result = await options.createTemplatePreviewTables(resolved.context, blueprintName); - if (result.tables.length === 0) return undefined; - return { - kind: 'template', - displayName, - blueprintName: result.blueprintName || undefined, - tables: result.tables, - }; - }, - }, + : { gatedTools: options.gatedTools, ...constructiveGateDeps(options) }, ); return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f624c8e8f1..9334425d8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,22 @@ importers: version: 1.1.38 publishDirectory: dist + agentic/dsh: + dependencies: + '@agentic-kit/db-tools': + specifier: workspace:^ + version: link:../db-tools/dist + '@agentic-kit/harness': + specifier: workspace:^ + version: link:../harness/dist + '@agentic-kit/run-log': + specifier: workspace:^ + version: link:../run-log/dist + zod: + specifier: ^4.4.3 + version: 4.4.3 + publishDirectory: dist + agentic/harness: dependencies: appstash: