diff --git a/README.md b/README.md index 0f8b40d..e073067 100644 --- a/README.md +++ b/README.md @@ -28,335 +28,279 @@ npm install subconscious yarn add subconscious ``` -## Quick Start +## Quick start -```typescript -import { Subconscious } from 'subconscious'; +```ts +import { Subconscious, tools } from 'subconscious'; +import { z } from 'zod'; -const client = new Subconscious({ - apiKey: process.env.SUBCONSCIOUS_API_KEY!, +const client = new Subconscious({ apiKey: process.env.SUBCONSCIOUS_API_KEY! }); + +const Summary = z.object({ + summary: z.string(), + score: z.number(), }); -const run = await client.run({ - engine: 'tim-large', +// Block until done — type-narrowed by the answerFormat schema: +const run = await client.runAndWait<{ summary: string; score: number }>({ + engine: 'tim-claude', input: { - instructions: 'Search for the latest AI news and summarize the top 3 stories', - tools: [{ type: 'platform', id: 'parallel_search', options: {} }], + instructions: 'Summarize and score this article: …', + tools: [tools.platform('parallel_search')], + answerFormat: Summary, // pass Zod directly }, - options: { awaitCompletion: true }, }); -console.log(run.result?.answer); +console.log(run.result?.answer.summary); // string, typed +console.log(run.result?.answer.score); // number, typed ``` -## Get Your API Key - -Create an API key in the [Subconscious dashboard](https://www.subconscious.dev/platform). - -## Usage +## Three ways to start a run -### Run and Wait +### 1. Fire-and-forget — `client.run` -The simplest way to use the SDK—create a run and wait for completion: +Returns the `runId` immediately. Use this when a background process polls or +when you've persisted the run id and will pick it up later. -```typescript -const run = await client.run({ - engine: 'tim-large', - input: { - instructions: 'Analyze the latest trends in renewable energy', - tools: [{ type: 'platform', id: 'parallel_search', options: {} }], - }, - options: { awaitCompletion: true }, +```ts +const { runId } = await client.run({ + engine: 'tim-claude', + input: { instructions: 'Search AI news' }, }); -console.log(run.result?.answer); -console.log(run.result?.reasoning); // Structured reasoning nodes +await db.insert({ runId, status: 'queued' }); ``` -### Fire and Forget +### 2. Block until done — `client.runAndWait` -Start a run without waiting, then check status later: +Creates the run and polls until it reaches a terminal state. -```typescript -const run = await client.run({ - engine: 'tim-large', - input: { - instructions: 'Generate a comprehensive report', - tools: [], - }, +```ts +const run = await client.runAndWait({ + engine: 'tim-claude', + input: { instructions: 'Search AI news' }, }); -console.log(`Run started: ${run.runId}`); - -// Check status later -const status = await client.get(run.runId); -console.log(status.status); // 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled' | 'timed_out' +console.log(run.result?.answer); ``` -### Poll with Custom Options - -```typescript -const run = await client.run({ - engine: 'tim-large', - input: { - instructions: 'Complex task', - tools: [{ type: 'platform', id: 'parallel_search' }], - }, -}); - -// Wait with custom polling options -const result = await client.wait(run.runId, { - intervalMs: 2000, // Poll every 2 seconds - maxAttempts: 60, // Give up after 60 attempts -}); +### 3. Stream — `client.stream` + +Returns an async iterable of typed events. The first event is always +`started` (carrying `runId`); the last is always `done`. Exactly one +`result` (success) or `error` (failure) event fires before `done`. + +```ts +for await (const event of client.stream({ + engine: 'tim-claude', + input: { instructions: 'Write an essay about ravens' }, +})) { + switch (event.type) { + case 'started': + console.log('runId:', event.runId); + break; + case 'delta': + process.stdout.write(event.content); + break; + case 'reasoning_node': + console.log('\nstep:', event.node.title); + break; + case 'tool_call': + console.log('tool:', event.call.tool_name, event.call.parameters); + break; + case 'result': + console.log('\nfinal answer:', event.result.answer); + break; + case 'error': + console.error(`[${event.code}] ${event.message}`); + break; + } +} ``` -### Streaming (Text Deltas) +## Re-attaching to a run — `client.observe` -Stream text as it's generated: +Pick up a live or already-finished run and stream its events from the +durable buffer. Same wire format as `stream()`. Useful when a worker +restarts. -```typescript -const stream = client.stream({ - engine: 'tim-large', - input: { - instructions: 'Write a short essay about space exploration', - tools: [{ type: 'platform', id: 'parallel_search' }], - }, -}); +```ts +const { runId } = await client.run({ engine: 'tim-claude', input }); +await db.persist(runId); -for await (const event of stream) { - if (event.type === 'delta') { - process.stdout.write(event.content); - } else if (event.type === 'done') { - console.log('\n\nRun completed:', event.runId); - } else if (event.type === 'error') { - console.error('Error:', event.message); - } +// … later, possibly in a different process: +for await (const event of client.observe(runId)) { + if (event.type === 'result') console.log(event.result.answer); } ``` -> **Note**: Rich streaming events (reasoning steps, tool calls) are coming soon. Currently, the stream provides text deltas as they're generated. +## Tools -### Tools - -```typescript -// Platform tools (hosted by Subconscious) -const parallelSearch = { - type: 'platform', - id: 'parallel_search', - options: {}, -}; +Use the `tools` builder to construct tool blocks without juggling the +discriminated union by hand: -// Function tools (your own functions) -const customFunction = { - type: 'function', - function: { - name: 'get_weather', - description: 'Get current weather for a location', - parameters: { - type: 'object', - properties: { - location: { type: 'string' }, - }, - required: ['location'], - }, - url: 'https://api.example.com/weather', - method: 'GET', - timeout: 30, - }, -}; +```ts +import { tools } from 'subconscious'; +import { z } from 'zod'; -// MCP tools -const mcpTool = { - type: 'mcp', - url: 'https://mcp.example.com', - allow: ['read', 'write'], +const input = { + instructions: 'Look up customers and send a follow-up email', + tools: [ + // Hosted platform tools (search, summarize, etc.) + tools.platform('parallel_search'), + + // Hosted runtime resources — sandbox / memory / browser + tools.resource('sandbox'), + + // Function tools the engine dispatches via HTTP POST + tools.function({ + name: 'sendEmail', + url: 'https://api.example.com/email', + parameters: z.object({ + to: z.string(), + body: z.string(), + }), + // Hidden values merged into the dispatched body. The model never + // sees these and the SDK auto-promotes them into `parameters` so + // the engine has a complete schema: + defaults: { sender_id: 'svc_abc' }, + headers: { Authorization: 'Bearer xyz' }, + }), + + // MCP servers — supports header-based auth (no URL placeholder hacks) + tools.mcp({ + url: 'https://mcp.example.com', + headers: { Authorization: 'Bearer xyz' }, + }), + ], }; ``` -### Structured Output - -Get structured responses using JSON Schema. We recommend using [Zod](https://zod.dev) to define your schema, then convert it with `zodToJsonSchema()`: - -```typescript -import { z } from 'zod'; -import { Subconscious, zodToJsonSchema } from 'subconscious'; +### Client-level FunctionTool overlays -const client = new Subconscious({ apiKey: process.env.SUBCONSCIOUS_API_KEY! }); +Avoid duplicating shared auth on every function tool: -// Define your output schema with Zod -const AnalysisSchema = z.object({ - summary: z.string().describe('A brief summary of the findings'), - keyPoints: z.array(z.string()).describe('Main takeaways'), - sentiment: z.enum(['positive', 'neutral', 'negative']), - confidence: z.number().describe('Confidence score from 0 to 1'), -}); - -const run = await client.run({ - engine: 'tim-large', - input: { - instructions: 'Analyze the latest news about electric vehicles', - tools: [{ type: 'platform', id: 'parallel_search', options: {} }], - answerFormat: zodToJsonSchema(AnalysisSchema, 'Analysis'), - }, - options: { awaitCompletion: true }, +```ts +const client = new Subconscious({ + apiKey: process.env.SUBCONSCIOUS_API_KEY!, + defaultFunctionToolHeaders: { Authorization: 'Bearer xyz' }, + defaultFunctionToolDefaults: { tenant_id: 't_abc' }, }); - -// Result is typed according to your schema -const result = run.result?.answer as z.infer; -console.log(result.summary); -console.log(result.keyPoints); ``` -You can also define a `reasoningFormat` to structure the agent's reasoning: +Per-tool values win on conflict. -```typescript -const ReasoningSchema = z.object({ - steps: z.array(z.object({ - thought: z.string(), - action: z.string(), - })), - conclusion: z.string(), -}); +## Structured output -const run = await client.run({ - engine: 'tim-large', - input: { - instructions: 'Research and explain quantum computing', - tools: [{ type: 'platform', id: 'parallel_search', options: {} }], - answerFormat: zodToJsonSchema(AnalysisSchema, 'Analysis'), - reasoningFormat: zodToJsonSchema(ReasoningSchema, 'Reasoning'), - }, - options: { awaitCompletion: true }, -}); -``` +Pass a Zod schema directly — the SDK converts it for you: -#### Manual JSON Schema +```ts +import { z } from 'zod'; -You can also provide the JSON Schema directly without Zod: +const Result = z.object({ + summary: z.string(), + score: z.number(), +}); -```typescript -const run = await client.run({ - engine: 'tim-large', +const run = await client.runAndWait<{ summary: string; score: number }>({ + engine: 'tim-claude', input: { - instructions: 'Analyze this topic', - tools: [], - answerFormat: { - title: 'Analysis', - type: 'object', - properties: { - summary: { type: 'string', description: 'Brief summary' }, - score: { type: 'number', description: 'Score from 1-10' }, - }, - required: ['summary', 'score'], - }, + instructions: 'Rate this article…', + answerFormat: Result, }, - options: { awaitCompletion: true }, }); -``` - -### Error Handling - -```typescript -import { SubconsciousError, AuthenticationError, RateLimitError } from 'subconscious'; - -try { - const run = await client.run({ /* ... */ }); -} catch (error) { - if (error instanceof AuthenticationError) { - console.error('Invalid API key'); - } else if (error instanceof RateLimitError) { - console.error('Rate limited, retry later'); - } else if (error instanceof SubconsciousError) { - console.error(`API error: ${error.code} - ${error.message}`); - } -} -``` - -### Cancellation -```typescript -// Cancel via AbortController -const controller = new AbortController(); -const stream = client.stream(params, { signal: controller.signal }); -setTimeout(() => controller.abort(), 30000); - -// Or cancel a running run -await client.cancel(run.runId); +run.result?.answer.summary; // string, typed ``` -## API Reference - -### `Subconscious` - -The main client class. +You can still pass a hand-built JSON Schema if you'd rather not depend +on Zod. `zodToJsonSchema()` is exported but seldom needed. -#### Constructor Options +## Cancelling a run -| Option | Type | Required | Default | -| --------- | -------- | -------- | --------------------------------- | -| `apiKey` | `string` | Yes | - | -| `baseUrl` | `string` | No | `https://api.subconscious.dev/v1` | +`client.cancel(runId)` is **idempotent**. You can call it whether the run +is running, queued, or already terminal — it returns the run's current +shape with a 200. Errors are only thrown for network / auth failures, so +you don't need a `.catch(() => undefined)` wrap for the common case. -#### Methods +```ts +const { runId } = await client.run({ engine, input }); +// safe to call regardless of state: +await client.cancel(runId); +await client.cancel(runId); // also safe +``` -| Method | Description | -| -------------------------- | ------------------------ | -| `run(params)` | Create a new run | -| `stream(params, options?)` | Stream text deltas | -| `get(runId)` | Get run status | -| `wait(runId, options?)` | Poll until completion | -| `cancel(runId)` | Cancel a running run | +## Error codes (R5) + +Every `error` stream event and every thrown `SubconsciousError` carries a +canonical `code` from the `ErrorCode` enum: + +```ts +type ErrorCode = + | 'invalid_request' + | 'authentication_failed' + | 'permission_denied' + | 'not_found' + | 'rate_limited' + | 'internal_error' + | 'service_unavailable' + | 'timeout' + | 'canceled'; +``` -### `zodToJsonSchema(schema, title)` +Pattern-match on `code`, never on `message.includes(...)`. -Convert a Zod schema to the JSON Schema format expected by `answerFormat` and `reasoningFormat`. +## Engines -| Param | Type | Description | -| -------- | ------------ | ---------------------------------- | -| `schema` | Zod object | A Zod object schema (`z.object()`) | -| `title` | `string` | Title for the schema | +The SDK accepts any engine name as a string; canonical live names are: -Returns an `OutputSchema` compatible with `answerFormat` and `reasoningFormat`. +- `tim`, `tim-edge` +- `tim-claude`, `tim-claude-heavy` +- `tim-omni`, `tim-omni-mini` -### Engines +Legacy names (`tim-large`, `tim-gpt`, `tim-small`, `timini`, …) are still +accepted and resolved to a live engine server-side. -| Engine | Type | Availability | Description | -| ------------------- | -------- | ------------ | ----------------------------------------------------------------- | -| `tim-small-preview` | Unified | Available | Fast and tuned for search tasks | -| `tim-large` | Compound | Available | Generalized reasoning engine backed by the power of OpenAI | -| `timini` | Compound | Coming soon | Generalized reasoning engine backed by the power of Google Gemini | +## Back-compat & deprecations -### Run Status +The SDK keeps a thin compatibility shim for callers from before the +run/runAndWait split and the wire-format normalization. Existing code +keeps working without changes; new code should reach for the new +spellings: -| Status | Description | -| ----------- | ---------------------- | -| `queued` | Waiting to start | -| `running` | Currently executing | -| `succeeded` | Completed successfully | -| `failed` | Encountered an error | -| `canceled` | Manually canceled | -| `timed_out` | Exceeded time limit | +### `options.awaitCompletion` — deprecated -## Requirements +The single-method `client.run({ ..., options: { awaitCompletion: true } })` +shape from older releases is still accepted. It transparently routes +through `client.runAndWait()` and emits a one-shot `console.warn` so the +deprecation is visible in dev. Migrate by calling `runAndWait()` directly: -- Node.js ≥ 18 -- ESM only +```ts +// Before (still works, prints a deprecation warning once): +const run = await client.run({ + engine: 'tim-claude', + input, + options: { awaitCompletion: true }, +}); -## Contributing +// After: +const run = await client.runAndWait({ engine: 'tim-claude', input }); +``` -Contributions are welcome! Please feel free to submit a pull request. +`RunOptions` will be removed in a future minor release. -## License +### Wire-format `runId` -Apache-2.0 +The canonical SSE event payload key is `runId` (camelCase, matching REST +responses). The SDK also accepts the legacy snake_case `run_id` shape so +callers running against older API builds keep working — but emitters +inside this codebase should always write `runId`. -## Support +### Error code spelling: `canceled` (one `l`) -For support and questions: -- Documentation: https://docs.subconscious.dev -- Email: {hongyin,jack}@subconscious.dev +`ErrorCode` and `RunStatus` both use `canceled` (one `l`). The earlier +double-`l` `cancelled` form was removed. ## License -Apache-2.0 +Apache-2.0. See `LICENSE`. diff --git a/package.json b/package.json index 965bcdb..54cba77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subconscious", - "version": "0.1.9", + "version": "0.1.10", "description": "Official Node.js SDK for the Subconscious API", "type": "module", "exports": { diff --git a/src/__tests__/builders.test.ts b/src/__tests__/builders.test.ts new file mode 100644 index 0000000..2abc7b9 --- /dev/null +++ b/src/__tests__/builders.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { tools } from '../builders.js'; + +describe('tools.platform (R11)', () => { + it('builds a minimal platform tool', () => { + expect(tools.platform('parallel_search')).toEqual({ + type: 'platform', + id: 'parallel_search', + }); + }); + + it('passes options when provided', () => { + expect(tools.platform('parallel_search', { region: 'us' })).toEqual({ + type: 'platform', + id: 'parallel_search', + options: { region: 'us' }, + }); + }); +}); + +describe('tools.function (R11, R12, R13)', () => { + it('accepts a Zod schema for parameters and converts to JSON Schema', () => { + const tool = tools.function({ + name: 'sendEmail', + url: 'https://api.example.com/email', + parameters: z.object({ + to: z.string(), + body: z.string(), + }), + }); + expect(tool.type).toBe('function'); + expect(tool.function.name).toBe('sendEmail'); + expect(tool.function.url).toBe('https://api.example.com/email'); + const params = tool.function.parameters as { + type: string; + properties: Record; + }; + expect(params.type).toBe('object'); + expect(Object.keys(params.properties).sort()).toEqual(['body', 'to']); + }); + + it('accepts a raw JSON Schema verbatim', () => { + const tool = tools.function({ + name: 'lookup', + url: 'https://api.example.com/lookup', + parameters: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + }); + expect((tool.function.parameters as any).properties.id).toEqual({ type: 'string' }); + }); + + it('preserves headers and defaults so normalize-tools can promote them', () => { + const tool = tools.function({ + name: 'sendEmail', + url: 'https://api.example.com/email', + parameters: z.object({ body: z.string() }), + headers: { Authorization: 'Bearer xyz' }, + defaults: { sender_id: 'svc_abc' }, + }); + expect(tool.function.headers).toEqual({ Authorization: 'Bearer xyz' }); + expect(tool.function.defaults).toEqual({ sender_id: 'svc_abc' }); + }); +}); + +describe('tools.mcp (R7)', () => { + it('passes headers through', () => { + const tool = tools.mcp({ + url: 'https://mcp.example.com', + headers: { Authorization: 'Bearer xyz' }, + }); + expect(tool).toEqual({ + type: 'mcp', + url: 'https://mcp.example.com', + headers: { Authorization: 'Bearer xyz' }, + }); + }); + + it('supports the structured auth shape', () => { + expect( + tools.mcp({ + url: 'https://mcp.example.com', + auth: { type: 'bearer', token: 'xyz' }, + }), + ).toEqual({ + type: 'mcp', + url: 'https://mcp.example.com', + auth: { type: 'bearer', token: 'xyz' }, + }); + }); +}); + +describe('tools.resource (R17)', () => { + it.each(['sandbox', 'memory', 'browser'] as const)('builds %s tool', (id) => { + expect(tools.resource(id)).toEqual({ type: 'resource', id }); + }); +}); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts new file mode 100644 index 0000000..28711a9 --- /dev/null +++ b/src/__tests__/client.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { tools } from '../builders.js'; +import { Subconscious } from '../client.js'; + +function mockFetchJSON(handlers: Record unknown>): typeof fetch { + return vi.fn(async (url, init = {}) => { + const u = String(url); + for (const [pattern, handler] of Object.entries(handlers)) { + if (u.endsWith(pattern)) { + const body = handler(init); + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + } + throw new Error(`unhandled fetch: ${u}`); + }) as unknown as typeof fetch; +} + +describe('client.run vs client.runAndWait (R18)', () => { + it('run returns immediately with just a runId', async () => { + const fetchMock = mockFetchJSON({ + '/runs': () => ({ runId: 'run_abc' }), + }); + vi.stubGlobal('fetch', fetchMock); + + const client = new Subconscious({ apiKey: 'k' }); + const run = await client.run({ + engine: 'tim-claude', + input: { instructions: 'hi' }, + }); + vi.unstubAllGlobals(); + + expect(run).toEqual({ runId: 'run_abc' }); + }); + + it('runAndWait polls until terminal and returns the final run', async () => { + let pollCount = 0; + const fetchMock = mockFetchJSON({ + '/runs': () => ({ runId: 'run_xyz' }), + '/runs/run_xyz': () => { + pollCount++; + if (pollCount < 2) return { runId: 'run_xyz', status: 'running' }; + return { + runId: 'run_xyz', + status: 'succeeded', + result: { answer: 'done', reasoning: null }, + }; + }, + }); + vi.stubGlobal('fetch', fetchMock); + + const client = new Subconscious({ apiKey: 'k' }); + const run = await client.runAndWait( + { engine: 'tim-claude', input: { instructions: 'hi' } }, + { intervalMs: 1 }, + ); + vi.unstubAllGlobals(); + + expect(run.status).toBe('succeeded'); + expect(run.result?.answer).toBe('done'); + expect(pollCount).toBeGreaterThanOrEqual(2); + }); + + it('runAndWait typechecks structured answers (compile-time only)', async () => { + type Out = { summary: string; score: number }; + const fetchMock = mockFetchJSON({ + '/runs': () => ({ runId: 'r' }), + '/runs/r': () => ({ + runId: 'r', + status: 'succeeded', + result: { answer: { summary: 'ok', score: 7 }, reasoning: null }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const client = new Subconscious({ apiKey: 'k' }); + const run = await client.runAndWait( + { engine: 'tim-claude', input: { instructions: 'rate this' } }, + { intervalMs: 1 }, + ); + vi.unstubAllGlobals(); + + expect(run.result?.answer.summary).toBe('ok'); + expect(run.result?.answer.score).toBe(7); + }); +}); + +describe('client constructor — defaults (R9)', () => { + it('merges defaultFunctionToolHeaders into the create-run body', async () => { + let captured: any; + const fetchMock = mockFetchJSON({ + '/runs': (init) => { + captured = JSON.parse(String(init.body)); + return { runId: 'r' }; + }, + }); + vi.stubGlobal('fetch', fetchMock); + + const client = new Subconscious({ + apiKey: 'k', + defaultFunctionToolHeaders: { Authorization: 'Bearer xyz' }, + }); + await client.run({ + engine: 'tim-claude', + input: { + instructions: 'hi', + tools: [ + tools.function({ + name: 'send', + url: 'https://api.example.com', + parameters: { type: 'object', properties: {}, required: [] }, + }), + ], + }, + }); + vi.unstubAllGlobals(); + + expect(captured.input.tools[0].function.headers).toEqual({ + Authorization: 'Bearer xyz', + }); + }); +}); + +describe('client.run — back-compat: options.awaitCompletion (deprecated)', () => { + it('routes through runAndWait and emits a one-shot deprecation warning', async () => { + let pollCount = 0; + const fetchMock = mockFetchJSON({ + '/runs': () => ({ runId: 'run_legacy' }), + '/runs/run_legacy': () => { + pollCount++; + return { + runId: 'run_legacy', + status: 'succeeded', + result: { answer: 'ok', reasoning: null }, + }; + }, + }); + vi.stubGlobal('fetch', fetchMock); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const client = new Subconscious({ apiKey: 'k' }); + // The mock returns `succeeded` on the first poll so we never hit the + // 1000ms sleep — no need to override pollOptions in the test. + const run = await client.run({ + engine: 'tim-claude', + input: { instructions: 'hi' }, + options: { awaitCompletion: true }, + }); + vi.unstubAllGlobals(); + + expect(run.status).toBe('succeeded'); + expect(run.result?.answer).toBe('ok'); + expect(pollCount).toBeGreaterThanOrEqual(1); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toMatch(/options\.awaitCompletion/); + warn.mockRestore(); + }); + + it('without options.awaitCompletion still fire-and-forgets', async () => { + let polled = false; + const fetchMock = mockFetchJSON({ + '/runs': () => ({ runId: 'r' }), + '/runs/r': () => { + polled = true; + return { runId: 'r', status: 'succeeded', result: { answer: '', reasoning: null } }; + }, + }); + vi.stubGlobal('fetch', fetchMock); + + const client = new Subconscious({ apiKey: 'k' }); + const run = await client.run({ + engine: 'tim-claude', + input: { instructions: 'hi' }, + options: {}, + }); + vi.unstubAllGlobals(); + + expect(run).toEqual({ runId: 'r' }); + expect(polled).toBe(false); + }); +}); + +describe('client.run — R13 accepts Zod directly for answerFormat', () => { + it('coerces a Zod schema before sending', async () => { + let captured: any; + const fetchMock = mockFetchJSON({ + '/runs': (init) => { + captured = JSON.parse(String(init.body)); + return { runId: 'r' }; + }, + }); + vi.stubGlobal('fetch', fetchMock); + + const client = new Subconscious({ apiKey: 'k' }); + await client.run({ + engine: 'tim-claude', + input: { + instructions: 'rate', + answerFormat: z.object({ summary: z.string(), score: z.number() }), + }, + }); + vi.unstubAllGlobals(); + + const af = captured.input.answerFormat; + expect(af.type).toBe('object'); + expect(Object.keys(af.properties).sort()).toEqual(['score', 'summary']); + expect(af.title).toBeTypeOf('string'); + }); +}); diff --git a/src/__tests__/normalize-tools.test.ts b/src/__tests__/normalize-tools.test.ts new file mode 100644 index 0000000..1b7548e --- /dev/null +++ b/src/__tests__/normalize-tools.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { tools } from '../builders.js'; +import { normalizeTools } from '../internal/normalize-tools.js'; + +describe('normalizeTools — R12 auto-promote defaults to properties', () => { + it('adds defaults-only keys to parameters.properties so the engine can dispatch them', () => { + const input = [ + tools.function({ + name: 'sendEmail', + url: 'https://api.example.com/email', + parameters: { + type: 'object', + properties: { to: { type: 'string' } }, + required: ['to'], + }, + defaults: { sender_id: 'svc_abc' }, + }), + ]; + const out = normalizeTools(input, {})!; + const params = (out[0] as any).function.parameters; + expect(params.properties.sender_id).toEqual({ type: 'string' }); + expect(params.properties.to).toEqual({ type: 'string' }); + }); + + it('does not overwrite an explicit property if the user already declared it', () => { + const input = [ + tools.function({ + name: 'sendEmail', + url: 'https://api.example.com/email', + parameters: { + type: 'object', + properties: { + sender_id: { type: 'string', description: 'explicit' }, + }, + required: [], + }, + defaults: { sender_id: 'svc_abc' }, + }), + ]; + const out = normalizeTools(input, {})!; + expect((out[0] as any).function.parameters.properties.sender_id).toEqual({ + type: 'string', + description: 'explicit', + }); + }); + + it('infers reasonable shapes for non-string defaults', () => { + const input = [ + tools.function({ + name: 'createTicket', + url: 'https://api.example.com/tickets', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + defaults: { priority: 3, urgent: true, tags: ['ops'], extra: { foo: 1 } }, + }), + ]; + const out = normalizeTools(input, {})!; + const props = (out[0] as any).function.parameters.properties; + expect(props.priority).toEqual({ type: 'number' }); + expect(props.urgent).toEqual({ type: 'boolean' }); + expect(props.tags).toEqual({ type: 'array', items: { type: 'string' } }); + expect(props.extra).toEqual({ type: 'object' }); + }); +}); + +describe('normalizeTools — R9 client-level overlays', () => { + it('merges defaultFunctionToolHeaders into every function tool', () => { + const input = [ + tools.function({ + name: 'sendEmail', + url: 'https://api.example.com/email', + parameters: { type: 'object', properties: {}, required: [] }, + }), + ]; + const out = normalizeTools(input, { + defaultFunctionToolHeaders: { 'X-Tenant': 'acme' }, + })!; + expect((out[0] as any).function.headers).toEqual({ 'X-Tenant': 'acme' }); + }); + + it('per-tool headers win on key conflict', () => { + const input = [ + tools.function({ + name: 'sendEmail', + url: 'https://api.example.com/email', + parameters: { type: 'object', properties: {}, required: [] }, + headers: { 'X-Tenant': 'beta' }, + }), + ]; + const out = normalizeTools(input, { + defaultFunctionToolHeaders: { 'X-Tenant': 'acme', 'X-Trace': 't1' }, + })!; + expect((out[0] as any).function.headers).toEqual({ + 'X-Tenant': 'beta', + 'X-Trace': 't1', + }); + }); + + it('merges defaultFunctionToolDefaults and promotes the lifted keys to properties', () => { + const input = [ + tools.function({ + name: 'sendEmail', + url: 'https://api.example.com/email', + parameters: { type: 'object', properties: {}, required: [] }, + }), + ]; + const out = normalizeTools(input, { + defaultFunctionToolDefaults: { tenant_id: 't_xyz' }, + })!; + expect((out[0] as any).function.defaults).toEqual({ tenant_id: 't_xyz' }); + expect((out[0] as any).function.parameters.properties.tenant_id).toEqual({ + type: 'string', + }); + }); + + it('leaves non-function tools untouched', () => { + const input = [tools.platform('parallel_search'), tools.resource('sandbox')]; + expect( + normalizeTools(input, { defaultFunctionToolHeaders: { 'X-Tenant': 'acme' } }), + ).toEqual(input); + }); +}); diff --git a/src/__tests__/stream.test.ts b/src/__tests__/stream.test.ts new file mode 100644 index 0000000..7afa6d4 --- /dev/null +++ b/src/__tests__/stream.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Subconscious } from '../client.js'; +import type { StreamEvent } from '../types/events.js'; +import type { Run } from '../types/run.js'; + +/** + * Build a fake `fetch` returning a chunked SSE stream of `frames`. Each + * frame is a string already terminated with `\n\n`. + */ +function mockFetchSSE(frames: string[], headerRunId?: string): typeof fetch { + return vi.fn(async (_url, _init) => { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async start(controller) { + for (const f of frames) { + controller.enqueue(encoder.encode(f)); + } + controller.close(); + }, + }); + const headers = new Headers({ 'content-type': 'text/event-stream' }); + if (headerRunId) headers.set('x-run-id', headerRunId); + return new Response(body, { status: 200, headers }); + }) as unknown as typeof fetch; +} + +async function collect(stream: AsyncGenerator): Promise { + const out: T[] = []; + for await (const ev of stream) out.push(ev); + return out; +} + +async function collectWithReturn(stream: AsyncGenerator): Promise<{ + events: T[]; + returned: R; +}> { + const events: T[] = []; + while (true) { + const next = await stream.next(); + if (next.done) return { events, returned: next.value }; + events.push(next.value); + } +} + +describe('client.stream — Stream Events v2 (R8, R15)', () => { + it('emits StartedEvent first using the x-run-id header before any server frame', async () => { + // Canonical wire shape uses camelCase `runId` (matches REST responses). + const fetchMock = mockFetchSSE( + [ + 'event: meta\ndata: {"runId":"run_abc"}\n\n', + 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', + 'event: result\ndata: {"result":{"answer":"hi","reasoning":null}}\n\n', + 'data: [DONE]\n\n', + ], + 'run_abc', + ); + vi.stubGlobal('fetch', fetchMock); + + const client = new Subconscious({ apiKey: 'k' }); + const events = (await collect( + client.stream({ + engine: 'tim-claude', + input: { instructions: 'hi' }, + }), + )) as StreamEvent[]; + + vi.unstubAllGlobals(); + + expect(events[0]).toEqual({ type: 'started', runId: 'run_abc' }); + expect(events.at(-1)).toEqual({ type: 'done', runId: 'run_abc' }); + const types = events.map((e) => e.type); + expect(types).toContain('delta'); + expect(types).toContain('result'); + }); + + it('parses event: result with usage', async () => { + const fetchMock = mockFetchSSE( + [ + 'event: started\ndata: {"run_id":"r1"}\n\n', + 'event: result\ndata: {"result":{"answer":"42","reasoning":null},"usage":{"inputTokens":1,"outputTokens":2}}\n\n', + 'data: [DONE]\n\n', + ], + 'r1', + ); + vi.stubGlobal('fetch', fetchMock); + + const client = new Subconscious({ apiKey: 'k' }); + const events = (await collect( + client.stream({ + engine: 'tim-claude', + input: { instructions: 'hi' }, + }), + )) as StreamEvent[]; + vi.unstubAllGlobals(); + + const result = events.find((e) => e.type === 'result'); + expect(result).toBeDefined(); + if (result?.type !== 'result') throw new Error('unreachable'); + expect(result.result.answer).toBe('42'); + expect(result.usage).toEqual({ inputTokens: 1, outputTokens: 2 }); + }); + + it('parses event: tool_call into ToolCallEvent', async () => { + const fetchMock = mockFetchSSE( + [ + 'event: started\ndata: {"run_id":"r1"}\n\n', + 'event: tool_call\ndata: {"call":{"tool_name":"web_search","parameters":{"q":"x"},"tool_result":{"docs":[]}}}\n\n', + 'event: result\ndata: {"result":{"answer":"done","reasoning":null}}\n\n', + 'data: [DONE]\n\n', + ], + 'r1', + ); + vi.stubGlobal('fetch', fetchMock); + + const client = new Subconscious({ apiKey: 'k' }); + const events = (await collect( + client.stream({ engine: 'tim-claude', input: { instructions: 'hi' } }), + )) as StreamEvent[]; + vi.unstubAllGlobals(); + + const toolCall = events.find((e) => e.type === 'tool_call'); + if (toolCall?.type !== 'tool_call') throw new Error('expected tool_call'); + expect(toolCall.call.tool_name).toBe('web_search'); + }); + + it('back-compat: legacy run_id (snake_case) on the wire still parses', async () => { + // Older API builds emitted snake_case `run_id`. SDKs MUST keep + // accepting the legacy shape for at least one minor release. + const fetchMock = mockFetchSSE( + [ + 'event: started\ndata: {"run_id":"r_legacy"}\n\n', + 'event: result\ndata: {"result":{"answer":"ok","reasoning":null}}\n\n', + 'data: [DONE]\n\n', + ], + 'r_legacy', + ); + vi.stubGlobal('fetch', fetchMock); + const client = new Subconscious({ apiKey: 'k' }); + const events = (await collect( + client.stream({ engine: 'tim-claude', input: { instructions: 'hi' } }), + )) as StreamEvent[]; + vi.unstubAllGlobals(); + + expect(events[0]).toEqual({ type: 'started', runId: 'r_legacy' }); + }); + + it('parses event: error with code "canceled" (one l)', async () => { + const fetchMock = mockFetchSSE( + [ + 'event: started\ndata: {"runId":"r1"}\n\n', + 'event: error\ndata: {"code":"canceled","message":"The run was canceled"}\n\n', + 'data: [DONE]\n\n', + ], + 'r1', + ); + vi.stubGlobal('fetch', fetchMock); + const client = new Subconscious({ apiKey: 'k' }); + const events = (await collect( + client.stream({ engine: 'tim-claude', input: { instructions: 'hi' } }), + )) as StreamEvent[]; + vi.unstubAllGlobals(); + + const err = events.find((e) => e.type === 'error'); + if (err?.type !== 'error') throw new Error('expected error'); + expect(err.code).toBe('canceled'); + }); + + it('parses event: error with required code (R5)', async () => { + const fetchMock = mockFetchSSE( + [ + 'event: started\ndata: {"run_id":"r1"}\n\n', + 'event: error\ndata: {"code":"rate_limited","message":"slow down","details":{"retryAfterMs":1000}}\n\n', + 'data: [DONE]\n\n', + ], + 'r1', + ); + vi.stubGlobal('fetch', fetchMock); + + const client = new Subconscious({ apiKey: 'k' }); + const events = (await collect( + client.stream({ engine: 'tim-claude', input: { instructions: 'hi' } }), + )) as StreamEvent[]; + vi.unstubAllGlobals(); + + const err = events.find((e) => e.type === 'error'); + if (err?.type !== 'error') throw new Error('expected error'); + expect(err.code).toBe('rate_limited'); + expect(err.message).toBe('slow down'); + expect(err.details).toEqual({ retryAfterMs: 1000 }); + }); + + it('returns a failed Run when the stream terminates after an error event', async () => { + const fetchMock = mockFetchSSE( + [ + 'event: started\ndata: {"runId":"r_failed"}\n\n', + 'event: error\ndata: {"code":"rate_limited","message":"slow down"}\n\n', + 'data: [DONE]\n\n', + ], + 'r_failed', + ); + vi.stubGlobal('fetch', fetchMock); + + const client = new Subconscious({ apiKey: 'k' }); + const { returned } = await collectWithReturn( + client.stream({ engine: 'tim-claude', input: { instructions: 'hi' } }), + ); + vi.unstubAllGlobals(); + + expect(returned).toMatchObject({ runId: 'r_failed', status: 'failed' } satisfies Partial); + }); + + it('returns a succeeded Run with result and usage after a result event', async () => { + const fetchMock = mockFetchSSE( + [ + 'event: started\ndata: {"runId":"r_ok"}\n\n', + 'event: result\ndata: {"result":{"answer":"42","reasoning":null},"usage":{"inputTokens":1,"outputTokens":2}}\n\n', + 'data: [DONE]\n\n', + ], + 'r_ok', + ); + vi.stubGlobal('fetch', fetchMock); + + const client = new Subconscious({ apiKey: 'k' }); + const { returned } = await collectWithReturn( + client.stream({ engine: 'tim-claude', input: { instructions: 'hi' } }), + ); + vi.unstubAllGlobals(); + + expect(returned).toEqual({ + runId: 'r_ok', + status: 'succeeded', + result: { answer: '42', reasoning: null }, + usage: { inputTokens: 1, outputTokens: 2 }, + }); + }); +}); + +describe('client.observe (R16)', () => { + it('reads from /v1/runs/:runId/stream and parses events', async () => { + const fetchMock = mockFetchSSE([ + 'event: started\ndata: {"run_id":"r_obs"}\n\n', + 'data: {"choices":[{"delta":{"content":"replay"}}]}\n\n', + 'event: result\ndata: {"result":{"answer":"replay","reasoning":null}}\n\n', + 'data: [DONE]\n\n', + ]); + vi.stubGlobal('fetch', fetchMock); + + const client = new Subconscious({ apiKey: 'k' }); + const events = (await collect(client.observe('r_obs'))) as StreamEvent[]; + vi.unstubAllGlobals(); + + expect(events[0]).toEqual({ type: 'started', runId: 'r_obs' }); + expect(events.some((e) => e.type === 'delta' && e.content === 'replay')).toBe(true); + }); +}); diff --git a/src/builders.ts b/src/builders.ts new file mode 100644 index 0000000..8366b1d --- /dev/null +++ b/src/builders.ts @@ -0,0 +1,98 @@ +import { coerceAnswerFormat } from './types/schema.js'; +import type { + FunctionTool, + MCPAuth, + MCPTool, + PlatformTool, + ResourceTool, +} from './types/tool.js'; + +/** + * Tool builders (R11). Tiny helpers that turn the discriminated-union + * verbosity of `Tool` into a one-call API while preserving full type + * safety. Use these in preference to building tool literals by hand. + * + * @example + * ```ts + * import { tools } from 'subconscious'; + * + * const input = { + * instructions: 'Search the web for X and write to my sandbox', + * tools: [ + * tools.platform('parallel_search'), + * tools.resource('sandbox'), + * tools.function({ + * name: 'sendEmail', + * description: 'Send an email', + * url: 'https://api.example.com/email', + * parameters: EmailSchema, // Zod or JSON Schema + * defaults: { sender_id: 'svc_abc' }, // hidden from model (R12) + * headers: { Authorization: 'Bearer …' }, + * }), + * tools.mcp({ + * url: 'https://mcp.example.com', + * headers: { Authorization: 'Bearer …' }, // R7 + * }), + * ], + * }; + * ``` + */ +export const tools = { + platform(id: string, options?: Record): PlatformTool { + return options ? { type: 'platform', id, options } : { type: 'platform', id }; + }, + + /** + * Function tool. `parameters` accepts a Zod schema OR a raw JSON Schema + * object. (R13.) Defaults declared here are merged into the dispatched + * body server-side; consumers can omit them from `parameters` and they + * will be auto-promoted at SDK normalization time. (R12.) + */ + function(args: { + name: string; + description?: string; + url: string; + parameters: unknown; + headers?: Record; + defaults?: Record; + }): FunctionTool { + const schema = coerceAnswerFormat(args.parameters, args.name); + return { + type: 'function', + function: { + name: args.name, + ...(args.description ? { description: args.description } : {}), + url: args.url, + parameters: schema, + ...(args.headers ? { headers: args.headers } : {}), + ...(args.defaults ? { defaults: args.defaults } : {}), + }, + }; + }, + + /** + * MCP (Model Context Protocol) tool. Use `headers` for header-based + * auth (R7) or `auth` for the structured Bearer / API-key shape. + */ + mcp(args: { + url: string; + allowedTools?: string[]; + headers?: Record; + auth?: MCPAuth; + }): MCPTool { + return { + type: 'mcp', + url: args.url, + ...(args.allowedTools ? { allowedTools: args.allowedTools } : {}), + ...(args.headers ? { headers: args.headers } : {}), + ...(args.auth ? { auth: args.auth } : {}), + }; + }, + + /** + * Hosted runtime resource — sandbox, memory, or browser. (R17.) + */ + resource(id: ResourceTool['id']): ResourceTool { + return { type: 'resource', id }; + }, +}; diff --git a/src/client.ts b/src/client.ts index d0d4741..c07124b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,37 +1,106 @@ import { request } from './internal/http.js'; +import { normalizeTools } from './internal/normalize-tools.js'; import { pollUntilComplete, type PollOptions } from './internal/poll.js'; -import { createStream, type StreamOptions, type RunStream } from './stream.js'; -import type { Run, Engine, RunInput, RunOptions, RunParams } from './types/run.js'; +import { + createObserveStream, + createStream, + type RunStream, + type StreamOptions, +} from './stream.js'; +import { coerceAnswerFormat } from './types/schema.js'; +import type { OutputSchema } from './types/schema.js'; +import type { Engine, Run, RunInput, RunOptions, RunParams } from './types/run.js'; export type SubconsciousOptions = { apiKey: string; baseUrl?: string; + /** + * Headers merged into every FunctionTool dispatch. Use this for + * cross-cutting auth (e.g. an HMAC shared secret) instead of + * duplicating `headers` on every function tool. Per-tool headers + * take precedence on conflict. (R9.) + */ + defaultFunctionToolHeaders?: Record; + /** + * Hidden parameter values merged into every FunctionTool's `defaults`. + * Keys collide last-wins toward the per-tool definition. (R9.) + * + * Example: `defaultFunctionToolDefaults: { tenant_id: 't_abc' }` + * lets every dispatched function call carry the tenant id without + * re-declaring it on each tool. + */ + defaultFunctionToolDefaults?: Record; }; +/** + * Generic params for `run` / `runAndWait` / `stream`. The `input` + * accepts either a JSON Schema `OutputSchema` or a Zod schema (R13); + * the client coerces Zod to JSON Schema before dispatch. + */ +export type GenericRunParams = Omit & { + input: Omit & { + /** JSON Schema or Zod schema (R13). */ + answerFormat?: OutputSchema | unknown; + /** JSON Schema or Zod schema (R13). */ + reasoningFormat?: OutputSchema | unknown; + }; + /** + * @deprecated Use `client.runAndWait()` instead of `options.awaitCompletion`. + * Passing `options.awaitCompletion: true` to `client.run()` transparently + * routes through `runAndWait()` and emits a one-shot console warning. + */ + options?: RunOptions; +}; + +let awaitCompletionWarningShown = false; +function warnAwaitCompletionDeprecated() { + if (awaitCompletionWarningShown) return; + awaitCompletionWarningShown = true; + // eslint-disable-next-line no-console + console.warn( + '[subconscious] `options.awaitCompletion` is deprecated. ' + + 'Call `client.runAndWait(...)` instead of `client.run({ ..., options: { awaitCompletion: true } })`. ' + + 'The legacy field will be removed in a future minor release.', + ); +} + /** * The main Subconscious API client. * - * @example + * @example Fire-and-forget (R18) * ```ts - * import { Subconscious } from "subconscious"; - * - * const client = new Subconscious({ apiKey: process.env.SUBCONSCIOUS_API_KEY }); + * const { runId } = await client.run({ + * engine: 'tim-claude', + * input: { instructions: 'Search the latest AI news' }, + * }); + * ``` * - * const run = await client.run({ - * engine: "tim-large", + * @example Wait for completion (R18, R10) + * ```ts + * const run = await client.runAndWait<{ summary: string }>({ + * engine: 'tim-claude', * input: { - * instructions: "Search for the latest news about AI", - * tools: [{ type: "platform", id: "parallel_search", options: {} }], + * instructions: 'Summarize this article…', + * answerFormat: SummarySchema, // pass Zod directly (R13) * }, - * options: { awaitCompletion: true }, * }); + * console.log(run.result?.answer.summary); // typed + * ``` * - * console.log(run.result?.answer); + * @example Streaming + * ```ts + * for await (const event of client.stream({ engine: 'tim-claude', input })) { + * if (event.type === 'started') console.log('runId:', event.runId); + * if (event.type === 'delta') process.stdout.write(event.content); + * if (event.type === 'result') console.log('answer:', event.result.answer); + * } * ``` */ export class Subconscious { private readonly baseUrl: string; private readonly apiKey: string; + private readonly defaultFunctionToolHeaders?: Record; + private readonly defaultFunctionToolDefaults?: Record; constructor(opts: SubconsciousOptions) { if (!opts.apiKey) { @@ -39,94 +108,172 @@ export class Subconscious { } this.baseUrl = opts.baseUrl ?? 'https://api.subconscious.dev/v1'; this.apiKey = opts.apiKey; + this.defaultFunctionToolHeaders = opts.defaultFunctionToolHeaders; + this.defaultFunctionToolDefaults = opts.defaultFunctionToolDefaults; } /** - * Create a new run. + * Create a run and return its `runId` immediately. Fire-and-forget. * - * @param params.engine - The engine to use for the run - * @param params.input - The input configuration including instructions and tools - * @param params.options.awaitCompletion - If true, poll until the run completes - * @returns The created run, optionally with results if awaitCompletion is true + * Use `client.runAndWait()` if you want to block until the run reaches + * a terminal state. (R18.) + * + * Back-compat: if `params.options?.awaitCompletion === true`, this method + * transparently routes through `runAndWait()` and emits a one-shot + * deprecation warning. New code should call `runAndWait()` directly. + * + * @returns The created run, with only `runId` populated (or a fully + * resolved run when the deprecated `awaitCompletion` is true). + */ + async run(params: GenericRunParams): Promise> { + if (params.options?.awaitCompletion) { + warnAwaitCompletionDeprecated(); + return this.runAndWait(params); + } + return this.createRunOnly(params); + } + + /** + * Create a run and poll until it reaches a terminal state. + * + * @example + * ```ts + * const run = await client.runAndWait<{ summary: string }>({...}); + * console.log(run.result?.answer.summary); // typed + * ``` */ - async run(params: RunParams): Promise { + async runAndWait( + params: GenericRunParams, + pollOptions?: PollOptions, + ): Promise> { + // Use `createRunOnly` (the bare POST) instead of `run()` to avoid + // ping-ponging on the deprecated `options.awaitCompletion` back-compat + // path: `run({ options: { awaitCompletion: true } })` calls + // `runAndWait`, which would then call `run` again and recurse forever. + const { runId } = await this.createRunOnly(params); + return this.wait(runId, pollOptions); + } + + /** + * The bare "POST /runs and return the runId" path. Internal — public + * callers should reach for `run()` (fire-and-forget) or `runAndWait()` + * (polling). + */ + private async createRunOnly(params: GenericRunParams): Promise> { + const body = this.buildCreateBody(params); const { runId } = await request<{ runId: string }>(`${this.baseUrl}/runs`, { method: 'POST', headers: this.authHeaders(), - body: JSON.stringify({ - engine: params.engine, - input: params.input, - }), + body: JSON.stringify(body), }); - - if (!params.options?.awaitCompletion) { - return { runId }; - } - - return this.wait(runId); + return { runId } as Run; } /** - * Create a streaming run that yields text deltas as they arrive. + * Create a streaming run that yields typed events as they arrive. * - * @param params.engine - The engine to use for the run - * @param params.input - The input configuration including instructions and tools - * @param options.signal - AbortSignal to cancel the stream - * @returns An async generator yielding delta, done, or error events + * Stream Events v2 (R8, R15) — yields `started`, `delta`, + * `reasoning_node`, `tool_call`, `result`, `error`, `done`. + * The first event is always `started` and carries the runId + * synchronously; the last event is always `done`. * * @example * ```ts - * const stream = client.stream({ - * engine: "tim-large", - * input: { instructions: "...", tools: [] }, - * }); - * - * for await (const event of stream) { - * if (event.type === 'delta') { - * process.stdout.write(event.content); + * for await (const event of client.stream({ engine, input })) { + * switch (event.type) { + * case 'started': registerCancel(event.runId); break; + * case 'delta': write(event.content); break; + * case 'tool_call': console.log(event.call); break; + * case 'result': console.log(event.result); break; + * case 'error': handle(event.code, event.message); break; * } * } * ``` */ - stream(params: { engine: Engine; input: RunInput }, options?: StreamOptions): RunStream { - return createStream(this.baseUrl, this.apiKey, params, options); + stream(params: GenericRunParams, options?: StreamOptions): RunStream { + const body = this.buildCreateBody(params); + return createStream(this.baseUrl, this.apiKey, body as { engine: Engine; input: RunInput }, options); } /** - * Get the current state of a run. + * Re-attach to an in-flight (or finished) run and stream its events + * from the durable buffer. Same wire format as `stream()`. Useful when + * a parent process restarts and needs to resume an existing run. (R16.) * - * @param runId - The ID of the run to retrieve + * @example + * ```ts + * const { runId } = await client.run({ engine, input }); + * await persistToDb(runId); + * // … later, possibly in a different process … + * for await (const event of client.observe(runId)) { ... } + * ``` + */ + observe(runId: string, options?: StreamOptions): RunStream { + return createObserveStream(this.baseUrl, this.apiKey, runId, options); + } + + /** + * Get the current state of a run. */ - async get(runId: string): Promise { - return request(`${this.baseUrl}/runs/${runId}`, { + async get(runId: string): Promise> { + return request>(`${this.baseUrl}/runs/${runId}`, { headers: this.authHeaders(), }); } /** * Wait for a run to complete by polling. - * - * @param runId - The ID of the run to wait for - * @param options.intervalMs - Polling interval in milliseconds (default: 1000) - * @param options.maxAttempts - Maximum polling attempts before throwing - * @param options.signal - AbortSignal to cancel polling */ - async wait(runId: string, options?: PollOptions): Promise { - return pollUntilComplete(`${this.baseUrl}/runs/${runId}`, this.authHeaders(), options); + async wait(runId: string, options?: PollOptions): Promise> { + return pollUntilComplete(`${this.baseUrl}/runs/${runId}`, this.authHeaders(), options); } /** - * Cancel a running run. + * Cancel a run. **Idempotent** (R9): callers may invoke this against a + * run in any state (running, queued, already terminal) and receive the + * run's current shape with a 200 response. Already-cancelled or already- + * succeeded runs are returned unchanged with their existing status, so + * you do not need to wrap this in a `try/catch` for the common case. * - * @param runId - The ID of the run to cancel + * Errors are only thrown for network/auth failures. */ - async cancel(runId: string): Promise { - return request(`${this.baseUrl}/runs/${runId}/cancel`, { + async cancel(runId: string): Promise> { + return request>(`${this.baseUrl}/runs/${runId}/cancel`, { method: 'POST', headers: this.authHeaders(), }); } + /** + * Build the POST /v1/runs body from a `GenericRunParams`. Handles the + * R13 Zod-or-JSON-Schema coercion and R9 client-level header / defaults + * injection on FunctionTools, then runs R12 normalize-tools (auto-promote + * `defaults` keys into `parameters.properties`). + */ + private buildCreateBody(params: GenericRunParams): { engine: Engine; input: RunInput } { + const { engine, input } = params; + + const tools = normalizeTools(input.tools, { + defaultFunctionToolHeaders: this.defaultFunctionToolHeaders, + defaultFunctionToolDefaults: this.defaultFunctionToolDefaults, + }); + + const { answerFormat, reasoningFormat, ...rest } = input; + + const normalizedInput: RunInput = { + ...rest, + ...(tools !== undefined ? { tools } : {}), + ...(answerFormat !== undefined && { + answerFormat: coerceAnswerFormat(answerFormat, 'Answer'), + }), + ...(reasoningFormat !== undefined && { + reasoningFormat: coerceAnswerFormat(reasoningFormat, 'Reasoning'), + }), + }; + + return { engine, input: normalizedInput }; + } + private authHeaders(): Record { return { Authorization: `Bearer ${this.apiKey}` }; } diff --git a/src/index.ts b/src/index.ts index 57a181a..c09387e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,12 @@ // Main client -export { Subconscious, type SubconsciousOptions } from './client.js'; +export { + Subconscious, + type SubconsciousOptions, + type GenericRunParams, +} from './client.js'; + +// Tool builders (R11) — recommended way to construct tools. +export { tools } from './builders.js'; // Types - Run export type { @@ -7,9 +14,9 @@ export type { RunStatus, RunResult, RunInput, - RunOptions, RunParams, ReasoningNode, + ToolUse, Engine, Usage, ModelUsage, @@ -17,10 +24,17 @@ export type { } from './types/run.js'; // Types - Tools -export type { Tool, PlatformTool, FunctionTool, MCPTool } from './types/tool.js'; +export type { + Tool, + PlatformTool, + FunctionTool, + MCPTool, + MCPAuth, + ResourceTool, +} from './types/tool.js'; // Schema types and utilities -export { zodToJsonSchema } from './types/schema.js'; +export { zodToJsonSchema, coerceAnswerFormat } from './types/schema.js'; export type { OutputSchema, JSONSchemaProperty, @@ -33,8 +47,17 @@ export type { JSONSchemaAnyOf, } from './types/schema.js'; -// Types - Stream Events -export type { StreamEvent, DeltaEvent, DoneEvent, ErrorEvent } from './types/events.js'; +// Types - Stream Events (Stream Events v2) +export type { + StreamEvent, + StartedEvent, + DeltaEvent, + ReasoningNodeEvent, + ToolCallEvent, + ResultEvent, + DoneEvent, + ErrorEvent, +} from './types/events.js'; // Types - Errors export { @@ -49,3 +72,6 @@ export { // Stream types export type { RunStream, StreamOptions } from './stream.js'; + +// Polling type +export type { PollOptions } from './internal/poll.js'; diff --git a/src/internal/normalize-tools.ts b/src/internal/normalize-tools.ts new file mode 100644 index 0000000..5aa4b53 --- /dev/null +++ b/src/internal/normalize-tools.ts @@ -0,0 +1,116 @@ +import type { FunctionTool, Tool } from '../types/tool.js'; + +export type NormalizeOpts = { + defaultFunctionToolHeaders?: Record; + defaultFunctionToolDefaults?: Record; +}; + +/** + * Pre-flight tool normalization (R9, R12). + * + * Two responsibilities: + * + * 1. **Inject client-level FunctionTool overlays.** When the SDK was + * constructed with `defaultFunctionToolHeaders` / `…Defaults`, merge + * those into every FunctionTool. Per-tool values win on conflict so + * consumers can still override. + * + * 2. **Auto-promote `defaults` keys into the JSON Schema** (R12). Defaults + * are hidden values the engine never sees as model-controlled + * parameters, but the schema needs to declare them anyway so the engine + * can dispatch a complete payload. We synthesize a minimal property + * descriptor (`{ type: 'string' }`) for each defaults-only key that is + * missing from `parameters.properties`. Existing properties are left + * untouched. + * + * This eliminates the "I declared `user_id` in defaults but the engine + * sent `{}`" footgun documented in the friction report. + */ +export function normalizeTools( + tools: Tool[] | undefined, + opts: NormalizeOpts, +): Tool[] | undefined { + if (!tools) return tools; + return tools.map((t) => normalizeOne(t, opts)); +} + +function normalizeOne(tool: Tool, opts: NormalizeOpts): Tool { + if (tool.type !== 'function') return tool; + return normalizeFunctionTool(tool, opts); +} + +function normalizeFunctionTool(tool: FunctionTool, opts: NormalizeOpts): FunctionTool { + const fn = tool.function; + + // R9: merge SDK-level overlays. Per-tool values win on conflict. + const mergedHeaders = + opts.defaultFunctionToolHeaders || fn.headers + ? { ...(opts.defaultFunctionToolHeaders ?? {}), ...(fn.headers ?? {}) } + : undefined; + + const mergedDefaults = + opts.defaultFunctionToolDefaults || fn.defaults + ? { ...(opts.defaultFunctionToolDefaults ?? {}), ...(fn.defaults ?? {}) } + : undefined; + + // R12: ensure every defaults-only key is declared in parameters.properties. + const parameters = promoteDefaultsToProperties(fn.parameters, mergedDefaults); + + return { + ...tool, + function: { + ...fn, + parameters, + ...(mergedHeaders ? { headers: mergedHeaders } : {}), + ...(mergedDefaults ? { defaults: mergedDefaults } : {}), + }, + }; +} + +function promoteDefaultsToProperties( + parameters: Record, + defaults: Record | undefined, +): Record { + if (!defaults) return parameters; + + const next: Record = { ...parameters }; + // The expected shape is a JSON Schema object with `properties: {…}`. If + // it isn't, leave it alone (the user supplied a custom schema we don't + // want to mutate). + if (next['type'] !== 'object') return next; + + const properties = isObject(next['properties']) ? { ...next['properties'] } : {}; + let mutated = false; + + for (const key of Object.keys(defaults)) { + if (!(key in properties)) { + properties[key] = inferPropertyShape(defaults[key]); + mutated = true; + } + } + + if (!mutated) return parameters; + + return { ...next, properties }; +} + +function inferPropertyShape(value: unknown): Record { + switch (typeof value) { + case 'string': + return { type: 'string' }; + case 'number': + return { type: 'number' }; + case 'boolean': + return { type: 'boolean' }; + case 'object': + if (value === null) return { type: 'string' }; + if (Array.isArray(value)) return { type: 'array', items: { type: 'string' } }; + return { type: 'object' }; + default: + return { type: 'string' }; + } +} + +function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} diff --git a/src/internal/poll.ts b/src/internal/poll.ts index d39426e..13a1a72 100644 --- a/src/internal/poll.ts +++ b/src/internal/poll.ts @@ -9,11 +9,11 @@ export type PollOptions = { signal?: AbortSignal; }; -export async function pollUntilComplete( +export async function pollUntilComplete( url: string, headers: Record, options: PollOptions = {}, -): Promise { +): Promise> { const { intervalMs = 1000, maxAttempts, signal } = options; let attempts = 0; @@ -23,7 +23,7 @@ export async function pollUntilComplete( throw new Error('Polling aborted'); } - const run = await request(url, { headers, signal }); + const run = await request>(url, { headers, signal }); if (run.status && TERMINAL_STATUSES.includes(run.status)) { return run; diff --git a/src/run.ts b/src/run.ts index 6a03680..108c300 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,11 +1,15 @@ -// Re-export run-related types for direct import +// Re-export run-related types for direct import. export type { Run, RunStatus, RunResult, RunInput, - RunOptions, RunParams, + RunOptions, ReasoningNode, + ToolUse, Engine, + Usage, + ModelUsage, + PlatformToolUsage, } from './types/run.js'; diff --git a/src/stream.ts b/src/stream.ts index 1911a66..1a234f9 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -1,57 +1,57 @@ import { requestStream } from './internal/http.js'; -import type { StreamEvent } from './types/events.js'; -import type { RunInput, Engine, Run } from './types/run.js'; +import type { ErrorCode } from './types/error.js'; +import type { + ErrorEvent, + ReasoningNodeEvent, + ResultEvent, + StartedEvent, + StreamEvent, + ToolCallEvent, +} from './types/events.js'; +import type { Engine, Run, RunInput, RunStatus } from './types/run.js'; export type StreamOptions = { signal?: AbortSignal; }; -export type RunStream = AsyncGenerator; +type ParsedStreamState = Pick, 'runId' | 'status' | 'result' | 'usage'>; + +function statusFromErrorCode(code: ErrorCode): RunStatus { + if (code === 'canceled') return 'canceled'; + if (code === 'timeout') return 'timed_out'; + return 'failed'; +} /** - * Create a streaming run that yields events as they arrive. + * Stream Events v2 (R8, R15): the SDK emits a typed discriminated union. * - * The API uses OpenAI-compatible SSE format: - * - event: meta → { run_id } - * - data: { choices: [{ delta: { content } }] } - * - event: error → { error, details } - * - data: [DONE] + * Yielded order: + * 1. `started` — always first; carries runId synchronously. + * 2. zero or more `delta` / `reasoning_node` / `tool_call` events. + * 3. exactly one `result` (success) or `error` (failure). + * 4. `done` — always last. * - * @internal Used by Subconscious.stream() + * Generic `T` narrows the `result.answer` shape when paired with + * `answerFormat`. Defaults to `unknown` so consumers without structured + * output get the historical `string` answer. */ -export async function* createStream( - baseUrl: string, - apiKey: string, - params: { - engine: Engine; - input: RunInput; - }, - options: StreamOptions = {}, -): RunStream { - const response = await requestStream(`${baseUrl}/runs/stream`, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - engine: params.engine, - input: params.input, - }), - signal: options.signal, - }); - - // Extract run ID from headers if available - let runId = response.headers.get('x-run-id') || ''; - - const reader = response.body?.getReader(); - if (!reader) { - throw new Error('Response body is not readable'); - } +export type RunStream = AsyncGenerator< + StreamEvent, + Run | undefined, + undefined +>; +/** Internal helper — yields parsed StreamEvents from an SSE Response body. */ +async function* parseSSEStream( + body: ReadableStream, + initialRunId: string, +): AsyncGenerator, ParsedStreamState, undefined> { + const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ''; - let isError = false; + let runId = initialRunId; + const state: ParsedStreamState = { runId }; + let pendingEvent: string | null = null; try { while (true) { @@ -63,54 +63,113 @@ export async function* createStream( buffer = lines.pop() ?? ''; for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith(':')) continue; + // Comment / heartbeat — `:keep-alive\n\n` + if (line.startsWith(':')) continue; - // Handle event type markers - if (trimmed.startsWith('event:')) { - const eventType = trimmed.slice(6).trim(); - isError = eventType === 'error'; + if (line === '') { + // Blank line = end of one SSE record. Reset event tag. + pendingEvent = null; continue; } - // Handle data lines - if (trimmed.startsWith('data:')) { - const dataContent = trimmed.slice(5).trim(); + if (line.startsWith('event:')) { + pendingEvent = line.slice(6).trim(); + continue; + } - // Stream end - if (dataContent === '[DONE]') { - yield { type: 'done', runId }; - continue; - } + if (!line.startsWith('data:')) continue; + const dataContent = line.slice(5).trim(); - try { - const payload = JSON.parse(dataContent); + if (dataContent === '[DONE]') { + yield { type: 'done', runId } as StreamEvent; + pendingEvent = null; + continue; + } - // Meta event with run_id - if (payload.run_id) { - runId = payload.run_id; - continue; - } + let payload: any; + try { + payload = JSON.parse(dataContent); + } catch { + // Malformed JSON — drop frame. + continue; + } - // Error event - if (isError || payload.error) { - yield { - type: 'error', - runId, - message: payload.details || payload.error || 'Unknown error', - code: payload.code, - }; - isError = false; - continue; + switch (pendingEvent) { + case 'started': + case 'meta': { + // Both shapes carry the runId. We always emit `started` once. + // Canonical wire key is `runId` (camelCase). The legacy + // `run_id` snake_case form is accepted for one minor release + // of back-compat with older API builds. + const id = payload.runId ?? payload.run_id; + if (typeof id === 'string' && id.length > 0) { + if (runId !== id) { + runId = id; + state.runId = id; + yield { type: 'started', runId } as StartedEvent as StreamEvent; + } else if (pendingEvent === 'started') { + yield { type: 'started', runId } as StartedEvent as StreamEvent; + } } + break; + } + + case 'reasoning_node': { + const node = payload.node ?? payload; + yield { + type: 'reasoning_node', + runId, + node, + } as ReasoningNodeEvent as StreamEvent; + break; + } - // OpenAI-compatible chunk with text delta + case 'tool_call': { + const call = payload.call ?? payload; + yield { + type: 'tool_call', + runId, + call, + } as ToolCallEvent as StreamEvent; + break; + } + + case 'result': { + const result = payload.result ?? payload; + const event = { + type: 'result', + runId, + result, + ...(payload.usage ? { usage: payload.usage } : {}), + } as ResultEvent; + state.status = 'succeeded'; + state.result = event.result; + if (event.usage) state.usage = event.usage; + yield event as StreamEvent; + break; + } + + case 'error': { + const code: ErrorCode = (payload.code as ErrorCode) ?? 'internal_error'; + const message = payload.message ?? payload.details ?? payload.error ?? 'Unknown error'; + state.status = statusFromErrorCode(code); + yield { + type: 'error', + runId, + code, + message, + ...(payload.details ? { details: payload.details } : {}), + } as ErrorEvent as StreamEvent; + break; + } + + default: { + // Untagged data frames are OpenAI-compat delta chunks. const content = payload.choices?.[0]?.delta?.content; if (typeof content === 'string' && content.length > 0) { - yield { type: 'delta', runId, content }; + yield { type: 'delta', runId, content } as StreamEvent; } - } catch { - // Skip malformed JSON + break; } } } @@ -119,5 +178,105 @@ export async function* createStream( reader.releaseLock(); } - return runId ? { runId, status: 'succeeded' } : undefined; + state.runId = runId; + return state; +} + +/** + * Create a streaming run that yields events as they arrive. + * + * Stream Events v2 (R8, R15): yields a typed discriminated union including + * `started`, `reasoning_node`, `tool_call`, and `result` in addition to + * the legacy `delta` / `error` / `done` events. + * + * @internal Used by Subconscious.stream() + */ +export async function* createStream( + baseUrl: string, + apiKey: string, + params: { + engine: Engine; + input: RunInput; + }, + options: StreamOptions = {}, +): RunStream { + const response = await requestStream(`${baseUrl}/runs/stream`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + engine: params.engine, + input: params.input, + }), + signal: options.signal, + }); + + const headerRunId = response.headers.get('x-run-id') ?? ''; + + if (!response.body) { + throw new Error('Response body is not readable'); + } + + // R8: emit `started` synchronously the moment we have a runId, even + // before the first server frame, so consumers can register cancellation + // before any deltas. The parser will not double-emit if the server's + // `started` frame carries the same id. + let synthesizedStarted = false; + if (headerRunId) { + yield { type: 'started', runId: headerRunId } as StartedEvent as StreamEvent; + synthesizedStarted = true; + } + + const finalState = yield* (async function* () { + const inner = parseSSEStream(response.body!, headerRunId); + let firstStartedSkipped = !synthesizedStarted; + while (true) { + const next = await inner.next(); + if (next.done) return next.value; + // Skip the parser's first `started` if we already synthesized one + // for the same id. + if ( + !firstStartedSkipped && + next.value.type === 'started' && + next.value.runId === headerRunId + ) { + firstStartedSkipped = true; + continue; + } + firstStartedSkipped = true; + yield next.value; + } + })(); + + return finalState.runId ? (finalState as Run) : undefined; +} + +/** + * Re-attach to an in-flight (or already finished) run and stream its + * events. Same wire format as `createStream`. (R16.) + * + * @internal Used by Subconscious.observe() + */ +export async function* createObserveStream( + baseUrl: string, + apiKey: string, + runId: string, + options: StreamOptions = {}, +): RunStream { + const response = await requestStream(`${baseUrl}/runs/${runId}/stream`, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + signal: options.signal, + }); + + if (!response.body) { + throw new Error('Response body is not readable'); + } + + const finalState = yield* parseSSEStream(response.body, runId); + return finalState.runId ? (finalState as Run) : undefined; } diff --git a/src/tools.ts b/src/tools.ts index c7a0614..7f00d54 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,2 +1,10 @@ -// Re-export tool types for direct use -export type { PlatformTool, FunctionTool, MCPTool, Tool } from './types/tool.js'; +// Re-export tool types and builders for direct use. +export type { + PlatformTool, + FunctionTool, + MCPTool, + MCPAuth, + ResourceTool, + Tool, +} from './types/tool.js'; +export { tools } from './builders.js'; diff --git a/src/types/error.ts b/src/types/error.ts index ab4031c..6ae0e7c 100644 --- a/src/types/error.ts +++ b/src/types/error.ts @@ -6,7 +6,8 @@ export type ErrorCode = | 'rate_limited' | 'internal_error' | 'service_unavailable' - | 'timeout'; + | 'timeout' + | 'canceled'; export type APIErrorResponse = { error: { diff --git a/src/types/events.ts b/src/types/events.ts index 39f5a2c..9c20501 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -1,3 +1,16 @@ +import type { ErrorCode } from './error.js'; +import type { ReasoningNode, RunResult, ToolUse, Usage } from './run.js'; + +/** + * Stream emitted `started` — always the first event. Carries the runId + * synchronously so consumers can register cancellation handlers before + * any deltas arrive. (R8.) + */ +export type StartedEvent = { + type: 'started'; + runId: string; +}; + /** * Text delta event - emitted as text is generated. */ @@ -8,7 +21,42 @@ export type DeltaEvent = { }; /** - * Stream completed successfully. + * One completed reasoning node from the live tree. Emitted by engines + * that support structured streaming. (R15.) + */ +export type ReasoningNodeEvent = { + type: 'reasoning_node'; + runId: string; + node: ReasoningNode; +}; + +/** + * One completed tool invocation. Emitted by engines that support + * structured streaming. (R15.) + */ +export type ToolCallEvent = { + type: 'tool_call'; + runId: string; + call: ToolUse; +}; + +/** + * Final structured run envelope. Emitted exactly once on success, + * immediately before `done`. Eliminates the JSON.parse-the-accumulated- + * delta-buffer pattern. (R15.) + * + * Generic `T` defaults to `unknown` so callers using `answerFormat` can + * narrow it via the `client.stream(...)` overload. + */ +export type ResultEvent = { + type: 'result'; + runId: string; + result: RunResult; + usage?: Usage; +}; + +/** + * Stream completed successfully. Always the last event. */ export type DoneEvent = { type: 'done'; @@ -16,19 +64,28 @@ export type DoneEvent = { }; /** - * Stream encountered an error. + * Stream encountered an error. Always carries a `code` (R5) so consumers + * can pattern-match on the canonical enum without parsing message text. */ export type ErrorEvent = { type: 'error'; runId: string; + code: ErrorCode; message: string; - code?: string; + details?: Record; }; /** - * All possible stream events. + * Discriminated union of every stream event the SDK emits. * - * Currently supports text deltas. Rich events (reasoning, tool calls) - * are coming soon. + * Order invariants: `started` first, exactly one of `result`/`error` + * before `done`, `done` last. */ -export type StreamEvent = DeltaEvent | DoneEvent | ErrorEvent; +export type StreamEvent = + | StartedEvent + | DeltaEvent + | ReasoningNodeEvent + | ToolCallEvent + | ResultEvent + | DoneEvent + | ErrorEvent; diff --git a/src/types/run.ts b/src/types/run.ts index 044cfeb..2b45889 100644 --- a/src/types/run.ts +++ b/src/types/run.ts @@ -1,30 +1,84 @@ -export type Engine = 'tim-small-preview' | 'tim-large' | 'timini' | (string & {}); +/** + * Engine identity. The canonical live set is sourced from the API's + * `packages/common/engines.ts` ENGINE_DATA. Old aliases (tim-gpt, tim-large, + * timini, tim-small, etc.) are still accepted for back-compat by the server, + * which resolves them to the live engine; SDK consumers writing new code + * should reach for one of the live names below. + * + * The string-and-string-shaped fallback (`(string & {})`) keeps autocomplete + * narrow without blocking forward-compatible engine names. + */ +export type Engine = + | 'tim' + | 'tim-edge' + | 'tim-claude' + | 'tim-claude-heavy' + | 'tim-omni' + | 'tim-omni-mini' + // Legacy aliases — accepted by the server, resolved to a live engine. + | 'tim-large' + | 'tim-small' + | 'tim-small-preview' + | 'tim-gpt' + | 'tim-gpt-heavy' + | 'timini' + | (string & {}); export type RunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled' | 'timed_out'; +/** + * One completed tool invocation captured inside a reasoning node. (R3.) + * + * Pre-1.0 the SDK typed the tool envelope as `unknown[]`, forcing every + * consumer to cast and JSON.parse. We now expose the concrete shape the + * engine emits so consumers can read parameters and results directly. + */ +export type ToolUse = { + tool_name: string; + parameters?: unknown; + tool_result?: unknown; +}; + +/** + * Single node in the reasoning tree. + * + * Note `subtasks` (plural). Earlier versions shipped `subtask` (singular) + * which differed from the Python SDK and from what the engine actually + * emits. (R2.) + * + * `tooluse` is `ToolUse | null` (singular, not an array) — engines emit at + * most one tool call per node. (R3.) + */ export type ReasoningNode = { title: string; thought: string; - tooluse: unknown[]; - subtask: ReasoningNode[]; + tooluse: ToolUse | null; + subtasks: ReasoningNode[]; conclusion: string; }; -export type RunResult = { - answer: string; - reasoning: ReasoningNode; +/** + * Final structured result. + * + * Generic `T` defaults to `unknown` so consumers using `answerFormat` can + * narrow it: `client.runAndWait({...})`. When unset the engine + * returns `answer: string` for free-form completions. (R10.) + */ +export type RunResult = { + answer: T; + reasoning: ReasoningNode[] | null; }; -export type Run = { +export type Run = { runId: string; status?: RunStatus; - result?: RunResult; + result?: RunResult; usage?: Usage; }; export type Usage = { - models: ModelUsage[]; - platformTools: PlatformToolUsage[]; + inputTokens: number; + outputTokens: number; }; export type ModelUsage = { @@ -42,18 +96,55 @@ export type PlatformToolUsage = { export type RunInput = { instructions: string; tools?: import('./tool.js').Tool[]; + /** + * Inline image inputs. Each entry is either a public URL or a base64 + * data URI. The server folds these into `content[]` at ingest. (R1.) + */ + images?: string[]; + /** + * **Deprecated** — pass `{ type: 'resource', id }` blocks inside + * `tools[]` instead. Still accepted by the server for one minor + * release of back-compat. (R17.) + */ + resources?: string[]; + /** + * Skill IDs. The server resolves these into a manifest the engine + * sees alongside `instructions`. (R1.) + */ + skills?: string[]; + /** + * Optional agent identifier — the run is associated with this agent's + * config + memory. (R1.) + */ + agentId?: string; /** JSON Schema for the answer output format. Use zodToJsonSchema() to generate from Zod. */ answerFormat?: import('./schema.js').OutputSchema; /** JSON Schema for the reasoning output format. Use zodToJsonSchema() to generate from Zod. */ reasoningFormat?: import('./schema.js').OutputSchema; }; +/** + * **Deprecated.** Pre-split `client.run()` accepted `options.awaitCompletion` + * to choose between fire-and-forget and polling-until-complete. The same + * thing is now expressed by calling `client.run()` (fire-and-forget) or + * `client.runAndWait()` (polls until terminal). Existing call sites + * continue to work — passing `options.awaitCompletion: true` to + * `client.run()` transparently routes through `runAndWait()` and emits a + * one-shot console deprecation warning so the change is noisy in dev. + * + * @deprecated Use `client.runAndWait()` instead of `options.awaitCompletion`. + * This shape will be removed in a future minor release. + */ export type RunOptions = { + /** @deprecated Use `client.runAndWait()` instead. */ awaitCompletion?: boolean; }; export type RunParams = { engine: Engine; input: RunInput; + /** + * @deprecated Use `client.runAndWait()` instead of `options.awaitCompletion`. + */ options?: RunOptions; }; diff --git a/src/types/schema.ts b/src/types/schema.ts index 79d10b3..e2c6829 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -69,9 +69,52 @@ export type OutputSchema = { type: 'object'; }; +/** + * Detect whether `value` looks like a Zod schema (has `_def.typeName` or + * a `.shape` getter). Zod isn't a runtime dependency so we duck-type. (R13.) + */ +function isZodSchema(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const v = value as { _def?: { typeName?: string }; shape?: unknown }; + if (v._def?.typeName) return true; + if (typeof v.shape === 'function' || typeof v.shape === 'object') return true; + return false; +} + +/** + * Detect whether `value` is already a JSON Schema OutputSchema. + */ +function isOutputSchema(value: unknown): value is OutputSchema { + if (!value || typeof value !== 'object') return false; + const v = value as { type?: unknown; properties?: unknown }; + return v.type === 'object' && typeof v.properties === 'object' && v.properties !== null; +} + +/** + * Coerce a user-provided answer/reasoning format into the canonical + * `OutputSchema`. Accepts either a Zod schema or an already-built JSON + * Schema. (R13.) + * + * Earlier versions forced consumers to call `zodToJsonSchema(schema, 'X')` + * themselves, which (a) leaked the title parameter into business code and + * (b) failed silently if forgotten. Now both shapes work. + * + * @internal Used by `Subconscious.run()` / `runAndWait()` / `stream()`. + */ +export function coerceAnswerFormat(input: unknown, defaultTitle: string): OutputSchema { + if (isOutputSchema(input)) return input; + if (isZodSchema(input)) return zodToJsonSchema(input, defaultTitle); + // Last-ditch: assume it is already JSON Schema-shaped. + return input as OutputSchema; +} + /** * Convert a Zod schema to the JSON Schema format expected by Subconscious. * + * Most users no longer need to call this directly — `client.run()` and + * friends accept Zod schemas in `answerFormat` and call this internally. + * (R13.) + * * @param schema - A Zod object schema (z.object(...)) * @param title - The title for the schema (required) * @returns A JSON Schema compatible with answerFormat/reasoningFormat @@ -79,7 +122,6 @@ export type OutputSchema = { * @example * ```ts * import { z } from 'zod'; - * import { zodToJsonSchema } from 'subconscious'; * * const schema = z.object({ * summary: z.string().describe('A brief summary'), @@ -87,15 +129,10 @@ export type OutputSchema = { * tags: z.array(z.string()), * }); * - * const answerFormat = zodToJsonSchema(schema, 'AnalysisResult'); - * - * const run = await client.run({ - * engine: 'tim-large', - * input: { - * instructions: 'Analyze this article...', - * tools: [], - * answerFormat, - * }, + * // Pass schema directly — the SDK handles conversion (R13): + * const run = await client.runAndWait<{ summary: string; score: number; tags: string[] }>({ + * engine: 'tim-claude', + * input: { instructions: 'Analyze this article…', answerFormat: schema }, * }); * ``` */ diff --git a/src/types/tool.ts b/src/types/tool.ts index 110bf2b..c1be03a 100644 --- a/src/types/tool.ts +++ b/src/types/tool.ts @@ -1,7 +1,19 @@ +/** + * Tool definitions — one envelope per kind. The discriminator (`type`) is + * the same axis the API uses, and matches `@subconscious/common` 1:1. + * + * Stream Events v2 (R7, R17) adds: + * - `MCPTool.headers` — arbitrary header-based auth that mirrors + * FunctionTool.headers. Use this in preference to URL placeholder + * substitution like `https://mcp.example.com/?api_key={api_key}`. + * - `ResourceTool` — hosted runtime resources (sandbox, memory, browser) + * promoted to first-class tool blocks. + */ + export type PlatformTool = { type: 'platform'; id: string; - options: Record; + options?: Record; }; export type FunctionTool = { @@ -9,14 +21,56 @@ export type FunctionTool = { function: { name: string; description?: string; + /** JSON Schema for tool parameters. */ parameters: Record; + /** + * URL the API POSTs to when the engine invokes this tool. The body is + * flat JSON keyed by parameter name (no `tool_name` envelope). + */ + url: string; + /** + * Header-based auth forwarded as-is on every dispatch. Use this for + * Bearer tokens, X-Api-Key headers, etc. + */ + headers?: Record; + /** + * Hidden parameter values merged into the dispatched body server-side + * before the tool sees it. The model never sees these values; they are + * not declared in `parameters` and not sent to the engine. (R12.) + * + * Example: `defaults: { user_id: 'u_1234' }` lets the tool receive a + * scoped user_id without surfacing it in the schema. + */ + defaults?: Record; }; }; +/** + * Bearer / API-key authentication for an MCP tool. + */ +export type MCPAuth = + | { type: 'bearer'; token: string } + | { type: 'api_key'; token: string; header?: string }; + export type MCPTool = { type: 'mcp'; url: string; - allow?: string[]; + /** Tool names to expose. `["*"]` or omit to expose all. `[]` blocks all. */ + allowedTools?: string[]; + /** Header-based auth forwarded on every MCP request. (R7.) */ + headers?: Record; + /** Bearer / API-key auth via dedicated `auth` block. */ + auth?: MCPAuth; +}; + +/** + * Hosted runtime resource. The server materializes this into one or more + * function tools (`Sandbox.exec`, `Browser.openTab`, …) before dispatch. + * (R17.) + */ +export type ResourceTool = { + type: 'resource'; + id: 'sandbox' | 'memory' | 'browser'; }; -export type Tool = PlatformTool | FunctionTool | MCPTool; +export type Tool = PlatformTool | FunctionTool | MCPTool | ResourceTool;