diff --git a/README.md b/README.md index 9b28ca31..9d35e60c 100644 --- a/README.md +++ b/README.md @@ -506,16 +506,20 @@ More: [Heartbeat guide](docs/guides/heartbeat.md) ### Programmatic Runtime -Heddle is not only a CLI. The npm package exposes two main programmatic layers: +Heddle is not only a CLI. The npm package exposes three main programmatic layers: - `createConversationEngine`: an alpha API for persisted multi-turn sessions with session storage, compaction, approvals, traces, semantic activity, and custom frontends or local hosts +- `ConversationRunService`: process-local run identity, ordered activity, bounded replay, cancellation, and approval resolution for reconnectable hosted conversations - `AgentLoopRuntimeService.run(...)`: a lower-level single-run execution loop for hosts that do not need persisted chat or session behavior Advanced hosts can also reuse lower-level class APIs such as `ToolRegistry`, `ToolExecutionService`, `TraceRecorder`, `TraceConsoleFormatter`, and `ReviewDiffParser` when they intentionally assemble custom runtime or review surfaces. Other exported primitives include `HeartbeatRunnerAgent.run`, `HeartbeatSchedulerService.runDueTasks`, and `FileHeartbeatTaskService` for scheduled or custom host workflows. -More: [Programmatic hosts](docs/guides/programmatic/README.md) +More: [Programmatic hosts](docs/guides/programmatic/README.md), +[choosing an integration layer](docs/guides/programmatic/integration-layers.md), +and the runnable +[hosted service → HTTP/SSE → browser client example](examples/sdk/05-hosted-agent/README.md). ### Semantic Drift diff --git a/docs/guides/programmatic/README.md b/docs/guides/programmatic/README.md index c44ff58d..4aa9accf 100644 --- a/docs/guides/programmatic/README.md +++ b/docs/guides/programmatic/README.md @@ -17,6 +17,11 @@ There are two import entry points: models, awareness) and specialized runtimes (agent loop, heartbeat, integrations). Reach for this only when the curated surface is not enough. +Before choosing an API, read +[Choose a Programmatic Integration Layer](integration-layers.md). It maps common +host stacks to the smallest useful Heddle boundary and makes the responsibility +split between Heddle and the host explicit for developers and coding agents. + 1. **Start here** — stand up a conversation agent. - [Quickstart](quickstart.md): a minimal persisted conversation with default text output, in a few lines. @@ -33,6 +38,9 @@ There are two import entry points: - [Conversation engine](conversation-engine.md): engine setup, state roots, and persisted sessions. - [Approvals](approvals.md): own policy decisions in the host. + - [Hosted agent stack](../../../examples/sdk/05-hosted-agent/README.md): compose + a transport-neutral run service, Express/SSE API, and browser client while + keeping each layer replaceable. 5. **Advanced: storage** — back Heddle with your own persistence. - [Result artifacts](result-artifacts.md): save large generated values as reusable artifacts. diff --git a/docs/guides/programmatic/conversation-engine.md b/docs/guides/programmatic/conversation-engine.md index 1c597a9a..479fdf1c 100644 --- a/docs/guides/programmatic/conversation-engine.md +++ b/docs/guides/programmatic/conversation-engine.md @@ -121,6 +121,12 @@ The replay buffer is intentionally process-local and bounded. Durable final conversation state remains in the engine's session repository; transports and cross-process delivery remain host responsibilities. +For a complete runnable host, follow the +[hosted agent stack example](../../../examples/sdk/05-hosted-agent/README.md). It +uses this exact service for account-scoped start/subscribe/cancel, then adds an +Express/SSE adapter and a framework-neutral browser client without moving HTTP, +authentication, or reconnect policy into the conversation core. + ## Reading artifacts Each turn result already includes the artifacts produced by that turn. To review diff --git a/docs/guides/programmatic/integration-layers.md b/docs/guides/programmatic/integration-layers.md new file mode 100644 index 00000000..451feac9 --- /dev/null +++ b/docs/guides/programmatic/integration-layers.md @@ -0,0 +1,153 @@ +# Choose a Programmatic Integration Layer + +Heddle's curated SDK assumes a Node.js 20+ TypeScript host that wants to build a +conversational agent experience. It intentionally does not assume how that host +is deployed, which server framework it uses, how events are transported, or +which client renders the conversation. + +Use this guide before copying an example. The goal is to adopt the lowest layer +that does the needed heavy lifting while leaving existing product architecture +in control. + +## Mental model + +```text +HOST-OWNED PRODUCT + UI state and rendering + | + transport client and public wire contract optional + | + server adapter, auth, tenancy, rate limits optional + | + application service and composition root + (product session IDs, tools, config, repositories, policy) +======================== HEDDLE SDK BOUNDARY ======================== + ConversationRunService + (active run ID, ordered events, bounded replay, cancel, approvals) + | + ConversationEngine + (persisted sessions, turns, compaction, traces, artifacts) + | + agent loop, models, tools, host extensions, MCP +``` + +The application service above the boundary is host code. The +[`HostedAgentService`](../../../examples/sdk/05-hosted-agent/01-hosted-service/agent-service.ts) +example shows one useful shape, but it is not a required Heddle wrapper and is +not transport infrastructure. + +## Responsibility boundary + +| Concern | Heddle owns | Host owns | +| --- | --- | --- | +| Conversation semantics | Messages, turns, continuation, compaction, leases, and persisted session behavior | Stable product conversation ID and access policy | +| Agent execution | Model/tool loop, tool registry/execution, host-extension composition, traces, and artifacts | Product tools, system context, model choice, credentials, and capability policy | +| Approvals | Approval request/resolution lifecycle and run integration | Whether an action is allowed, authenticated approver, and approval UI/policy | +| Active runs | Run ID, ordered sequence, process-local replay, subscription, cancellation, and terminal result | Lifetime of the run service, address scope, process routing, draining, and multi-process delivery | +| Persistence | File-backed defaults plus injectable session/artifact repository ports | Production repository implementations, retention, encryption, backup, and tenancy | +| Identity and authorization | No identity-provider assumption | Authentication, tenant/user mapping, authorization for start/subscribe/cancel | +| Transport/API | No HTTP, tRPC, SSE, or WebSocket assumption | Framework, routes/procedures, wire schemas, errors, limits, CORS, and rate limiting | +| Client experience | Semantic activities and terminal run events | Messages, tool rendering, UI state, retries, notifications, and product-specific result presentation | + +Do not rebuild Heddle-owned conversation or run behavior in the host. In +particular, avoid replaying product chat history into every prompt, generating a +second run ID, adding another in-process event bus, or treating subscriber +disconnect as implicit cancellation. + +## Choose by host assumptions + +| What the host already has | Heddle entrypoint | Example to follow | +| --- | --- | --- | +| Nothing beyond a TypeScript process | `runQuickstartConversationCli` | [`01-interactive-chat.ts`](../../../examples/sdk/01-interactive-chat.ts) | +| A local loop that needs product tools or MCP | Quickstart plus tools/host extensions | [`02-add-a-tool.ts`](../../../examples/sdk/02-add-a-tool.ts), [`03-add-an-mcp-server.ts`](../../../examples/sdk/03-add-an-mcp-server.ts) | +| Its own output sink or local UI | `createConversationEngine` + `createConversationTextHost` or host callbacks | [`04-custom-output.ts`](../../../examples/sdk/04-custom-output.ts) | +| A server/worker that owns transport | `createConversationEngine` + `ConversationRunService` | [`05-hosted-agent/01-hosted-service`](../../../examples/sdk/05-hosted-agent/01-hosted-service) | +| Express with REST + SSE | Same core plus a host adapter | [`05-hosted-agent/02-http-sse-api`](../../../examples/sdk/05-hosted-agent/02-http-sse-api) | +| A browser using that exact REST/SSE contract | Host API plus the protocol client | [`05-hosted-agent/03-browser-client`](../../../examples/sdk/05-hosted-agent/03-browser-client) | + +For tRPC, Fastify, Hono, Nest, WebSocket, Electron IPC, queues, or another +transport, stop at the hosted-service layer and implement the adapter in the +host's existing framework. Preserve the run lifecycle contract—start, +sequence-based subscribe/replay, explicit cancel, and a terminal event—without +copying Express-specific code. + +## Layer-by-layer assumptions + +### Quickstart runner + +Use `runQuickstartConversationCli` when Heddle may own the prompt loop and +plain-text terminal experience. The host supplies configuration and optional +capabilities. Move deeper when the product needs its own presentation or +lifecycle. + +### Conversation engine + +Use `createConversationEngine` when the host owns presentation, commands, +approval UI, or session browsing. Heddle still owns the durable conversation +model. Call `engine.turns.submit(...)` for an in-process turn. + +### Run service + +Add one host-long-lived `ConversationRunService` when request and subscription +lifetimes differ, clients can reconnect, or a turn needs a separately +addressable cancel/approval lifecycle. It is a process-local coordinator above +the engine, not a transport or durable message broker. + +### Transport adapter + +Add a host-owned adapter when a remote client needs start, subscribe, cancel, or +approval operations. Validate all untrusted wire data and project internal run +results into an explicitly public schema. Authentication and authorization must +happen before resolving the Heddle run address. + +### Client protocol and UI + +Add a transport client only when it matches the host's API. Keep protocol +parsing, cursors, and abort propagation below product UI state. Keep messages, +tool rendering, retry UX, and product result handling in the application's +normal client architecture. + +## Extension points + +| Requirement | Extend here | +| --- | --- | +| Domain actions or data access | `ToolDefinition`, toolkits, or host extensions | +| MCP-backed capabilities | `prepareMcpHostExtension` | +| Prompt/system behavior | Engine `systemContext`, turn prompt formatting, or host extensions | +| Model and credentials | Host composition root / `createConversationEngine` config | +| Approval behavior | `ConversationEngineHost.approvals` and the product's policy/UI | +| Session/artifact storage | `ChatSessionRepository` and `ArtifactRepository` | +| Public API fields | Host-owned validation schemas and terminal-result projection | +| REST, tRPC, SSE, WebSocket, or IPC | Host transport adapter above the application service | +| React or other UI state | Product client/application layer above the protocol client | +| Multi-process live delivery | Host-selected shared routing/broker infrastructure | + +## Instructions for coding agents + +Before implementation, discover and state: + +1. the host's server framework and transport; +2. how identity and tenancy are authenticated; +3. how product conversation IDs are persisted; +4. which UI/state layer consumes run events; +5. whether one process owns a run for its lifetime; +6. which repositories, approval policy, tools, and telemetry are production + requirements. + +Then select the lowest matching layer, copy only the relevant example stages, +and preserve the dependency direction shown above. Prefer adapting the host's +existing framework over installing the sample stack. Do not move transport, +auth, or UI decisions into Heddle core. + +Before handoff, verify at minimum: + +- a second turn reuses the same durable Heddle session; +- disconnecting one subscriber does not cancel the run; +- reconnecting after sequence N does not restart the turn or duplicate earlier + events; +- a different authenticated scope cannot subscribe to or cancel the run; +- explicit cancellation reaches a terminal cancelled event; +- only intended public result fields cross the transport boundary. + +Continue with the [programmatic guide index](README.md) for API details or the +[numbered SDK examples](../../../examples/sdk/README.md) for runnable code. diff --git a/docs/guides/programmatic/quickstart.md b/docs/guides/programmatic/quickstart.md index 9890a43b..19cfce16 100644 --- a/docs/guides/programmatic/quickstart.md +++ b/docs/guides/programmatic/quickstart.md @@ -5,6 +5,10 @@ This is rung 1 of the programmatic ladder — the smallest working agent. See th shape output → lifecycle → storage), and [`examples/sdk/`](../../../examples/sdk/README.md) for runnable versions. +If your product already owns a server, transport, or UI, use the +[integration-layer chooser](integration-layers.md) to skip directly to the +lowest matching boundary instead of copying the terminal quickstart stack. + Use `runQuickstartConversationCli` when you want a working interactive conversation loop before building a custom UI: diff --git a/examples/sdk/01-interactive-chat.ts b/examples/sdk/01-interactive-chat.ts index 1d0e44a4..03a6c4cd 100644 --- a/examples/sdk/01-interactive-chat.ts +++ b/examples/sdk/01-interactive-chat.ts @@ -1,3 +1,12 @@ +/** + * Stage 01: the smallest persisted conversational agent. + * + * Prerequisite: a configured Heddle model credential. + * Assumption: Heddle may own the local prompt loop and plain-text output. + * Shows: the default SDK experience before the host customizes capabilities, + * presentation, lifecycle, transport, or storage. + * Run: yarn example:sdk:interactive + */ import { runQuickstartConversationCli } from '../../src/index.js'; await runQuickstartConversationCli(); diff --git a/examples/sdk/02-add-a-tool.ts b/examples/sdk/02-add-a-tool.ts index 3cec4351..f5656491 100644 --- a/examples/sdk/02-add-a-tool.ts +++ b/examples/sdk/02-add-a-tool.ts @@ -1,5 +1,11 @@ -// Rung 2 — add a capability: hand the agent one of your own tools. -// Run: yarn example:sdk:add-tool +/** + * Stage 02: add one host-owned capability to the quickstart conversation. + * + * Prerequisite: stage 01's credential setup. + * Assumption: the host owns useful domain behavior and exposes it as a tool; + * Heddle owns registration, model invocation, approval integration, and traces. + * Run: yarn example:sdk:add-tool + */ import { runQuickstartConversationCli, type ToolDefinition } from '../../src/index.js'; const currentTimeTool: ToolDefinition = { diff --git a/examples/sdk/03-add-an-mcp-server.ts b/examples/sdk/03-add-an-mcp-server.ts index 04b4e0e1..b7f9a10d 100644 --- a/examples/sdk/03-add-an-mcp-server.ts +++ b/examples/sdk/03-add-an-mcp-server.ts @@ -1,7 +1,11 @@ -// Rung 2 — add a capability: expose an MCP server's tools to the agent. -// prepareMcpHostExtension writes MCP config/catalog under stateRoot, then -// returns a host extension you pass straight to the runner. -// Run: yarn example:sdk:add-mcp (edit the server command for a real server) +/** + * Stage 03: expose an existing MCP server as curated Heddle capabilities. + * + * Prerequisites: stage 01's credential setup plus a runnable MCP server. + * Assumption: the host selects/configures MCP; Heddle prepares its tool catalog, + * approval path, and host extension. Replace the demo command for production. + * Run: yarn example:sdk:add-mcp + */ import { join } from 'node:path'; import { prepareMcpHostExtension, runQuickstartConversationCli } from '../../src/index.js'; diff --git a/examples/sdk/04-custom-output.ts b/examples/sdk/04-custom-output.ts index 061d64dc..f28f7d46 100644 --- a/examples/sdk/04-custom-output.ts +++ b/examples/sdk/04-custom-output.ts @@ -1,6 +1,12 @@ -// Rung 3 — shape input/output: drive the engine directly and send streaming -// text to your own destination instead of the default terminal writer. -// Run: yarn example:sdk:custom-output +/** + * Stage 04: keep Heddle's conversation engine and replace presentation. + * + * Prerequisite: stage 01's credential setup. + * Assumption: one process owns the turn while the host owns the output sink. + * Shows: the boundary to use for a custom terminal, webhook, log sink, or local + * application view before adding addressable/reconnectable run lifecycle. + * Run: yarn example:sdk:custom-output + */ import { join } from 'node:path'; import { createConversationEngine, createConversationTextHost } from '../../src/index.js'; diff --git a/examples/sdk/05-hosted-agent/01-hosted-service/README.md b/examples/sdk/05-hosted-agent/01-hosted-service/README.md new file mode 100644 index 00000000..92af50fb --- /dev/null +++ b/examples/sdk/05-hosted-agent/01-hosted-service/README.md @@ -0,0 +1,29 @@ +# 01 Hosted Service + +Start here when a TypeScript server, worker, desktop backend, or other long-lived +process should host a conversational agent without committing to a transport. +See the [full hosted-agent walkthrough](../README.md#01-hosted-service-heddle-engine-host-lifecycle) +for the runnable flow and production checklist. + +## Assumptions + +- Heddle owns conversation history, turns, tools, traces, artifacts, run events, + replay, and cancellation. +- The host owns authenticated account scope, stable product conversation IDs, + engine configuration, repositories, approval policy, and process lifetime. +- Active run handles and replay may live in one process. A distributed host must + add its own routing/shared-delivery design above this boundary. + +## Read in this order + +1. [`agent-service.ts`](agent-service.ts) — application-owned scope and lifecycle + composed over Heddle's `ConversationRunService`. +2. [`example-agent.ts`](example-agent.ts) — replaceable local composition and + demo policy. +3. [`run.ts`](run.ts) — disconnect, cursor replay, and explicit cancellation in + one process. + +Keep this folder free of HTTP request/response types and UI state. If the host +already uses tRPC, Fastify, Hono, Nest, WebSocket, or IPC, adapt this service +directly in that stack. Continue to [02 HTTP/SSE API](../02-http-sse-api/) only +when Express + REST/SSE matches the host. diff --git a/examples/sdk/05-hosted-agent/01-hosted-service/agent-service.ts b/examples/sdk/05-hosted-agent/01-hosted-service/agent-service.ts new file mode 100644 index 00000000..12e282a5 --- /dev/null +++ b/examples/sdk/05-hosted-agent/01-hosted-service/agent-service.ts @@ -0,0 +1,163 @@ +/** + * Stage 05.1: host application lifecycle above Heddle's conversation/run APIs. + * + * This module assumes the host owns authenticated account scope, stable product + * session IDs, composition, and process lifetime. It deliberately has no HTTP, + * transport, or UI dependency; those belong in later optional stages. + */ +import { + ConversationRunService, + type ConversationEngine, + type ConversationEngineHost, + type ConversationRunHandle, + type ConversationRunReplayOptions, + type ConversationRunStreamItem, + type ConversationTurnResultSummary, +} from '../../../../src/index.js'; + +const DEFAULT_RUN_RETENTION_MS = 5 * 60_000; + +export type HostedRunAddress = { + accountId: string; + sessionId: string; +}; + +export type StartHostedAgentRunInput = HostedRunAddress & { + prompt: string; +}; + +export type HostedAgentRunAccepted = { + accepted: true; + runId: string; + acceptedAt: string; + sessionId: string; +}; + +export type HostedAgentRunStreamItem = ConversationRunStreamItem; + +export type HostedAgentServiceOptions = { + createEngine(address: HostedRunAddress): ConversationEngine | Promise; + createHost?(address: HostedRunAddress): ConversationEngineHost | Promise; + replay?: ConversationRunReplayOptions; +}; + +type HostedRunContext = { + address: HostedRunAddress; + run: ConversationRunHandle; +}; + +export class HostedAgentRunNotFoundError extends Error {} +export class HostedAgentRunConflictError extends Error {} +export class HostedAgentInputError extends Error {} + +/** + * Owns the application-level lifecycle for a hosted conversational agent. + * + * Heddle owns conversation execution and replay. This service owns account + * scoping, engine/session construction, and authorization of run handles. + * Authentication verification happens before this service; HTTP, SSE, and UI + * state stay outside it. + */ +export class HostedAgentService { + private readonly runs: ConversationRunService; + private readonly contexts = new Map(); + private readonly retentionMs: number; + + constructor(private readonly options: HostedAgentServiceOptions) { + this.retentionMs = options.replay?.retentionMs ?? DEFAULT_RUN_RETENTION_MS; + this.runs = new ConversationRunService({ + addressKey: ({ accountId, sessionId }) => JSON.stringify([accountId, sessionId]), + replay: { + maxEventsPerRun: options.replay?.maxEventsPerRun, + retentionMs: this.retentionMs, + }, + }); + } + + async start(input: StartHostedAgentRunInput): Promise { + const address = HostedAgentService.normalizeAddress(input); + const prompt = input.prompt.trim(); + if (!prompt) { + throw new HostedAgentInputError('Hosted agent prompts cannot be empty.'); + } + if (this.runs.isRunning(address)) { + throw new HostedAgentRunConflictError(`A run is already active for session ${address.sessionId}.`); + } + + const engine = await this.options.createEngine(address); + const host = await this.options.createHost?.(address); + const session = engine.sessions.readExisting(address.sessionId) + ?? engine.sessions.create({ id: address.sessionId, name: 'Hosted agent conversation' }); + + if (this.runs.isRunning(address)) { + throw new HostedAgentRunConflictError(`A run is already active for session ${address.sessionId}.`); + } + + const run = this.runs.startTurn({ + address, + engine, + turn: { + sessionId: session.id, + prompt, + host, + }, + }); + + this.contexts.set(run.runId, { address, run }); + this.expireContext(run); + + return { + accepted: true, + runId: run.runId, + acceptedAt: run.acceptedAt, + sessionId: session.id, + }; + } + + subscribe(input: { + accountId: string; + runId: string; + afterSequence?: number; + signal?: AbortSignal; + }): AsyncIterable { + return this.requireOwnedRun(input.accountId, input.runId).run.events({ + afterSequence: input.afterSequence, + signal: input.signal, + }); + } + + cancel(accountId: string, runId: string): boolean { + return this.requireOwnedRun(accountId, runId).run.cancel(); + } + + private requireOwnedRun(accountId: string, runId: string): HostedRunContext { + const context = this.contexts.get(runId); + if (!context || context.address.accountId !== accountId.trim()) { + throw new HostedAgentRunNotFoundError(`Hosted agent run not found: ${runId}`); + } + return context; + } + + private expireContext(run: HostedRunContext['run']): void { + void run.result.finally(() => { + if (this.retentionMs === 0) { + this.contexts.delete(run.runId); + return; + } + + const timer = setTimeout(() => this.contexts.delete(run.runId), this.retentionMs); + timer.unref?.(); + }).catch(() => undefined); + } + + private static normalizeAddress(input: { accountId: string; sessionId: string }): HostedRunAddress { + const address = { + accountId: input.accountId.trim(), + sessionId: input.sessionId.trim(), + }; + if (!address.accountId || !address.sessionId) { + throw new HostedAgentInputError('Hosted runs require non-empty account and session IDs.'); + } + return address; + } +} diff --git a/examples/sdk/05-hosted-agent/01-hosted-service/example-agent.ts b/examples/sdk/05-hosted-agent/01-hosted-service/example-agent.ts new file mode 100644 index 00000000..cadac79c --- /dev/null +++ b/examples/sdk/05-hosted-agent/01-hosted-service/example-agent.ts @@ -0,0 +1,44 @@ +/** + * Runnable composition root for stage 05. + * + * The local filesystem, inspect-only tools, demo model fallback, and denied + * approvals are example policy. A product should replace them here without + * changing the transport-neutral HostedAgentService. + */ +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { createConversationEngine } from '../../../../src/index.js'; +import { HostedAgentService } from './agent-service.js'; + +export const EXAMPLE_ACCOUNT_ID = 'local-example-account'; + +export function createExampleHostedAgentService(): HostedAgentService { + const workspaceRoot = process.cwd(); + const exampleStateRoot = join(workspaceRoot, '.heddle', 'examples', 'hosted-agent'); + + return new HostedAgentService({ + createEngine: ({ accountId }) => createConversationEngine({ + workspaceRoot, + stateRoot: join(exampleStateRoot, accountStorageKey(accountId)), + model: process.env.HEDDLE_EXAMPLE_MODEL ?? process.env.HEDDLE_MODEL ?? 'gpt-5.4', + systemContext: 'You are a conversational assistant embedded in a TypeScript product.', + memoryMaintenanceMode: 'none', + toolProfile: { + preset: 'inspect', + memoryMode: 'none', + }, + }), + createHost: () => ({ + approvals: { + requestToolApproval: async ({ call }) => ({ + approved: false, + reason: `The hosted example does not approve ${call.tool}. Inject your product policy here.`, + }), + }, + }), + }); +} + +function accountStorageKey(accountId: string): string { + return createHash('sha256').update(accountId).digest('hex'); +} diff --git a/examples/sdk/05-hosted-agent/01-hosted-service/run.ts b/examples/sdk/05-hosted-agent/01-hosted-service/run.ts new file mode 100644 index 00000000..845f8d89 --- /dev/null +++ b/examples/sdk/05-hosted-agent/01-hosted-service/run.ts @@ -0,0 +1,75 @@ +/** + * Stage 05.1 runner: exercise a hosted run without choosing HTTP, SSE, or a UI. + * + * Assumptions: the TypeScript host owns account/session identity and process + * lifetime; Heddle owns the persisted conversation and active run lifecycle. + * Run: yarn example:sdk:hosted-agent "What does this repository do?" + */ +import type { HostedAgentRunStreamItem } from './agent-service.js'; +import { EXAMPLE_ACCOUNT_ID, createExampleHostedAgentService } from './example-agent.js'; + +const cancelDemo = process.argv.includes('--cancel-demo'); +const prompt = process.argv + .slice(2) + .filter((argument) => argument !== '--cancel-demo') + .join(' ') + || 'Read the repository README and summarize the project in three sentences.'; +const sessionId = process.env.HEDDLE_EXAMPLE_SESSION_ID ?? 'hosted-agent-service-example'; +const agent = createExampleHostedAgentService(); +const accepted = await agent.start({ accountId: EXAMPLE_ACCOUNT_ID, sessionId, prompt }); + +console.log(`Accepted ${accepted.runId}.`); + +let cursor = 0; +let terminal: HostedAgentRunStreamItem | undefined; +for await (const event of agent.subscribe({ accountId: EXAMPLE_ACCOUNT_ID, runId: accepted.runId })) { + cursor = event.sequence; + terminal = renderEvent(event); + if (event.kind === 'activity') { + console.log(`Disconnecting after sequence ${cursor}; the run continues in the host process.`); + break; + } +} + +if (!terminal) { + console.log(`Reconnecting from sequence ${cursor}.`); + for await (const event of agent.subscribe({ + accountId: EXAMPLE_ACCOUNT_ID, + runId: accepted.runId, + afterSequence: cursor, + })) { + renderEvent(event); + } +} + +if (cancelDemo) { + const cancellation = await agent.start({ + accountId: EXAMPLE_ACCOUNT_ID, + sessionId: `${sessionId}-cancel-${Date.now()}`, + prompt: 'Inspect this repository carefully and report its main subsystems.', + }); + console.log(`Cancelling ${cancellation.runId}: ${agent.cancel(EXAMPLE_ACCOUNT_ID, cancellation.runId)}`); + for await (const event of agent.subscribe({ + accountId: EXAMPLE_ACCOUNT_ID, + runId: cancellation.runId, + })) { + renderEvent(event); + } +} + +function renderEvent(event: HostedAgentRunStreamItem): HostedAgentRunStreamItem | undefined { + if (event.kind === 'activity') { + console.log(`[${event.sequence}] activity ${event.activity.type}`); + return undefined; + } + if (event.kind === 'result') { + console.log(`[${event.sequence}] result ${event.result.summary}`); + return event; + } + if (event.kind === 'cancelled') { + console.log(`[${event.sequence}] cancelled ${event.reason}`); + return event; + } + console.log(`[${event.sequence}] error ${event.error.message}`); + return event; +} diff --git a/examples/sdk/05-hosted-agent/02-http-sse-api/README.md b/examples/sdk/05-hosted-agent/02-http-sse-api/README.md new file mode 100644 index 00000000..df55c35a --- /dev/null +++ b/examples/sdk/05-hosted-agent/02-http-sse-api/README.md @@ -0,0 +1,26 @@ +# 02 HTTP/SSE API + +Follow this optional stage when the host uses Express and wants a REST/SSE API +over the [transport-neutral hosted service](../01-hosted-service/). See the +[full hosted-agent walkthrough](../README.md#02-httpsse-api-host-owned-transport) +for curl commands, lifecycle details, and production replacements. + +## Assumptions + +- Stage 01 already owns Heddle conversation/run lifecycle. +- The host chose Express, JSON requests, and SSE as its transport. +- The host will replace demo bearer auth with verified product identity and + derive account scope server-side. + +## Read in this order + +1. [`contracts.ts`](contracts.ts) — Zod schemas for untrusted public wire data. +2. [`http-api.ts`](http-api.ts) — authenticated start, cursor subscribe, and + explicit cancel handlers. +3. [`server.ts`](server.ts) — runnable local composition with deliberately + non-production auth. + +The transport must preserve stable `runId`, ordered `sequence`, replay after a +cursor, subscriber-only disconnect, explicit cancellation, and a terminal +event. If the host uses tRPC, Fastify, Hono, Nest, WebSocket, or another stack, +implement those semantics there and do not copy the Express router. diff --git a/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts b/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts new file mode 100644 index 00000000..4f907935 --- /dev/null +++ b/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts @@ -0,0 +1,68 @@ +/** + * Stage 05.2 public HTTP/SSE contract. + * + * Zod schemas live at the untrusted wire boundary, not in the stage-1 service. + * Extend these schemas only with product data safe for remote clients. + */ +import { z } from 'zod'; + +export const StartHostedAgentRunInputSchema = z.object({ + sessionId: z.string().trim().min(1).max(128), + prompt: z.string().trim().min(1).max(20_000), +}); + +export const StartHostedAgentRunResultSchema = z.object({ + accepted: z.literal(true), + runId: z.string().min(1), + acceptedAt: z.iso.datetime(), + sessionId: z.string().min(1), +}); + +const RunEventEnvelopeSchema = z.object({ + runId: z.string().min(1), + sequence: z.number().int().positive().safe(), + timestamp: z.iso.datetime(), +}); + +export const HostedAgentRunEventSchema = z.discriminatedUnion('kind', [ + RunEventEnvelopeSchema.extend({ + kind: z.literal('activity'), + activity: z.object({ + type: z.string().min(1), + }).passthrough(), + }), + RunEventEnvelopeSchema.extend({ + kind: z.literal('result'), + result: z.object({ + outcome: z.string().min(1), + summary: z.string(), + }), + }), + RunEventEnvelopeSchema.extend({ + kind: z.literal('cancelled'), + reason: z.string(), + }), + RunEventEnvelopeSchema.extend({ + kind: z.literal('error'), + error: z.object({ + code: z.literal('run_failed'), + message: z.string(), + }), + }), +]); + +export const CancelHostedAgentRunResultSchema = z.object({ + cancelled: z.boolean(), +}); + +export const HostedAgentApiErrorSchema = z.object({ + error: z.object({ + code: z.string().min(1), + message: z.string().min(1), + }), +}); + +export type StartHostedAgentRunInput = z.infer; +export type StartHostedAgentRunResult = z.infer; +export type HostedAgentRunEvent = z.infer; +export type CancelHostedAgentRunResult = z.infer; diff --git a/examples/sdk/05-hosted-agent/02-http-sse-api/http-api.ts b/examples/sdk/05-hosted-agent/02-http-sse-api/http-api.ts new file mode 100644 index 00000000..658b37d9 --- /dev/null +++ b/examples/sdk/05-hosted-agent/02-http-sse-api/http-api.ts @@ -0,0 +1,196 @@ +/** + * Stage 05.2 Express/SSE adapter for the transport-neutral hosted service. + * + * The host owns authentication, authorization, public schemas, HTTP policy, + * and deployment. Reimplement this adapter in the host's existing framework + * when Express/SSE is not already part of the stack. + */ +import { once } from 'node:events'; +import { + Router, + type Request, + type RequestHandler, + type Response, +} from 'express'; +import { z, ZodError } from 'zod'; +import { + HostedAgentInputError, + HostedAgentRunConflictError, + HostedAgentRunNotFoundError, + type HostedAgentService, +} from '../01-hosted-service/agent-service.js'; +import { + CancelHostedAgentRunResultSchema, + HostedAgentApiErrorSchema, + HostedAgentRunEventSchema, + StartHostedAgentRunInputSchema, + StartHostedAgentRunResultSchema, + type HostedAgentRunEvent, +} from './contracts.js'; + +const AuthenticatedAccountSchema = z.object({ + accountId: z.string().trim().min(1), +}); +const RunIdSchema = z.string().trim().min(1); +const ReplayCursorSchema = z.string() + .regex(/^(0|[1-9]\d*)$/, 'Replay cursor must be a non-negative integer.') + .transform(Number) + .refine(Number.isSafeInteger, 'Replay cursor must be a safe integer.'); + +export type HostedAgentApiDeps = { + agent: HostedAgentService; + authenticate(request: Request): Promise<{ accountId: string }>; + onError?(error: unknown): void; +}; + +export class HostedAgentApiError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + } +} + +export function createHostedAgentApiRouter(deps: HostedAgentApiDeps): Router { + const router = Router(); + router.post('/runs', createStartHostedAgentRunHandler(deps)); + router.get('/runs/:runId/events', createSubscribeHostedAgentRunHandler(deps)); + router.post('/runs/:runId/cancel', createCancelHostedAgentRunHandler(deps)); + return router; +} + +export function createStartHostedAgentRunHandler(deps: HostedAgentApiDeps): RequestHandler { + return async (request, response) => { + try { + const { accountId } = await authenticate(deps, request); + const input = StartHostedAgentRunInputSchema.parse(request.body); + const accepted = await deps.agent.start({ accountId, ...input }); + response.status(202).json(StartHostedAgentRunResultSchema.parse(accepted)); + } catch (error) { + sendRequestError(deps, response, error); + } + }; +} + +export function createSubscribeHostedAgentRunHandler(deps: HostedAgentApiDeps): RequestHandler { + return async (request, response) => { + const subscription = new AbortController(); + const abortSubscription = () => subscription.abort(); + request.once('aborted', abortSubscription); + response.once('close', abortSubscription); + + try { + const { accountId } = await authenticate(deps, request); + const runId = RunIdSchema.parse(request.params.runId); + const afterSequence = parseReplayCursor(request); + const events = deps.agent.subscribe({ + accountId, + runId, + afterSequence, + signal: subscription.signal, + }); + + setSseHeaders(response); + for await (const event of events) { + await writeSseEvent(response, HostedAgentRunEventSchema.parse(event), subscription.signal); + } + endResponse(response); + } catch (error) { + if (subscription.signal.aborted) { + return; + } + if (!response.headersSent) { + sendRequestError(deps, response, error); + return; + } + deps.onError?.(error); + endResponse(response); + } finally { + request.off('aborted', abortSubscription); + response.off('close', abortSubscription); + } + }; +} + +export function createCancelHostedAgentRunHandler(deps: HostedAgentApiDeps): RequestHandler { + return async (request, response) => { + try { + const { accountId } = await authenticate(deps, request); + const runId = RunIdSchema.parse(request.params.runId); + response.json(CancelHostedAgentRunResultSchema.parse({ + cancelled: deps.agent.cancel(accountId, runId), + })); + } catch (error) { + sendRequestError(deps, response, error); + } + }; +} + +async function authenticate(deps: HostedAgentApiDeps, request: Request): Promise<{ accountId: string }> { + return AuthenticatedAccountSchema.parse(await deps.authenticate(request)); +} + +function parseReplayCursor(request: Request): number | undefined { + // An explicit query cursor wins over Last-Event-ID so fetch clients can + // deliberately choose their checkpoint; native EventSource uses the header. + const value = request.query.after ?? request.header('Last-Event-ID'); + return value === undefined ? undefined : ReplayCursorSchema.parse(value); +} + +function setSseHeaders(response: Response): void { + response.status(200); + response.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + response.setHeader('Cache-Control', 'no-cache, no-transform'); + response.setHeader('Connection', 'keep-alive'); + response.setHeader('X-Accel-Buffering', 'no'); + response.flushHeaders?.(); +} + +async function writeSseEvent( + response: Response, + event: HostedAgentRunEvent, + signal: AbortSignal, +): Promise { + const frame = `event: ${event.kind}\nid: ${event.sequence}\ndata: ${JSON.stringify(event)}\n\n`; + if (response.write(frame)) { + return; + } + await once(response, 'drain', { signal }); +} + +function endResponse(response: Response): void { + if (!response.destroyed && !response.writableEnded) { + response.end(); + } +} + +function sendRequestError(deps: HostedAgentApiDeps, response: Response, error: unknown): void { + const apiError = toApiError(error); + if (apiError.status >= 500) { + deps.onError?.(error); + } + response.status(apiError.status).json(HostedAgentApiErrorSchema.parse({ + error: { + code: apiError.code, + message: apiError.message, + }, + })); +} + +function toApiError(error: unknown): HostedAgentApiError { + if (error instanceof HostedAgentApiError) { + return error; + } + if (error instanceof HostedAgentRunNotFoundError) { + return new HostedAgentApiError(404, 'run_not_found', error.message); + } + if (error instanceof HostedAgentRunConflictError) { + return new HostedAgentApiError(409, 'run_conflict', error.message); + } + if (error instanceof HostedAgentInputError || error instanceof ZodError) { + return new HostedAgentApiError(400, 'invalid_request', 'Request validation failed.'); + } + return new HostedAgentApiError(500, 'internal_error', 'The hosted agent request failed.'); +} diff --git a/examples/sdk/05-hosted-agent/02-http-sse-api/server.ts b/examples/sdk/05-hosted-agent/02-http-sse-api/server.ts new file mode 100644 index 00000000..a3513e62 --- /dev/null +++ b/examples/sdk/05-hosted-agent/02-http-sse-api/server.ts @@ -0,0 +1,64 @@ +/** + * Stage 05.2 runner: expose the stage-1 service through Express and SSE. + * + * This stage assumes the host chose this transport stack. Authentication, + * routing, tenancy, and deployment remain host responsibilities. + * Run: HEDDLE_EXAMPLE_BEARER_TOKEN=local-example-secret yarn example:sdk:hosted-api + */ +import { timingSafeEqual } from 'node:crypto'; +import express, { type Request } from 'express'; +import { + EXAMPLE_ACCOUNT_ID, + createExampleHostedAgentService, +} from '../01-hosted-service/example-agent.js'; +import { + HostedAgentApiError, + createHostedAgentApiRouter, +} from './http-api.js'; + +if (process.env.NODE_ENV === 'production') { + throw new Error('The hosted-agent demo authentication adapter refuses to run in production.'); +} + +const bearerToken = process.env.HEDDLE_EXAMPLE_BEARER_TOKEN; +if (!bearerToken || bearerToken.length < 16) { + throw new Error('Set HEDDLE_EXAMPLE_BEARER_TOKEN to an explicit secret of at least 16 characters.'); +} + +const port = parsePort(process.env.HEDDLE_EXAMPLE_PORT ?? '8787'); +const app = express(); +app.disable('x-powered-by'); +app.use(express.json({ limit: '32kb' })); +app.use('/api/agent', createHostedAgentApiRouter({ + agent: createExampleHostedAgentService(), + authenticate: async (request) => authenticateBearer(request, bearerToken), + onError: (error) => console.error('Hosted agent API error:', error), +})); + +const server = app.listen(port, '127.0.0.1', () => { + console.log(`Hosted agent API listening at http://127.0.0.1:${port}/api/agent.`); + console.log('Run `yarn example:sdk:browser-client "your prompt"` in another terminal.'); +}); + +const shutdown = () => server.close(); +process.once('SIGINT', shutdown); +process.once('SIGTERM', shutdown); + +function authenticateBearer(request: Request, expectedToken: string): { accountId: string } { + const authorization = request.header('authorization'); + const providedToken = authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : ''; + const provided = Buffer.from(providedToken); + const expected = Buffer.from(expectedToken); + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { + throw new HostedAgentApiError(401, 'unauthorized', 'A valid example bearer token is required.'); + } + return { accountId: EXAMPLE_ACCOUNT_ID }; +} + +function parsePort(value: string): number { + const port = Number(value); + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { + throw new Error('HEDDLE_EXAMPLE_PORT must be an integer between 1 and 65535.'); + } + return port; +} diff --git a/examples/sdk/05-hosted-agent/03-browser-client/README.md b/examples/sdk/05-hosted-agent/03-browser-client/README.md new file mode 100644 index 00000000..d6d623fa --- /dev/null +++ b/examples/sdk/05-hosted-agent/03-browser-client/README.md @@ -0,0 +1,24 @@ +# 03 Browser Client + +Follow this optional stage only when the browser consumes the exact REST/SSE +contract from [02 HTTP/SSE API](../02-http-sse-api/). See the +[full hosted-agent walkthrough](../README.md#03-browser-client-protocol-not-product-ui) +for the runnable reconnect/cancel flow. + +## Assumptions + +- The server exposes the stage-02 routes and public Zod contract. +- The browser can use streaming `fetch` for authenticated SSE. +- The product already has, or will choose, its own UI and state architecture. + +## Read in this order + +1. [`browser-client.ts`](browser-client.ts) — URL/auth/error/SSE parsing, schema + validation, cursor validation, and abort propagation. +2. [`run.ts`](run.ts) — application-owned reconnect, terminal-event, and cancel + policy without a UI framework. + +Keep messages, tool rendering, optimistic state, retry UX, notifications, and +product-specific results above this protocol client. A React application should +integrate it with its existing query/state layer rather than adding React state +to this folder. diff --git a/examples/sdk/05-hosted-agent/03-browser-client/browser-client.ts b/examples/sdk/05-hosted-agent/03-browser-client/browser-client.ts new file mode 100644 index 00000000..26af4cb3 --- /dev/null +++ b/examples/sdk/05-hosted-agent/03-browser-client/browser-client.ts @@ -0,0 +1,172 @@ +/** + * Stage 05.3 browser-compatible client for the example REST/SSE contract. + * + * This module owns protocol mechanics only. Product messages, tool rendering, + * reconnect UX, UI state, and terminal-result handling stay above it. + */ +import { + createParser, + type EventSourceMessage, +} from 'eventsource-parser'; +import type { ZodType } from 'zod'; +import { + CancelHostedAgentRunResultSchema, + HostedAgentApiErrorSchema, + HostedAgentRunEventSchema, + StartHostedAgentRunResultSchema, + type CancelHostedAgentRunResult, + type HostedAgentRunEvent, + type StartHostedAgentRunInput, + type StartHostedAgentRunResult, +} from '../02-http-sse-api/contracts.js'; + +type Fetch = typeof globalThis.fetch; + +export type HostedAgentClientOptions = { + baseUrl: string; + getHeaders?: () => HeadersInit | Promise; + fetch?: Fetch; +}; + +export class HostedAgentClientError extends Error { + constructor( + message: string, + readonly status?: number, + readonly code?: string, + ) { + super(message); + } +} + +export class HostedAgentClient { + private readonly baseUrl: string; + private readonly fetch: Fetch; + + constructor(private readonly options: HostedAgentClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ''); + this.fetch = options.fetch ?? globalThis.fetch; + } + + async start(input: StartHostedAgentRunInput, signal?: AbortSignal): Promise { + return await this.request('/runs', StartHostedAgentRunResultSchema, { + method: 'POST', + body: JSON.stringify(input), + signal, + }); + } + + async subscribe(input: { + runId: string; + afterSequence?: number; + signal?: AbortSignal; + onEvent(event: HostedAgentRunEvent): void | Promise; + }): Promise { + if (input.afterSequence !== undefined + && (!Number.isSafeInteger(input.afterSequence) || input.afterSequence < 0)) { + throw new HostedAgentClientError('afterSequence must be a non-negative safe integer.'); + } + + const query = input.afterSequence === undefined ? '' : `?after=${input.afterSequence}`; + const response = await this.fetch( + `${this.baseUrl}/runs/${encodeURIComponent(input.runId)}/events${query}`, + { + headers: await this.headers({ Accept: 'text/event-stream' }), + signal: input.signal, + }, + ); + if (!response.ok || !response.body) { + throw await this.responseError(response); + } + if (!response.headers.get('content-type')?.startsWith('text/event-stream')) { + throw new HostedAgentClientError('Hosted agent event response was not an SSE stream.', response.status); + } + + const pending: EventSourceMessage[] = []; + const parser = createParser({ + onEvent: (event) => pending.push(event), + onError: (error) => { + throw error; + }, + }); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const flushPending = async () => { + while (pending.length > 0) { + input.signal?.throwIfAborted(); + const message = pending.shift(); + if (message) { + await input.onEvent(parseEvent(message)); + } + } + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + parser.feed(decoder.decode()); + parser.reset({ consume: true }); + await flushPending(); + return; + } + parser.feed(decoder.decode(value, { stream: true })); + await flushPending(); + } + } finally { + reader.releaseLock(); + } + } + + async cancel(runId: string, signal?: AbortSignal): Promise { + return await this.request( + `/runs/${encodeURIComponent(runId)}/cancel`, + CancelHostedAgentRunResultSchema, + { method: 'POST', signal }, + ); + } + + private async request(path: string, schema: ZodType, init: RequestInit): Promise { + const headers = await this.headers(init.headers ?? {}); + headers.set('Content-Type', 'application/json'); + const response = await this.fetch(`${this.baseUrl}${path}`, { + ...init, + headers, + }); + if (!response.ok) { + throw await this.responseError(response); + } + return schema.parse(await response.json()); + } + + private async headers(additions: HeadersInit): Promise { + const headers = new Headers(await this.options.getHeaders?.()); + new Headers(additions).forEach((value, name) => headers.set(name, value)); + return headers; + } + + private async responseError(response: Response): Promise { + const body = await response.json().catch(() => undefined); + const parsed = HostedAgentApiErrorSchema.safeParse(body); + return parsed.success + ? new HostedAgentClientError(parsed.data.error.message, response.status, parsed.data.error.code) + : new HostedAgentClientError(`Hosted agent request failed (${response.status}).`, response.status); + } +} + +function parseEvent(message: EventSourceMessage): HostedAgentRunEvent { + let body: unknown; + try { + body = JSON.parse(message.data); + } catch { + throw new HostedAgentClientError('Hosted agent event contained invalid JSON.'); + } + + const event = HostedAgentRunEventSchema.parse(body); + if (message.id !== String(event.sequence)) { + throw new HostedAgentClientError('Hosted agent event ID did not match its canonical sequence.'); + } + if (message.event !== event.kind) { + throw new HostedAgentClientError('Hosted agent SSE event name did not match its payload kind.'); + } + return event; +} diff --git a/examples/sdk/05-hosted-agent/03-browser-client/run.ts b/examples/sdk/05-hosted-agent/03-browser-client/run.ts new file mode 100644 index 00000000..64160dfc --- /dev/null +++ b/examples/sdk/05-hosted-agent/03-browser-client/run.ts @@ -0,0 +1,133 @@ +/** + * Stage 05.3 runner: consume the stage-2 API with browser-compatible fetch/SSE. + * + * This is protocol code, not a UI framework. The product still owns messages, + * rendering, optimistic state, retry UX, and product-specific terminal data. + * Start the stage-2 server first, then run this script with the same token. + */ +import { setTimeout as delay } from 'node:timers/promises'; +import { + HostedAgentClient, + HostedAgentClientError, +} from './browser-client.js'; +import type { HostedAgentRunEvent } from '../02-http-sse-api/contracts.js'; + +const bearerToken = process.env.HEDDLE_EXAMPLE_BEARER_TOKEN; +if (!bearerToken) { + throw new Error('Set HEDDLE_EXAMPLE_BEARER_TOKEN to the secret used by the hosted API example.'); +} + +const cancelDemo = process.argv.includes('--cancel-demo'); +const prompt = process.argv + .slice(2) + .filter((argument) => argument !== '--cancel-demo') + .join(' ') + || 'Read the repository README and summarize the project in three sentences.'; +const baseUrl = process.env.HEDDLE_EXAMPLE_AGENT_URL ?? 'http://127.0.0.1:8787/api/agent'; +const sessionId = process.env.HEDDLE_EXAMPLE_SESSION_ID ?? 'hosted-agent-browser-example'; +const client = new HostedAgentClient({ + baseUrl, + getHeaders: () => ({ Authorization: `Bearer ${bearerToken}` }), +}); + +const accepted = await client.start({ sessionId, prompt }); +console.log(`Accepted ${accepted.runId}.`); +const cursor = await disconnectAfterFirstActivity(client, accepted.runId); +console.log(`Browser subscription disconnected at sequence ${cursor}; reconnecting.`); +const terminal = await consumeUntilTerminal(client, accepted.runId, cursor); +console.log(`Run settled as ${terminal.kind}.`); + +if (cancelDemo) { + const cancellation = await client.start({ + sessionId: `${sessionId}-cancel-${Date.now()}`, + prompt: 'Inspect this repository carefully and report its main subsystems.', + }); + const cancelled = await client.cancel(cancellation.runId); + console.log(`Cancellation accepted: ${cancelled.cancelled}.`); + const cancelledTerminal = await consumeUntilTerminal(client, cancellation.runId, 0); + if (cancelledTerminal.kind !== 'cancelled') { + throw new Error(`Expected a cancelled terminal, received ${cancelledTerminal.kind}.`); + } +} + +async function disconnectAfterFirstActivity(agent: HostedAgentClient, runId: string): Promise { + const subscription = new AbortController(); + let cursor = 0; + let disconnected = false; + try { + await agent.subscribe({ + runId, + signal: subscription.signal, + onEvent: (event) => { + cursor = event.sequence; + renderEvent(event); + if (event.kind === 'activity') { + disconnected = true; + subscription.abort(); + } + }, + }); + } catch (error) { + if (!subscription.signal.aborted) { + throw error; + } + } + if (!disconnected) { + throw new Error('The first subscription ended before receiving an activity.'); + } + return cursor; +} + +async function consumeUntilTerminal( + agent: HostedAgentClient, + runId: string, + initialCursor: number, +): Promise { + let cursor = initialCursor; + let terminal: HostedAgentRunEvent | undefined; + + for (let attempt = 0; attempt < 5 && !terminal; attempt += 1) { + try { + await agent.subscribe({ + runId, + afterSequence: cursor, + onEvent: (event) => { + cursor = Math.max(cursor, event.sequence); + renderEvent(event); + if (event.kind !== 'activity') { + terminal = event; + } + }, + }); + } catch (error) { + if (error instanceof HostedAgentClientError + && (error.status === undefined || error.status < 500)) { + throw error; + } + } + if (!terminal) { + await delay(Math.min(250 * (2 ** attempt), 2_000)); + } + } + + if (!terminal) { + throw new Error(`Run ${runId} did not reach a terminal event after bounded reconnects.`); + } + return terminal; +} + +function renderEvent(event: HostedAgentRunEvent): void { + if (event.kind === 'activity') { + console.log(`[${event.sequence}] activity ${event.activity.type}`); + return; + } + if (event.kind === 'result') { + console.log(`[${event.sequence}] result ${event.result.summary}`); + return; + } + if (event.kind === 'cancelled') { + console.log(`[${event.sequence}] cancelled ${event.reason}`); + return; + } + console.log(`[${event.sequence}] error ${event.error.message}`); +} diff --git a/examples/sdk/05-hosted-agent/README.md b/examples/sdk/05-hosted-agent/README.md new file mode 100644 index 00000000..32593795 --- /dev/null +++ b/examples/sdk/05-hosted-agent/README.md @@ -0,0 +1,261 @@ +# Hosted Agent Stack Example + +This is stage 05 of the [SDK example ladder](../README.md). It shows how a +TypeScript application can host a reconnectable conversational Heddle agent +without making Heddle depend on the application's server, transport, auth, or +UI stack. + +Read [Choose a Programmatic Integration Layer](../../../docs/guides/programmatic/integration-layers.md) +for the canonical ownership model. This README maps that model to runnable +files. + +## What this example assumes + +- The product wants Heddle to own conversation and run semantics. +- The host owns authenticated account identity and durable product session IDs. +- A single Node.js process can own active runs and bounded replay for this + example. +- The optional API uses Express, JSON requests, bearer authentication, and SSE. +- The optional client uses browser-compatible streaming `fetch`; it does not + assume React or another UI framework. + +Only the first two assumptions are fundamental to the hosted-service pattern. +Express, SSE, bearer auth, and the sample browser client are replaceable host +choices. + +When copying this example into another project, replace the relative +`src/index.js` imports with `@roackb2/heddle`. Stage 01 requires Heddle only; +stage 02 additionally uses `express`, its TypeScript types, and `zod`; stage 03 +uses `eventsource-parser` and the shared Zod wire contracts. Declare those +libraries directly in the host project instead of relying on transitive +dependencies. + +## Choose the layers that match your stack + +| Host stack | Follow | Skip or replace | +| --- | --- | --- | +| Node process, worker, Electron backend, or no remote client | `01-hosted-service` | Skip stages 02 and 03 | +| Existing tRPC, Fastify, Hono, Nest, WebSocket, or framework-specific server | `01-hosted-service`, then adapt stage 02's lifecycle rules | Replace the Express router and wire schemas | +| Express + REST/SSE server | `01-hosted-service` → `02-http-sse-api` | Replace only demo auth, storage, and deployment policy | +| Browser using this exact REST/SSE protocol | All three stages | Add product UI state and rendering above stage 03 | +| Multi-process or serverless deployment | Use stage 01 as the application boundary | Replace process-local replay/routing with host-chosen shared infrastructure | + +## Read the files in this order + +```text +05-hosted-agent/ + 01-hosted-service/ + agent-service.ts host scope + engine/session/run lifecycle + example-agent.ts runnable composition root and demo policy + run.ts in-process disconnect/replay/cancel demo + | + v + 02-http-sse-api/ + contracts.ts untrusted public wire schemas + http-api.ts Express start/subscribe/cancel + SSE adapter + server.ts runnable local server and demo authentication + | + v + 03-browser-client/ + browser-client.ts typed fetch/SSE protocol adapter + run.ts reconnect, cursor, and terminal-policy demo +``` + +The dependency direction is intentional: + +- stage 01 imports Heddle and has no HTTP or browser dependency; +- stage 02 imports stage 01 and translates it into one chosen wire protocol; +- stage 03 imports stage 02's public contract and knows nothing about Heddle + internals. + +Do not reverse these imports when adapting the example. A transport-neutral +agent service should never read an HTTP request, and Heddle core should never +know which client framework renders the result. + +## 01 Hosted service: Heddle engine, host lifecycle + +Run it without a server: + +```bash +yarn example:sdk:hosted-agent "What does this repository do?" +``` + +The runner starts a real Heddle turn, consumes one activity, disconnects the +subscriber, then reconnects with `afterSequence` and receives the remainder +without restarting the turn. Add `--cancel-demo` to start and cancel a second +run through the same public run handle. + +### What it showcases + +[`agent-service.ts`](01-hosted-service/agent-service.ts) is application code +that composes Heddle's `ConversationRunService`; it is not another Heddle core +service or a required SDK wrapper. It demonstrates how a host can: + +- keep one run service alive for the host process; +- scope run addresses by authenticated account and durable session ID; +- inject engine and host construction so credentials, storage, approvals, and + telemetry stay application-owned; +- reuse `engine.sessions.readExisting(...)` before creating a conversation; +- return an accepted run ID immediately, then subscribe or cancel separately; +- use Heddle's canonical ordered replay instead of adding a second event bus. + +### Responsibility boundary + +Heddle owns persisted messages, compaction, model/tool execution, traces, +artifacts, run identity, ordered activities, bounded replay, and cancellation. +The host owns account-to-scope mapping, durable session IDs, engine +configuration, injected repositories, approval decisions, and process +lifecycle. + +[`example-agent.ts`](01-hosted-service/example-agent.ts) is the local +composition root. It chooses a model, filesystem state root, inspect-only tool +profile, and deny-by-default approval callback. In a production host, replace +those choices with authenticated credential resolution, product tools, +production repositories, approval policy/UI, and telemetry. + +### How to adapt it + +- Keep `HostedAgentService` in the host's application/service layer. +- Replace `accountId` with the host's authenticated tenant or user scope; never + trust a client-supplied account ID. +- Use a stable product conversation ID for `sessionId` so later turns reuse the + same Heddle conversation. +- Inject database-backed `ChatSessionRepository` and `ArtifactRepository` + implementations when local files are not suitable. +- Keep one active-run owner for each address. Decide explicitly how requests + route to that owner in a multi-process deployment. + +## 02 HTTP/SSE API: host-owned transport + +Start the server with an explicit non-production demo secret: + +```bash +HEDDLE_EXAMPLE_BEARER_TOKEN=local-example-secret \ + yarn example:sdk:hosted-api +``` + +The sample contract is: + +```text +POST /api/agent/runs +GET /api/agent/runs/:runId/events?after= +POST /api/agent/runs/:runId/cancel +``` + +### What it showcases + +[`contracts.ts`](02-http-sse-api/contracts.ts) validates untrusted wire data and +defines the public browser contract. [`http-api.ts`](02-http-sse-api/http-api.ts) +adapts HTTP operations to the transport-neutral service: + +- start returns `202 Accepted` after Heddle assigns a stable run identity; +- subscribe uses each canonical run sequence as the SSE `id`; +- reconnect accepts either `after` or `Last-Event-ID`, with the explicit query + taking precedence; +- response backpressure is respected; +- closing an SSE connection aborts only that subscription, not the run; +- cancel is a separate, authenticated operation. + +The API deliberately projects terminal results to public `outcome` and +`summary` fields. Trace paths, artifacts, tool results, and internal session +state are not serialized to the browser. Extend the public Zod schema with only +the product data the client is authorized to receive. + +### Responsibility boundary + +Heddle still owns the conversation and active run. The host owns authentication, +authorization, request limits, public schemas, HTTP status/error policy, CORS, +rate limiting, audit, deployment, and transport observability. + +[`server.ts`](02-http-sse-api/server.ts) deliberately refuses +`NODE_ENV=production`; its fixed demo token is not a production auth design. +Replace the injected `authenticate` callback with the host's session or JWT +verification and derive `accountId` from that verified principal. + +### Other server stacks + +For tRPC, expose start/cancel as mutations and run events as a subscription or +stream while preserving `runId`, sequence, terminal events, explicit cancel, +and subscriber-only disconnect semantics. For Fastify, Hono, Nest, WebSocket, +or another stack, apply the same lifecycle contract in that framework rather +than wrapping this Express router. + +Start a run manually: + +```bash +curl -i http://127.0.0.1:8787/api/agent/runs \ + -H 'authorization: Bearer local-example-secret' \ + -H 'content-type: application/json' \ + -d '{"sessionId":"curl-example","prompt":"Summarize this repository."}' +``` + +Use the returned `runId` to subscribe, resume, or cancel: + +```bash +curl -N http://127.0.0.1:8787/api/agent/runs/RUN_ID/events?after=0 \ + -H 'authorization: Bearer local-example-secret' + +curl -X POST http://127.0.0.1:8787/api/agent/runs/RUN_ID/cancel \ + -H 'authorization: Bearer local-example-secret' +``` + +## 03 Browser client: protocol, not product UI + +With the API running, use the same token in another terminal: + +```bash +HEDDLE_EXAMPLE_BEARER_TOKEN=local-example-secret \ + yarn example:sdk:browser-client "Explain the main architecture." +``` + +### What it showcases + +[`browser-client.ts`](03-browser-client/browser-client.ts) owns URL +construction, authentication headers, HTTP error decoding, incremental SSE +parsing with `eventsource-parser`, Zod validation, event-ID validation, and +abort propagation. + +[`run.ts`](03-browser-client/run.ts) is the consuming application policy. It +retains the greatest received sequence, deliberately disconnects after one +activity, reconnects with bounded exponential backoff, and stops on a +result/cancel/error terminal. Add `--cancel-demo` to exercise explicit +cancellation. + +### Responsibility boundary + +The protocol client does not own chat messages, tool-call presentation, React +state, optimistic updates, retry UI, notifications, or application result +handling. Put those concerns in the product's existing client/application +layer. If the host uses React Query, a state machine, or another mature client +library, wrap `start`, `subscribe`, and `cancel` there instead of adding UI state +to this transport client. + +Bearer-auth browser clients use streaming `fetch` because native `EventSource` +cannot attach an `Authorization` header. Cookie-auth applications can use +`EventSource` and let the browser send `Last-Event-ID` on reconnect. + +## Production adaptation checklist + +A coding agent adapting this example should report how the host handles each +item before calling the integration production-ready: + +- authenticated scope and authorization for start, subscribe, and cancel; +- stable product conversation IDs and tenant isolation; +- model credential resolution and product-owned tools/system context; +- approval policy and user-facing approval resolution; +- durable session and artifact repositories; +- request validation, payload limits, CORS, rate limiting, and audit; +- reconnect cursor persistence and duplicate-safe client event handling; +- process ownership, draining, and cross-process run routing; +- public terminal-result projection and sensitive-data review; +- traces, metrics, failure reporting, and cancellation verification. + +## Deliberate limits + +- Replay and active run handles are process-local and bounded. Durable final + conversation state remains in the conversation repository. Multi-process + delivery needs shared infrastructure chosen by the host. +- The example does not prescribe deployment, database, identity provider, + product state, UI framework, or tRPC/REST/WebSocket choice. +- The explicit service/API/client code is evidence for future SDK utilities, + not a promise that Heddle should hide every boundary behind one preset. diff --git a/examples/sdk/README.md b/examples/sdk/README.md index a86fad13..4bbeb504 100644 --- a/examples/sdk/README.md +++ b/examples/sdk/README.md @@ -1,43 +1,115 @@ # SDK Examples -These examples climb the programmatic ladder, from the smallest interactive host -toward more customized Heddle SDK usage. See the -[programmatic guide index](../../docs/guides/programmatic/README.md) for the -matching docs. +These numbered examples form a progressive path from a working local chat to a +hosted, reconnectable conversational agent. Follow them in order when learning +Heddle, or use the chooser below when your product already owns part of the +stack. -## 01 Interactive Chat (rung 1: start here) +Read [Choose a Programmatic Integration Layer](../../docs/guides/programmatic/integration-layers.md) +before adapting the examples into a product. It defines which behavior belongs +to Heddle and which behavior must stay in the host. + +## Prerequisites + +- Node.js 20 or newer and a TypeScript/ESM project. +- A configured model credential supported by Heddle. +- In this repository, run `yarn install` before the example scripts. +- In another project, install `@roackb2/heddle` and only the libraries used by + the host stack you choose. + +The SDK assumes that you want to build a conversational agent in TypeScript. It +does **not** assume Express, tRPC, HTTP, SSE, WebSocket, React, a deployment +platform, an identity provider, or a database. + +## Choose where to start + +| Your current host | Start with | New host responsibility | +| --- | --- | --- | +| No agent loop or UI yet | [`01-interactive-chat.ts`](01-interactive-chat.ts) | Configuration and prompts only | +| A local chat that needs product capabilities | [`02-add-a-tool.ts`](02-add-a-tool.ts) or [`03-add-an-mcp-server.ts`](03-add-an-mcp-server.ts) | Tool implementation or MCP server selection | +| A process that already owns output/rendering | [`04-custom-output.ts`](04-custom-output.ts) | Output sink and presentation policy | +| A server, worker, Electron backend, or custom transport | [`05-hosted-agent/01-hosted-service`](05-hosted-agent/01-hosted-service) | Identity scope, durable session IDs, engine composition, and process lifetime | +| An Express server using REST + SSE | [`05-hosted-agent/02-http-sse-api`](05-hosted-agent/02-http-sse-api) | Authentication, API policy, HTTP operations, and deployment | +| A browser consuming that REST + SSE contract | [`05-hosted-agent/03-browser-client`](05-hosted-agent/03-browser-client) | UI state, rendering, retry UX, and product result handling | + +If your server already uses tRPC, Fastify, Hono, Nest, WebSocket, or another +transport, follow the hosted-service stage and write an adapter for that stack. +Do not introduce Express merely to copy the example. + +## Follow the customization ladder + +### 01 Interactive Chat — working conversation ```bash yarn example:sdk:interactive ``` -A persisted conversation loop with default text output and local commands. The -recommended first file to copy when trying Heddle as a framework. +**Assumption:** Heddle can own the local prompt loop, persisted session, and +plain-text output. This is the smallest file to copy for an SDK evaluation. -## 02 Add a Tool (rung 2: add capabilities) +### 02 Add a Tool — native host capability ```bash yarn example:sdk:add-tool ``` -Defines one `ToolDefinition` and hands it to the runner. This is the smallest -way to give the agent a capability of your own. +**Assumption:** the host has a capability that should be model-visible. The host +owns the tool's domain behavior; Heddle owns tool registration, invocation, +approval integration, and trace visibility. -## 03 Add an MCP Server (rung 2: add capabilities) +### 03 Add an MCP Server — external capabilities ```bash yarn example:sdk:add-mcp ``` -Uses `prepareMcpHostExtension` to expose an MCP server's tools as curated Heddle -tools. Edit the server `command`/`args` to point at a real MCP server. +**Assumption:** the capability already exists behind an MCP server. The host +selects and configures the server; Heddle prepares its tools as a curated host +extension. -## 04 Custom Output (rung 3: shape input/output) +Examples 02 and 03 are sibling choices. Use either or both before moving on. + +### 04 Custom Output — host-owned presentation ```bash yarn example:sdk:custom-output ``` -Drives `createConversationEngine` directly and sends streaming text to a custom -destination via `createConversationTextHost`. Replace the `output` sink with your -own transport (web, chat, log collector). +**Assumption:** Heddle should still own conversation semantics, while the host +owns where streamed text and status are rendered. Replace the output sink with +a terminal writer, webhook, log collector, or local application surface. + +### 05 Hosted Agent — host-owned lifecycle and optional transport + +```bash +yarn example:sdk:hosted-agent "What does this repository do?" +``` + +This directory contains its own numbered path: + +1. `01-hosted-service` — transport-neutral account/session/run lifecycle. +2. `02-http-sse-api` — optional Express + REST/SSE adapter. +3. `03-browser-client` — optional framework-neutral browser protocol client. + +Read the [hosted-agent walkthrough](05-hosted-agent/README.md) before copying +this stage. Each later folder depends only on the earlier layer it extends, so +a coding agent can replace the host-specific layer without moving transport or +UI concerns into Heddle's conversation core. + +## Guidance for coding agents + +When adapting these examples: + +1. Identify the existing server framework, transport, authentication model, UI + framework, persistence, and deployment topology. +2. Choose the lowest numbered example that already satisfies the product need. +3. Copy only the layers whose assumptions match the host. +4. Preserve dependency direction: UI → transport → application service → + Heddle run service → Heddle conversation engine. +5. Keep product identity, authorization, public API schemas, UI state, and + cross-process delivery in the host. +6. Keep conversation history, turn execution, compaction, tool execution, + traces, artifacts, ordered run events, replay, and cancellation in Heddle. + +The [programmatic guide index](../../docs/guides/programmatic/README.md) has the +API-level documentation that corresponds to each example. diff --git a/package.json b/package.json index 415cd189..b5d688c7 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,9 @@ "example:sdk:add-tool": "tsx --no-cache examples/sdk/02-add-a-tool.ts", "example:sdk:add-mcp": "tsx --no-cache examples/sdk/03-add-an-mcp-server.ts", "example:sdk:custom-output": "tsx --no-cache examples/sdk/04-custom-output.ts", + "example:sdk:hosted-agent": "tsx --no-cache examples/sdk/05-hosted-agent/01-hosted-service/run.ts", + "example:sdk:hosted-api": "tsx --no-cache examples/sdk/05-hosted-agent/02-http-sse-api/server.ts", + "example:sdk:browser-client": "tsx --no-cache examples/sdk/05-hosted-agent/03-browser-client/run.ts", "example:conversation-engine": "tsx --no-cache examples/conversation-engine.ts", "example:programmatic": "tsx --no-cache examples/programmatic-loop.ts", "example:heartbeat": "tsx --no-cache examples/heartbeat.ts", @@ -244,6 +247,7 @@ "eslint": "^10.2.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", + "eventsource-parser": "^3.1.0", "globals": "^17.5.0", "jsdom": "^29.0.2", "react-dom": "^19.2.5", diff --git a/src/__tests__/integration/sdk/hosted-agent-stack.test.ts b/src/__tests__/integration/sdk/hosted-agent-stack.test.ts new file mode 100644 index 00000000..0bd0de30 --- /dev/null +++ b/src/__tests__/integration/sdk/hosted-agent-stack.test.ts @@ -0,0 +1,324 @@ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import express from 'express'; +import { describe, expect, it } from 'vitest'; +import type { + ConversationActivity, + ConversationEngine, + ConversationTurnResultSummary, + CreateConversationSessionInput, + SubmitConversationTurnInput, +} from '../../../index.js'; +import { + HostedAgentRunNotFoundError, + HostedAgentService, +} from '../../../../examples/sdk/05-hosted-agent/01-hosted-service/agent-service.js'; +import { + HostedAgentClient, +} from '../../../../examples/sdk/05-hosted-agent/03-browser-client/browser-client.js'; +import type { + HostedAgentRunEvent, +} from '../../../../examples/sdk/05-hosted-agent/02-http-sse-api/contracts.js'; +import { + HostedAgentApiError, + createHostedAgentApiRouter, +} from '../../../../examples/sdk/05-hosted-agent/02-http-sse-api/http-api.js'; + +describe('hosted agent SDK stack example', () => { + it('reuses a durable session and replays from the subscriber cursor', async () => { + const harness = createEngineHarness(); + const agent = new HostedAgentService({ createEngine: harness.createEngine }); + const accepted = await agent.start({ + accountId: 'account-a', + sessionId: 'conversation-a', + prompt: 'First prompt', + }); + + const first = await firstEvent(agent.subscribe({ + accountId: 'account-a', + runId: accepted.runId, + })); + expect(first).toMatchObject({ kind: 'activity', sequence: 1 }); + expect(() => agent.subscribe({ + accountId: 'account-b', + runId: accepted.runId, + })).toThrow(HostedAgentRunNotFoundError); + + (await harness.waitForTurn(0)).complete(); + const replay = await collect(agent.subscribe({ + accountId: 'account-a', + runId: accepted.runId, + afterSequence: first.sequence, + })); + expect(replay).toMatchObject([{ kind: 'result', sequence: 2 }]); + + const second = await agent.start({ + accountId: 'account-a', + sessionId: 'conversation-a', + prompt: 'Second prompt', + }); + (await harness.waitForTurn(1)).complete(); + await collect(agent.subscribe({ accountId: 'account-a', runId: second.runId })); + expect(harness.createdSessionIds).toEqual(['conversation-a']); + }); + + it('cancels the Heddle run through the owned service handle', async () => { + const harness = createEngineHarness(); + const agent = new HostedAgentService({ createEngine: harness.createEngine }); + const accepted = await agent.start({ + accountId: 'account-a', + sessionId: 'cancelled-conversation', + prompt: 'Keep working until cancelled', + }); + const events = collect(agent.subscribe({ accountId: 'account-a', runId: accepted.runId })); + await harness.waitForTurn(0); + + expect(agent.cancel('account-a', accepted.runId)).toBe(true); + await expect(events).resolves.toMatchObject([ + { kind: 'activity', sequence: 1 }, + { kind: 'cancelled', sequence: 2 }, + ]); + }); + + it('serves authenticated SSE replay to the framework-neutral browser client', async () => { + const harness = createEngineHarness(); + const agent = new HostedAgentService({ createEngine: harness.createEngine }); + const api = await startApi(agent); + + try { + const client = createClient(api.baseUrl, 'token-a'); + const otherAccountClient = createClient(api.baseUrl, 'token-b'); + const accepted = await client.start({ + sessionId: 'browser-conversation', + prompt: 'Stream this turn', + }); + const subscription = new AbortController(); + let cursor = 0; + + const disconnected = client.subscribe({ + runId: accepted.runId, + signal: subscription.signal, + onEvent: (event) => { + cursor = event.sequence; + subscription.abort(); + }, + }); + await expect(disconnected).rejects.toMatchObject({ name: 'AbortError' }); + const turn = await harness.waitForTurn(0); + expect(turn.signal?.aborted).toBe(false); + + turn.complete(); + const replay: HostedAgentRunEvent[] = []; + await client.subscribe({ + runId: accepted.runId, + afterSequence: cursor, + onEvent: (event) => replay.push(event), + }); + expect(replay).toEqual([ + expect.objectContaining({ + kind: 'result', + sequence: 2, + result: { + outcome: 'done', + summary: 'Completed: Stream this turn', + }, + }), + ]); + + await expect(otherAccountClient.subscribe({ + runId: accepted.runId, + onEvent: () => undefined, + })).rejects.toMatchObject({ + status: 404, + code: 'run_not_found', + }); + + const malformedCursor = await fetch(`${api.baseUrl}/runs/${accepted.runId}/events?after=bad`, { + headers: { Authorization: 'Bearer token-a' }, + }); + expect(malformedCursor.status).toBe(400); + expect(malformedCursor.headers.get('content-type')).toContain('application/json'); + + const headerResume = await fetch(`${api.baseUrl}/runs/${accepted.runId}/events`, { + headers: { + Authorization: 'Bearer token-a', + 'Last-Event-ID': '1', + }, + }); + expect(await headerResume.text()).toContain('id: 2\n'); + + const queryWins = await fetch(`${api.baseUrl}/runs/${accepted.runId}/events?after=0`, { + headers: { + Authorization: 'Bearer token-a', + 'Last-Event-ID': '1', + }, + }); + expect(await queryWins.text()).toContain('id: 1\n'); + + const cancellation = await client.start({ + sessionId: 'browser-cancellation', + prompt: 'Wait for cancellation', + }); + await harness.waitForTurn(1); + await expect(client.cancel(cancellation.runId)).resolves.toEqual({ cancelled: true }); + const cancellationEvents: HostedAgentRunEvent[] = []; + await client.subscribe({ + runId: cancellation.runId, + onEvent: (event) => cancellationEvents.push(event), + }); + expect(cancellationEvents.at(-1)).toMatchObject({ kind: 'cancelled' }); + expect(api.errors).toEqual([]); + } finally { + await closeServer(api.server); + } + }); +}); + +function createEngineHarness() { + const sessions = new Map(); + const turns: ControlledTurn[] = []; + const createdSessionIds: string[] = []; + + return { + createdSessionIds, + turns, + createEngine: (): ConversationEngine => ({ + sessions: { + readExisting: (id: string) => sessions.get(id), + create: (input: CreateConversationSessionInput = {}) => { + const session = { id: input.id ?? `session-${sessions.size + 1}` }; + sessions.set(session.id, session); + createdSessionIds.push(session.id); + return session; + }, + }, + turns: { + submit: async (input: SubmitConversationTurnInput) => { + const completion = deferred(); + const turn: ControlledTurn = { + signal: input.abortSignal, + complete: () => completion.resolve(), + }; + turns.push(turn); + input.host?.events?.onActivity?.({ + type: 'assistant.stream', + step: 1, + text: 'Working', + done: false, + timestamp: new Date().toISOString(), + } as ConversationActivity); + await waitForCompletion(completion.promise, input.abortSignal); + return createTurnResult(input.sessionId, input.prompt); + }, + }, + artifacts: {}, + } as unknown as ConversationEngine), + async waitForTurn(index: number): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + const turn = turns[index]; + if (turn) { + return turn; + } + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error(`Timed out waiting for controlled turn ${index}.`); + }, + }; +} + +type ControlledTurn = { + signal?: AbortSignal; + complete(): void; +}; + +function createTurnResult(sessionId: string, prompt: string): ConversationTurnResultSummary { + return { + outcome: 'done', + summary: `Completed: ${prompt}`, + session: { id: sessionId } as ConversationTurnResultSummary['session'], + artifacts: [], + toolResults: [], + }; +} + +async function waitForCompletion(completion: Promise, signal?: AbortSignal): Promise { + if (!signal) { + await completion; + return; + } + signal.throwIfAborted(); + await new Promise((resolve, reject) => { + const onAbort = () => reject(new Error('Controlled turn aborted.')); + signal.addEventListener('abort', onAbort, { once: true }); + completion.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); + }); +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +async function firstEvent(events: AsyncIterable): Promise { + for await (const event of events) { + return event; + } + throw new Error('Expected at least one run event.'); +} + +async function collect(events: AsyncIterable): Promise { + const values: T[] = []; + for await (const event of events) { + values.push(event); + } + return values; +} + +async function startApi(agent: HostedAgentService): Promise<{ + baseUrl: string; + server: Server; + errors: unknown[]; +}> { + const errors: unknown[] = []; + const app = express(); + app.use(express.json()); + app.use('/api/agent', createHostedAgentApiRouter({ + agent, + authenticate: async (request) => { + const token = request.header('authorization')?.replace(/^Bearer /, ''); + const accountId = new Map([ + ['token-a', 'account-a'], + ['token-b', 'account-b'], + ]).get(token ?? ''); + if (!accountId) { + throw new HostedAgentApiError(401, 'unauthorized', 'Invalid test token.'); + } + return { accountId }; + }, + onError: (error) => errors.push(error), + })); + const server = createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address() as AddressInfo; + return { + baseUrl: `http://127.0.0.1:${address.port}/api/agent`, + server, + errors, + }; +} + +function createClient(baseUrl: string, token: string): HostedAgentClient { + return new HostedAgentClient({ + baseUrl, + getHeaders: () => ({ Authorization: `Bearer ${token}` }), + }); +} + +async function closeServer(server: Server): Promise { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +} diff --git a/yarn.lock b/yarn.lock index 5e557a20..7a6ca01f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3000,6 +3000,11 @@ eventsource-parser@^3.0.0, eventsource-parser@^3.0.1: resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz#292e165e34cacbc936c3c92719ef326d4aeb4e90" integrity sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg== +eventsource-parser@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.1.0.tgz#4e198eb91cd333d0a8ddcc036502b3618a25f449" + integrity sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg== + eventsource@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-4.1.0.tgz#19e552341e4375289121fe7e1c9d401e5b1f7e8b"