diff --git a/.changeset/ignitetools-view-in-observation.md b/.changeset/ignitetools-view-in-observation.md new file mode 100644 index 00000000..19fb6995 --- /dev/null +++ b/.changeset/ignitetools-view-in-observation.md @@ -0,0 +1,10 @@ +--- +"@ignite-element/core": minor +"@ignite-element/adapters": minor +"@ignite-element/renderer": minor +"ignite-element": minor +--- + +igniteTools: surface the derived **view** in `ToolObservation` so an agent grounds on the read-model, not just the raw snapshot. + +`run()`'s observation is now `{ snapshot, view, events }` (was `{ snapshot, events }`). `igniteTools` binds `getView` — added to the `IgniteToolsRuntime` surface alongside `getSchema`/`execute` — and captures it at command-acknowledgement, so every observation, and thus every provider `tool_result` a dialect serializes, carries the view (the derived read-model, e.g. `lightsOn`/`allDoorsLocked`) the design says agents should ground on, distinct from the raw snapshot. `ToolObservation` gains a `View` type parameter (`ToolObservation`) and `NeutralToolResult` threads it through. Breaking to the pre-stable beta igniteTools surface (the observation shape + the `IgniteToolsRuntime` pick); the Anthropic dialect needs no change (it serializes the whole observation). Found while dogfooding the headless smart-home agent example. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e2f0f1c..ebadb4e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,7 +88,7 @@ jobs: exit 1 fi failed="" - for dir in examples/adapters/* examples/apps/* examples/frameworks/*; do + for dir in examples/adapters/* examples/apps/* examples/frameworks/* examples/agents/*; do [ -f "$dir/package.json" ] || continue echo "::group::$dir" ( cd "$dir" && pnpm install --ignore-workspace --no-link-workspace-packages ) diff --git a/docs/ignite-tools.md b/docs/ignite-tools.md index 78b2500b..3d66f408 100644 --- a/docs/ignite-tools.md +++ b/docs/ignite-tools.md @@ -112,17 +112,21 @@ two helpers; the OpenAI/Ollama dialect reuses them verbatim. ### Imperative shell -- `run(toolCall): Promise>` — the single - side-effect: `runtime.execute(name, payload)` (which may reach a remote actor). Returns - a `Result` so a failed command is data the agent reacts to, not an exception across the - seam. The LLM API call itself stays in the **consumer's** loop — `igniteTools` provides - the (provider-shaped) `tools` + `run`; the consumer runs the model. +- `run(toolCall): Promise>` — the single + side-effect: `runtime.execute(name, payload)` (which may reach a remote actor). The + observation carries the raw `snapshot`, the derived **`view`** (the read-model the + agent grounds on — `igniteTools` binds `getView` and captures it post-command), and + the `events` from the command window. Returns a `Result` so a failed command is data + the agent reacts to, not an exception across the seam. The LLM API call itself stays + in the **consumer's** loop — `igniteTools` provides the (provider-shaped) `tools` + + `run`; the consumer runs the model. ### Observation contract — act + acknowledgement `run` (and the underlying `execute`) is **act + ACK observation**: the returned -`ToolObservation` is the snapshot **at command-acknowledgement** plus the events -emitted up to that point — not "after the effect settles". The actor model has no +`ToolObservation` (`{ snapshot, view, events }`) is the snapshot + derived view +**at command-acknowledgement** plus the events emitted up to that point — not +"after the effect settles". The actor model has no bounded "done" for a long-running effect (a deploy spans minutes and many states), and a settle-wait would misattribute unrelated concurrent read-model updates. So for async/remote adapters the observation reflects **state at acknowledgement**; @@ -131,7 +135,9 @@ ongoing effects are observed via the **view/event stream** (`on()` / `watchView( as state and transport change. A first-class `observe()` channel on `igniteTools` (so act and observe come from one place) is a separate neutral-core task, sequenced with the dogfood; a bounded `settle` opt-in on `execute()` is deferred (YAGNI until -the dogfood shows short-command latency hurts). `ToolObservation` is unchanged. +the dogfood shows short-command latency hurts). `ToolObservation` carries +`{ snapshot, view, events }` — the derived view is captured at acknowledgement so +the agent grounds on the read-model, not just raw state. ### API shape diff --git a/examples/agents/smart-home/GAPS.md b/examples/agents/smart-home/GAPS.md new file mode 100644 index 00000000..bc434c5e --- /dev/null +++ b/examples/agents/smart-home/GAPS.md @@ -0,0 +1,60 @@ +# igniteTools gaps — found dogfooding the headless smart-home agent (Phase B) + +Building a real agent loop against `getSchema()` / `execute()` / `igniteTools` + +the Anthropic adapter surfaced these. Ordered by impact. Each is a candidate +follow-up; none blocks the example (the loop works headless today). + +## 1. ✅ FIXED — the tool result now carries the derived view, not just the snapshot + +**Was:** `ToolObservation = { snapshot, events }` carried only `getSnapshot()` (raw +machine context), so the model never saw the **view** (the derived read-model: +`lightsOn`, `allDoorsLocked`, `activeScene`) the design says agents should ground +on — a consumer had to inject `getView()` out-of-band. + +**Fixed in this PR:** `ToolObservation` is now `{ snapshot, view, events }`. +`igniteTools` binds `getView` (added to the `IgniteToolsRuntime` surface) and +captures it post-command, so every `run()` observation — and thus every +`tool_result` the adapter serializes — carries the view. The agent grounds on the +read-model out of the box. (See `result.trace[*].view` in the scripted test.) + +## 2. No availability gating (`canExecute`) + +The manifest offers **every** command regardless of state — `unlockDoor` is +offered while the `away` scene is armed, `runScene` while a scene is already +active. A smart home wants state-dependent availability ("don't offer unlock +while armed"). `igniteTools` already composes with `canExecute` *if present*, but +the runtime doesn't implement it yet. → tracked: `canExecute` task +(`task-1781798486122`). Until then, gating must live inside command logic as an +`ExecuteFailed`/validation, not as manifest availability. + +## 3. No `observe()` channel — events seen only during the command window + +The agent sees `result.value.events` emitted **during** `run()`, but there is no +channel to observe events/view **between** acts. The loop can't react to anything +that happens outside a command window. A first-class `observe()` on igniteTools +(events + view stream) would close the act → observe → act loop. → tracked: +observe-channel task (`task-1782332528771`). + +## 4. Async / long-running effects (act+ack vs settle) are untested here + +Scenes in this example apply **synchronously**, so `run()`'s acknowledgement +snapshot already reflects the full effect. The interesting contract case — `run()` +returns at acknowledgement while the effect settles over time — needs a genuinely +async scene (real-time transition) or a remote actor. Phase C (terminal↔browser +over a transport) is the natural place to exercise it; it will show whether a +bounded `settle` opt-in on `execute()` is warranted (currently deferred). + +## 5. Scalar `value`-wrapping costs LLM legibility (known Option D trade-off) + +A single-arg command (`lockDoor(door)`) is presented to the model as +`{ value: "front" }`, not `{ door: "front" }`. This is correct and collision-free +(Option D), but the generic `value` key is less self-documenting than the real +parameter name — the model has slightly less signal about what it's filling in. +Not a bug; worth weighing for prompt legibility (e.g., an optional param-name +hint in the description, or a future per-command label). + +## 6. Array inputs not yet exercised (coverage) + +The command set covers object / scalar-enum / no-arg inputs. An array-input +command (e.g. `dimRooms(rooms: Room[])`) would round out manifest + adapter +coverage for the array JSON-Schema shape and its scalar `value`-wrap. diff --git a/examples/agents/smart-home/README.md b/examples/agents/smart-home/README.md new file mode 100644 index 00000000..c4b102b1 --- /dev/null +++ b/examples/agents/smart-home/README.md @@ -0,0 +1,56 @@ +# Headless smart-home agent (igniteTools + Anthropic) + +A virtual smart home (lights, thermostat, blinds, door locks, scenes) built as an +ordinary `ignite-element` component and **driven by Claude** through +`igniteTools` + the `ignite-element/tools/anthropic` adapter — running **fully +headless in Node, with no DOM and no jsdom**. + +It's the agent analog of the other examples: instead of a person clicking a UI, +an LLM reads the component's `getSchema()`, calls its commands as tools, and +observes the result — the same `getSchema()` / `execute()` contract, no UI layer. + +## Run it + +```bash +# key-free, deterministic — a scripted "model" drives the home (no API key) +npm run mock + +# the real loop — Claude drives the home +npm install @anthropic-ai/sdk +ANTHROPIC_API_KEY=sk-... npm run anthropic -- "it's movie night" + +# the always-on assertions (this is what proves it runs headless) +npm test +``` + +## The loop + +``` +getSchema() → anthropic.tools(manifest) → [ model ] → tool_use + ▲ │ + └── tool_result ← anthropic.toolResult ← run() ← toolCalls() +``` + +`igniteTools(home, anthropic)` returns `{ tools, toolCalls, run, toolResult }`. +The consumer brings the model (the `Model` seam in `src/model.ts`: a scripted +mock or the real `@anthropic-ai/sdk`) and runs the loop in `src/agentLoop.ts`. + +## What it exercises + +- **DOM-free runtime** — the whole thing runs in the Vitest `node` environment + (see `vite.config.ts`); `getSchema`/`execute`/`on`/`watchView` need no DOM. +- **Varied command schemas** — object (`toggleLight`, `setThermostat`, + `setBlinds`), scalar enum (`lockDoor`, `unlockDoor`, `runScene`), and no-arg + (`status`) — all translated to Anthropic tool defs. +- **Option D scalar round-trip** — a single-arg command (`lockDoor(door)`) is + object-wrapped as `{ value }` for the model and unwrapped on the way back. +- **Errors as values** — an out-of-range input comes back as an `InvalidInput` + `tool_result` (never a throw), so the model can recover. +- **Events as observations** — `runScene` emits `scene-applied`, captured in the + command window. + +## Gaps found + +Dogfooding this surfaced several real gaps (view-vs-snapshot grounding, +`canExecute` availability gating, an `observe()` channel, async/settle). See +[`GAPS.md`](./GAPS.md). diff --git a/examples/agents/smart-home/package.json b/examples/agents/smart-home/package.json new file mode 100644 index 00000000..61db1709 --- /dev/null +++ b/examples/agents/smart-home/package.json @@ -0,0 +1,32 @@ +{ + "name": "smart-home-agent-example", + "version": "1.0.0", + "description": "Drive a headless ignite-element smart home with Claude via igniteTools + the anthropic ToolDialect — a key-free scripted run plus a real Anthropic loop, all in pure Node (no DOM).", + "type": "module", + "scripts": { + "test": "vitest run", + "mock": "vite-node src/mock.ts", + "anthropic": "vite-node src/anthropic.ts", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "ignite-element", + "igniteTools", + "anthropic", + "tool-use", + "agent", + "headless", + "smart-home", + "xstate" + ], + "license": "ISC", + "dependencies": { + "xstate": "5.32.1" + }, + "devDependencies": { + "typescript": "^5.9.3", + "vite": "^7.2.7", + "vite-node": "^6.0.0", + "vitest": "^4.0.3" + } +} diff --git a/examples/agents/smart-home/src/agentLoop.test.ts b/examples/agents/smart-home/src/agentLoop.test.ts new file mode 100644 index 00000000..f467f401 --- /dev/null +++ b/examples/agents/smart-home/src/agentLoop.test.ts @@ -0,0 +1,258 @@ +// @vitest-environment node +// +// Phase B's dogfood, key-free and headless: a scripted "model" drives a real +// ignite smart-home through the igniteTools + Anthropic loop in PURE NODE (no +// jsdom). This is the end-to-end proof of the DOM-free agent runtime (Phase A) +// AND a stress test of the agent API surface — varied command input schemas, +// the Option D scalar round-trip, the event observation stream, and errors-as- +// values — encoded as always-on assertions. +import { igniteTools } from "ignite-element/tools"; +import { + type AnthropicResponse, + anthropic, +} from "ignite-element/tools/anthropic"; +import { describe, expect, it } from "vitest"; +import { runHomeAgent } from "./agentLoop"; +import { createHome, DOORS, ROOMS, SCENES } from "./home"; +import { type Model, scriptedModel } from "./model"; + +describe("smart-home agent — Anthropic tool schemas (getSchema → adapter)", () => { + const { tools } = igniteTools(createHome(), anthropic); + const byName = (name: string) => tools.find((tool) => tool.name === name); + + it("runs headless: this whole file is in the node environment (no document)", () => { + expect(typeof document).toBe("undefined"); + }); + + it("translates an object command to an object input_schema", () => { + expect(byName("toggleLight")?.input_schema).toMatchObject({ + type: "object", + properties: { + room: { type: "string", enum: [...ROOMS] }, + on: { type: "boolean" }, + }, + }); + }); + + it("object-wraps a scalar enum command under `value` (Option D)", () => { + expect(byName("lockDoor")?.input_schema).toMatchObject({ + type: "object", + properties: { value: { type: "string", enum: [...DOORS] } }, + required: ["value"], + }); + expect(byName("runScene")?.input_schema).toMatchObject({ + properties: { value: { type: "string", enum: [...SCENES] } }, + }); + }); + + it("emits an empty-object schema for a no-arg command", () => { + expect(byName("status")?.input_schema).toEqual({ + type: "object", + properties: {}, + }); + }); +}); + +describe("smart-home agent — scripted session (round-trip, headless)", () => { + it("drives object + scalar-enum + no-arg commands, unwrapping scalars and observing events", async () => { + const script: AnthropicResponse[] = [ + // object input + { + content: [ + { + type: "tool_use", + id: "c1", + name: "toggleLight", + input: { room: "living", on: true }, + }, + ], + }, + // object input with a bounded number + { + content: [ + { + type: "tool_use", + id: "c2", + name: "setThermostat", + input: { room: "bedroom", temp: 72 }, + }, + ], + }, + // scalar enum — Claude sends it object-wrapped as { value } + { + content: [ + { + type: "tool_use", + id: "c3", + name: "lockDoor", + input: { value: "front" }, + }, + ], + }, + // scalar enum + emits a domain event (scene-applied) + { + content: [ + { + type: "tool_use", + id: "c4", + name: "runScene", + input: { value: "movie" }, + }, + ], + }, + // invalid: temp out of range → InvalidInput (errors as values, never throws) + { + content: [ + { + type: "tool_use", + id: "c5", + name: "setThermostat", + input: { room: "living", temp: 200 }, + }, + ], + }, + // no-arg + { content: [{ type: "tool_use", id: "c6", name: "status", input: {} }] }, + // done + { + content: [ + { + type: "text", + text: "Living light on, bedroom 72°, movie scene set.", + }, + ], + }, + ]; + + const result = await runHomeAgent( + scriptedModel(script), + "Turn on the living room light, set the bedroom to 72, lock the front door, and start movie mode.", + ); + + expect(result.modelCalls).toBe(7); + expect(result.trace.map((t) => t.command)).toEqual([ + "toggleLight", + "setThermostat", + "lockDoor", + "runScene", + "setThermostat", + "status", + ]); + + // Option D: scalar enums arrived as { value } and were unwrapped to bare strings. + expect(result.trace[2]).toMatchObject({ + command: "lockDoor", + input: "front", + ok: true, + }); + expect(result.trace[3]).toMatchObject({ + command: "runScene", + input: "movie", + ok: true, + }); + // Object input passes through verbatim. + expect(result.trace[0]).toMatchObject({ + command: "toggleLight", + input: { room: "living", on: true }, + }); + // Each observation carries the derived view (not just the raw snapshot), so + // the agent grounds on the read-model — after toggleLight the living light is on. + expect( + (result.trace[0].view as { lights: { living: boolean } }).lights.living, + ).toBe(true); + // runScene emitted the scene-applied event (the observation stream). + expect(result.trace[3].events).toContain("scene-applied"); + expect(result.trace[0].events).toContain("light-changed"); + + // Errors as values: the out-of-range temp was rejected, never threw. + expect(result.trace[4]).toMatchObject({ + command: "setThermostat", + ok: false, + errorKind: "InvalidInput", + }); + + // Final state reflects the valid commands; the invalid one left living temp alone. + const view = result.home.getView(); + expect(view).toMatchObject({ + activeScene: "movie", + thermostat: { bedroom: 72, living: 68 }, + }); + // movie scene turned the living light back off after toggleLight turned it on. + expect(view.lights.living).toBe(false); + }); + + it("clears the active scene when a device is manually overridden", async () => { + const home = createHome(); + + await home.execute("runScene", "morning"); + expect(home.getView().activeScene).toBe("morning"); + await home.execute("setThermostat", { room: "living", temp: 69 }); + expect(home.getView().activeScene).toBeNull(); + + await home.execute("runScene", "movie"); + expect(home.getView().activeScene).toBe("movie"); + await home.execute("setBlinds", { room: "living", percent: 25 }); + expect(home.getView().activeScene).toBeNull(); + + await home.execute("runScene", "away"); + expect(home.getView().activeScene).toBe("away"); + await home.execute("unlockDoor", "front"); + expect(home.getView().activeScene).toBeNull(); + }); + + it("keeps the active scene when a manual command is a no-op", async () => { + const home = createHome(); + + await home.execute("runScene", "away"); + expect(home.getView().activeScene).toBe("away"); + await home.execute("lockDoor", "front"); + expect(home.getView().activeScene).toBe("away"); + + await home.execute("runScene", "morning"); + expect(home.getView().activeScene).toBe("morning"); + await home.execute("setThermostat", { room: "living", temp: 70 }); + expect(home.getView().activeScene).toBe("morning"); + await home.execute("toggleLight", { room: "living", on: true }); + expect(home.getView().activeScene).toBe("morning"); + }); + + it("returns defensive copies from the derived view", () => { + const home = createHome(); + const view = home.getView(); + + view.lights.living = true; + view.thermostat.living = 80; + view.blinds.living = 100; + view.locks.front = false; + + expect(home.getView()).toMatchObject({ + lights: { living: false }, + thermostat: { living: 68 }, + blinds: { living: 0 }, + locks: { front: true }, + }); + }); + + it("fails loudly when a scripted fixture runs out of model turns", async () => { + const model = scriptedModel([ + { content: [{ type: "text", text: "done" }] }, + ]); + + await model({ tools: [], messages: [] }); + await expect(model({ tools: [], messages: [] })).rejects.toThrow( + /scriptedModel exhausted/, + ); + }); + + it("fails loudly when the model never produces a final response", async () => { + const toolOnlyModel: Model = async () => ({ + content: [ + { type: "tool_use", id: "keep-going", name: "status", input: {} }, + ], + }); + + await expect(runHomeAgent(toolOnlyModel, "never finish")).rejects.toThrow( + /runHomeAgent hit MAX_TURNS/, + ); + }); +}); diff --git a/examples/agents/smart-home/src/agentLoop.ts b/examples/agents/smart-home/src/agentLoop.ts new file mode 100644 index 00000000..56a36d3b --- /dev/null +++ b/examples/agents/smart-home/src/agentLoop.ts @@ -0,0 +1,98 @@ +import { igniteTools, isOk } from "ignite-element/tools"; +import { + type AnthropicResponse, + type AnthropicToolResultBlock, + anthropic, +} from "ignite-element/tools/anthropic"; +import { createHome } from "./home"; +import type { AnthropicMessage, Model } from "./model"; + +/** One tool call the agent made, plus what came back. */ +export type AgentTraceEntry = { + command: string; + /** The validated input — a scalar command's `{ value }` is already unwrapped. */ + input: unknown; + ok: boolean; + /** errors-as-values: the ToolError kind when the call was rejected. */ + errorKind?: string; + /** The derived view at command-acknowledgement — what the agent grounds on. */ + view?: unknown; + /** Domain events emitted during the command window (the observation stream). */ + events: string[]; +}; + +export type AgentResult = { + home: ReturnType; + trace: AgentTraceEntry[]; + finalText: string; + modelCalls: number; +}; + +/** A safety bound so a misbehaving model can't loop forever. */ +const MAX_TURNS = 12; + +/** + * Drive a fresh smart home through an Anthropic tool-use loop: + * + * getSchema() → anthropic.tools() → [model] → tool_use + * → toolCalls() (scalar-unwrap) → run() → toolResult() → repeat + * + * The `model` is injected, so the exact same loop runs against the scripted mock + * (key-free) or the real Anthropic API. This runs headless in pure Node — no DOM, + * no jsdom — which is the end-to-end proof of the DOM-free agent runtime. + */ +export async function runHomeAgent( + model: Model, + userPrompt: string, +): Promise { + const home = createHome(); + const { tools, toolCalls, run, toolResult } = igniteTools(home, anthropic); + + const messages: AnthropicMessage[] = [{ role: "user", content: userPrompt }]; + const trace: AgentTraceEntry[] = []; + let finalText = ""; + let modelCalls = 0; + + for (let turn = 0; turn < MAX_TURNS; turn++) { + const response = await model({ tools, messages }); + modelCalls++; + messages.push({ role: "assistant", content: response.content }); + + const calls = toolCalls(response); + if (calls.length === 0) { + finalText = textOf(response); + return { home, trace, finalText, modelCalls }; + } + + const resultBlocks: AnthropicToolResultBlock[] = []; + for (const call of calls) { + const result = await run(call); + trace.push({ + command: call.name, + input: call.input, + ok: isOk(result), + errorKind: isOk(result) ? undefined : result.error.kind, + view: isOk(result) ? result.value.view : undefined, + events: isOk(result) + ? result.value.events.map((event) => event.type) + : [], + }); + resultBlocks.push(toolResult({ id: call.id, name: call.name, result })); + } + messages.push({ role: "user", content: resultBlocks }); + } + + throw new Error( + `runHomeAgent hit MAX_TURNS (${MAX_TURNS}) before producing a final response`, + ); +} + +/** Concatenate the text blocks of an Anthropic response. */ +function textOf(response: AnthropicResponse): string { + return response.content + .map((block) => + block.type === "text" && typeof block.text === "string" ? block.text : "", + ) + .filter(Boolean) + .join("\n"); +} diff --git a/examples/agents/smart-home/src/anthropic.ts b/examples/agents/smart-home/src/anthropic.ts new file mode 100644 index 00000000..4a32b630 --- /dev/null +++ b/examples/agents/smart-home/src/anthropic.ts @@ -0,0 +1,62 @@ +import type { AgentResult } from "./agentLoop"; +import { runHomeAgent } from "./agentLoop"; +import { createHome } from "./home"; +import { anthropicModel } from "./model"; +import { renderHome } from "./render"; + +// The real loop: Claude drives the headless smart home through igniteTools + the +// Anthropic adapter. Needs an API key and the SDK: +// npm install @anthropic-ai/sdk +// ANTHROPIC_API_KEY=sk-... npm run anthropic -- "it's bedtime" + +const apiKey = process.env.ANTHROPIC_API_KEY; +if (!apiKey) { + console.error( + "Set ANTHROPIC_API_KEY to run the live loop (or `npm run mock` for the key-free demo).", + ); + process.exit(1); +} + +const prompt = + process.argv.slice(2).join(" ") || + "It's bedtime — turn off all the lights and lock every door."; + +function printSession(result: AgentResult): void { + console.log("Agent actions:"); + for (const entry of result.trace) { + const events = entry.events.length ? ` ⚡ ${entry.events.join(", ")}` : ""; + const outcome = entry.ok ? "ok" : `⛔ ${entry.errorKind}`; + console.log( + ` 🤖 ${entry.command}(${JSON.stringify(entry.input)}) → ${outcome}${events}`, + ); + } + console.log(`\n💬 ${result.finalText}\n`); + console.log("Final state:"); + console.log(renderHome(result.home.getView())); +} + +console.log("🏠 Smart-home agent — live Anthropic loop, headless (no DOM)\n"); +console.log("Initial state:"); +console.log(renderHome(createHome().getView())); +console.log(`\n🗣️ "${prompt}"\n`); + +try { + const result = await runHomeAgent(anthropicModel({ apiKey }), prompt); + printSession(result); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + const code = + typeof error === "object" && error !== null && "code" in error + ? (error as { code?: unknown }).code + : undefined; + if ( + code === "ERR_MODULE_NOT_FOUND" || + /Cannot find( module|package)|ERR_MODULE_NOT_FOUND/.test(message) + ) { + console.error( + "\n@anthropic-ai/sdk is not installed. Run `npm install @anthropic-ai/sdk` and retry.", + ); + process.exit(1); + } + throw error; +} diff --git a/examples/agents/smart-home/src/home.ts b/examples/agents/smart-home/src/home.ts new file mode 100644 index 00000000..fd13dcca --- /dev/null +++ b/examples/agents/smart-home/src/home.ts @@ -0,0 +1,257 @@ +import { igniteCore } from "ignite-element/xstate"; +import { assign, setup } from "xstate"; + +// A virtual smart home the agent drives. No hardware — the browser UI (Phase C) +// will render these devices; here the terminal renders them. The command set is +// deliberately varied to stress the manifest + the Anthropic adapter: +// - object inputs (toggleLight, setThermostat, setBlinds) +// - scalar enum (lockDoor / unlockDoor / runScene → Option D scalar wrap) +// - no-arg (status) + +export const ROOMS = ["living", "bedroom", "kitchen"] as const; +export const DOORS = ["front", "back", "garage"] as const; +export const SCENES = ["morning", "away", "movie", "night"] as const; + +export type Room = (typeof ROOMS)[number]; +export type Door = (typeof DOORS)[number]; +export type Scene = (typeof SCENES)[number]; + +export type HomeContext = { + lights: Record; + thermostat: Record; // °F + blinds: Record; // % open, 0–100 + locks: Record; // true = locked + activeScene: Scene | null; +}; + +type HomeEvent = + | { type: "TOGGLE_LIGHT"; room: Room; on: boolean } + | { type: "SET_THERMOSTAT"; room: Room; temp: number } + | { type: "SET_BLINDS"; room: Room; percent: number } + | { type: "SET_LOCK"; door: Door; locked: boolean } + | { type: "RUN_SCENE"; scene: Scene }; + +const initialContext: HomeContext = { + lights: { living: false, bedroom: false, kitchen: false }, + thermostat: { living: 68, bedroom: 68, kitchen: 68 }, + blinds: { living: 0, bedroom: 0, kitchen: 0 }, + activeScene: null, + locks: { front: true, back: true, garage: true }, +}; + +/** What each scene sets. Returns the full next context (merged). */ +function applyScene(ctx: HomeContext, scene: Scene): HomeContext { + switch (scene) { + case "morning": + return { + ...ctx, + activeScene: scene, + lights: { living: true, bedroom: true, kitchen: true }, + blinds: { living: 100, bedroom: 100, kitchen: 100 }, + thermostat: { living: 70, bedroom: 70, kitchen: 70 }, + }; + case "away": + return { + ...ctx, + activeScene: scene, + lights: { living: false, bedroom: false, kitchen: false }, + blinds: { living: 0, bedroom: 0, kitchen: 0 }, + thermostat: { living: 62, bedroom: 62, kitchen: 62 }, + locks: { front: true, back: true, garage: true }, + }; + case "movie": + return { + ...ctx, + activeScene: scene, + lights: { ...ctx.lights, living: false }, + blinds: { ...ctx.blinds, living: 0 }, + }; + case "night": + return { + ...ctx, + activeScene: scene, + lights: { living: false, bedroom: false, kitchen: false }, + locks: { front: true, back: true, garage: true }, + }; + } +} + +const homeMachine = setup({ + types: { + context: {} as HomeContext, + events: {} as HomeEvent, + }, +}).createMachine({ + id: "smart-home", + context: initialContext, + initial: "active", + states: { + active: { + on: { + TOGGLE_LIGHT: { + actions: assign({ + lights: ({ context, event }) => ({ + ...context.lights, + [event.room]: event.on, + }), + activeScene: ({ context, event }) => + context.lights[event.room] === event.on + ? context.activeScene + : null, + }), + }, + SET_THERMOSTAT: { + actions: assign({ + thermostat: ({ context, event }) => ({ + ...context.thermostat, + [event.room]: event.temp, + }), + activeScene: ({ context, event }) => + context.thermostat[event.room] === event.temp + ? context.activeScene + : null, + }), + }, + SET_BLINDS: { + actions: assign({ + blinds: ({ context, event }) => ({ + ...context.blinds, + [event.room]: event.percent, + }), + activeScene: ({ context, event }) => + context.blinds[event.room] === event.percent + ? context.activeScene + : null, + }), + }, + SET_LOCK: { + actions: assign({ + locks: ({ context, event }) => ({ + ...context.locks, + [event.door]: event.locked, + }), + activeScene: ({ context, event }) => + context.locks[event.door] === event.locked + ? context.activeScene + : null, + }), + }, + RUN_SCENE: { + actions: assign(({ context, event }) => + applyScene(context, event.scene), + ), + }, + }, + }, + }, +}); + +/** + * Build a fresh headless smart home. Returns the agent-runtime surface + * (`getSchema()` + `execute()` + `getView()` + `on()` + `watchView()`), no DOM. + */ +export function createHome() { + return igniteCore({ + source: homeMachine, + events: (event) => ({ + "light-changed": event<{ room: Room; on: boolean }>(), + "scene-applied": event<{ scene: Scene }>(), + "security-changed": event<{ allDoorsLocked: boolean }>(), + }), + view: ({ snapshot }) => { + const c = snapshot.context; + return { + lights: { ...c.lights }, + thermostat: { ...c.thermostat }, + blinds: { ...c.blinds }, + locks: { ...c.locks }, + activeScene: c.activeScene, + lightsOn: ROOMS.filter((room) => c.lights[room]), + allDoorsLocked: DOORS.every((door) => c.locks[door]), + }; + }, + commands: ({ actor, command }) => ({ + toggleLight: command( + ({ room, on }: { room: Room; on: boolean }) => + actor.send({ type: "TOGGLE_LIGHT", room, on }), + { + description: "Turn a room's light on or off.", + input: command.object({ + room: command.enum(ROOMS), + on: command.boolean(), + }), + }, + ), + setThermostat: command( + ({ room, temp }: { room: Room; temp: number }) => + actor.send({ type: "SET_THERMOSTAT", room, temp }), + { + description: "Set a room's target temperature in °F.", + input: command.object({ + room: command.enum(ROOMS), + temp: command.number({ minimum: 50, maximum: 90 }), + }), + }, + ), + setBlinds: command( + ({ room, percent }: { room: Room; percent: number }) => + actor.send({ type: "SET_BLINDS", room, percent }), + { + description: "Set how far a room's blinds are open (0–100%).", + input: command.object({ + room: command.enum(ROOMS), + percent: command.number({ minimum: 0, maximum: 100 }), + }), + }, + ), + lockDoor: command( + (door: Door) => actor.send({ type: "SET_LOCK", door, locked: true }), + { + description: "Lock a door.", + input: command.enum(DOORS), + }, + ), + unlockDoor: command( + (door: Door) => actor.send({ type: "SET_LOCK", door, locked: false }), + { + description: "Unlock a door.", + input: command.enum(DOORS), + }, + ), + runScene: command( + (scene: Scene) => actor.send({ type: "RUN_SCENE", scene }), + { + description: + "Activate a scene: morning, away, movie, or night. Sets several devices at once.", + input: command.enum(SCENES), + }, + ), + status: command( + () => { + // No-op: the home state is read from the returned snapshot / getView(). + }, + { description: "Read the current home state (no change)." }, + ), + }), + effects: ({ emit, select }) => { + const lights = select((state) => state.context.lights); + if (lights.changed) { + for (const room of ROOMS) { + if (lights.current[room] !== lights.previous[room]) { + emit("light-changed", { room, on: lights.current[room] }); + } + } + } + const scene = select((state) => state.context.activeScene); + if (scene.changed && scene.current) { + emit("scene-applied", { scene: scene.current }); + } + const locked = select((state) => + DOORS.every((door) => state.context.locks[door]), + ); + if (locked.changed) { + emit("security-changed", { allDoorsLocked: locked.current }); + } + }, + }); +} diff --git a/examples/agents/smart-home/src/mock.ts b/examples/agents/smart-home/src/mock.ts new file mode 100644 index 00000000..609a40bd --- /dev/null +++ b/examples/agents/smart-home/src/mock.ts @@ -0,0 +1,86 @@ +import type { AnthropicResponse } from "ignite-element/tools/anthropic"; +import type { AgentResult } from "./agentLoop"; +import { runHomeAgent } from "./agentLoop"; +import { createHome } from "./home"; +import { scriptedModel } from "./model"; +import { renderHome } from "./render"; + +// Key-free, headless demo: a scripted "model" returns tool_use blocks and the +// igniteTools loop drives a real ignite smart home — all in plain Node, no DOM, +// no API key. Run with: npm run mock + +const prompt = + "Turn on the living room light, set the bedroom to 72°, lock the front door, then start movie mode."; + +const script: AnthropicResponse[] = [ + { + content: [ + { + type: "tool_use", + id: "c1", + name: "toggleLight", + input: { room: "living", on: true }, + }, + ], + }, + { + content: [ + { + type: "tool_use", + id: "c2", + name: "setThermostat", + input: { room: "bedroom", temp: 72 }, + }, + ], + }, + { + content: [ + { + type: "tool_use", + id: "c3", + name: "lockDoor", + input: { value: "front" }, + }, + ], + }, + { + content: [ + { + type: "tool_use", + id: "c4", + name: "runScene", + input: { value: "movie" }, + }, + ], + }, + { + content: [ + { + type: "text", + text: "Living light on, bedroom set to 72°, front door locked, movie mode on.", + }, + ], + }, +]; + +function printSession(result: AgentResult): void { + console.log("Agent actions:"); + for (const entry of result.trace) { + const events = entry.events.length ? ` ⚡ ${entry.events.join(", ")}` : ""; + const outcome = entry.ok ? "ok" : `⛔ ${entry.errorKind}`; + console.log( + ` 🤖 ${entry.command}(${JSON.stringify(entry.input)}) → ${outcome}${events}`, + ); + } + console.log(`\n💬 ${result.finalText}\n`); + console.log("Final state:"); + console.log(renderHome(result.home.getView())); +} + +console.log("🏠 Smart-home agent — scripted, key-free, headless (no DOM)\n"); +console.log("Initial state:"); +console.log(renderHome(createHome().getView())); +console.log(`\n🗣️ "${prompt}"\n`); + +const result = await runHomeAgent(scriptedModel(script), prompt); +printSession(result); diff --git a/examples/agents/smart-home/src/model.ts b/examples/agents/smart-home/src/model.ts new file mode 100644 index 00000000..04b41832 --- /dev/null +++ b/examples/agents/smart-home/src/model.ts @@ -0,0 +1,83 @@ +import type { + AnthropicResponse, + AnthropicTool, +} from "ignite-element/tools/anthropic"; + +/** + * One Anthropic Messages turn — the subset the loop sends back. `content` is the + * user prompt string on the first turn, then the assistant's content blocks and + * our `tool_result` blocks on subsequent turns. + */ +export type AnthropicMessage = { + role: "user" | "assistant"; + content: unknown; +}; + +/** + * The pluggable model seam: the loop calls this; the implementation decides where + * the response comes from. Swap the real model for the scripted one without + * touching the loop. + */ +export type Model = (request: { + tools: AnthropicTool[]; + messages: AnthropicMessage[]; +}) => Promise; + +/** + * A deterministic, key-free model: replays a fixed script of responses. Lets the + * whole loop run and be asserted with zero network — the manual-validation + * harness for the headless runtime + the Anthropic adapter, encoded as a test. + */ +export function scriptedModel(script: AnthropicResponse[]): Model { + let turn = 0; + return async () => { + const response = script[turn++]; + if (!response) { + throw new Error("scriptedModel exhausted its scripted responses"); + } + return response; + }; +} + +/** + * The real Anthropic model. The SDK is imported lazily through a variable + * specifier so this example typechecks and the scripted path runs **without** + * `@anthropic-ai/sdk` installed; install it to use this path: + * + * npm install @anthropic-ai/sdk + */ +export function anthropicModel(options: { + apiKey: string; + model?: string; + system?: string; + maxTokens?: number; +}): Model { + return async ({ tools, messages }) => { + const specifier = "@anthropic-ai/sdk"; + const sdk = (await import(specifier)) as { + default: new (opts: { + apiKey: string; + }) => { + messages: { + create(body: { + model: string; + max_tokens: number; + system?: string; + tools: AnthropicTool[]; + messages: AnthropicMessage[]; + }): Promise; + }; + }; + }; + const client = new sdk.default({ apiKey: options.apiKey }); + return client.messages.create({ + model: options.model ?? "claude-sonnet-4-6", + max_tokens: options.maxTokens ?? 1024, + system: + options.system ?? + "You control a smart home through tools. Take the requested actions, then briefly confirm what you did.", + tools, + messages, + }); + }; +} diff --git a/examples/agents/smart-home/src/render.ts b/examples/agents/smart-home/src/render.ts new file mode 100644 index 00000000..64f2486e --- /dev/null +++ b/examples/agents/smart-home/src/render.ts @@ -0,0 +1,34 @@ +import { type createHome, DOORS, type Door, ROOMS, type Room } from "./home"; + +/** The home's projected read-model — exactly what `getView()` returns. */ +export type HomeView = ReturnType["getView"]>; + +const light = (on: boolean) => (on ? "💡 on " : "·· off"); +const lock = (locked: boolean) => (locked ? "🔒 locked " : "🔓 unlocked"); +const pad = (text: string, width: number) => text.padEnd(width); + +/** Render the home view as a compact terminal panel (no DOM — pure string). */ +export function renderHome(view: HomeView): string { + const lines: string[] = []; + lines.push("┌─ 🏠 Home ──────────────────────────────────────────┐"); + lines.push( + `│ scene: ${pad(view.activeScene ?? "—", 10)} doors: ${ + view.allDoorsLocked ? "all locked 🔒" : "UNLOCKED 🔓" + }${pad("", 8)}│`, + ); + lines.push("├────────────────────────────────────────────────────┤"); + for (const room of ROOMS as readonly Room[]) { + lines.push( + `│ ${pad(room, 8)} ${pad(light(view.lights[room]), 8)} 🌡️ ${pad( + `${view.thermostat[room]}°F`, + 5, + )} 🪟 ${pad(`${view.blinds[room]}%`, 4)} │`, + ); + } + lines.push("├────────────────────────────────────────────────────┤"); + for (const door of DOORS as readonly Door[]) { + lines.push(`│ ${pad(door, 8)} ${pad(lock(view.locks[door]), 38)} │`); + } + lines.push("└────────────────────────────────────────────────────┘"); + return lines.join("\n"); +} diff --git a/examples/agents/smart-home/tsconfig.json b/examples/agents/smart-home/tsconfig.json new file mode 100644 index 00000000..13d4e650 --- /dev/null +++ b/examples/agents/smart-home/tsconfig.json @@ -0,0 +1,57 @@ +{ + "extends": "../../../packages/ignite-element/tsconfig.typecheck.json", + "compilerOptions": { + "rootDir": "../../../", + "noEmit": true, + "composite": false, + "jsx": "react-jsx", + "jsxImportSource": "ignite-element/jsx", + "allowJs": false, + "paths": { + "ignite-element": ["../../../packages/ignite-element/src/index.ts"], + "ignite-element/jsx": [ + "../../../packages/ignite-element/src/jsx/index.ts" + ], + "ignite-element/jsx/jsx-runtime": [ + "../../../packages/ignite-element/src/jsx/jsx-runtime.ts" + ], + "ignite-element/jsx/jsx-dev-runtime": [ + "../../../packages/ignite-element/src/jsx/jsx-dev-runtime.ts" + ], + "ignite-element/*": ["../../../packages/ignite-element/src/*"], + "@ignite-element/core": ["../../../packages/ignite-core/src/index.ts"], + "@ignite-element/adapters": [ + "../../../packages/ignite-adapters/src/index.ts" + ], + "@ignite-element/adapters/xstate": [ + "../../../packages/ignite-adapters/src/xstate.ts" + ], + "@ignite-element/adapters/redux": [ + "../../../packages/ignite-adapters/src/redux.ts" + ], + "@ignite-element/adapters/mobx": [ + "../../../packages/ignite-adapters/src/mobx.ts" + ], + "@ignite-element/renderer": [ + "../../../packages/ignite-renderer/src/index.ts" + ], + "@ignite-element/renderer/jsx": [ + "../../../packages/ignite-renderer/src/renderers/ignite-jsx.ts" + ], + "@ignite-element/renderer/lit": [ + "../../../packages/ignite-renderer/src/renderers/lit.ts" + ], + "@ignite-element/renderer/jsx-runtime": [ + "../../../packages/ignite-renderer/src/jsx/jsx-runtime.ts" + ], + "@ignite-element/renderer/jsx-dev-runtime": [ + "../../../packages/ignite-renderer/src/jsx/jsx-dev-runtime.ts" + ], + "@ignite-element/renderer/jsx/index": [ + "../../../packages/ignite-renderer/src/jsx/index.ts" + ] + } + }, + "include": ["./**/*.ts", "./**/*.tsx"], + "exclude": [] +} diff --git a/examples/agents/smart-home/vite.config.ts b/examples/agents/smart-home/vite.config.ts new file mode 100644 index 00000000..24ff21ab --- /dev/null +++ b/examples/agents/smart-home/vite.config.ts @@ -0,0 +1,86 @@ +/// +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const resolvePath = (path: string) => + fileURLToPath(new URL(path, import.meta.url)); + +// Example-fixture wiring for this monorepo so the demo runs against current +// SOURCE (not a possibly-stale `dist/`, and not the published package — which +// doesn't carry `ignite-element/tools/anthropic` until the next beta). In a real +// app you import the published `ignite-element` package directly. Mirrors the +// alias set used by the other examples. Order matters: more-specific subpaths +// must precede the package roots. +const igniteElementSrc = resolvePath("../../../packages/ignite-element/src/"); +const adaptersSrc = resolvePath("../../../packages/ignite-adapters/src"); +const rendererSrc = resolvePath("../../../packages/ignite-renderer/src"); + +export default defineConfig({ + resolve: { + alias: [ + { + find: "@ignite-element/core", + replacement: resolvePath("../../../packages/ignite-core/src/index.ts"), + }, + { + find: "@ignite-element/adapters/xstate", + replacement: `${adaptersSrc}/xstate.ts`, + }, + { + find: "@ignite-element/adapters/redux", + replacement: `${adaptersSrc}/redux.ts`, + }, + { + find: "@ignite-element/adapters/mobx", + replacement: `${adaptersSrc}/mobx.ts`, + }, + { + find: "@ignite-element/adapters/actor-web", + replacement: `${adaptersSrc}/actor-web.ts`, + }, + { + find: "@ignite-element/adapters", + replacement: `${adaptersSrc}/index.ts`, + }, + { + find: "@ignite-element/renderer/jsx-runtime", + replacement: `${rendererSrc}/jsx/jsx-runtime.ts`, + }, + { + find: "@ignite-element/renderer/jsx-dev-runtime", + replacement: `${rendererSrc}/jsx/jsx-dev-runtime.ts`, + }, + { + find: "@ignite-element/renderer/jsx/index", + replacement: `${rendererSrc}/jsx/index.ts`, + }, + { + find: "@ignite-element/renderer/jsx", + replacement: `${rendererSrc}/renderers/ignite-jsx.ts`, + }, + { + find: "@ignite-element/renderer/lit", + replacement: `${rendererSrc}/renderers/lit.ts`, + }, + { + find: "@ignite-element/renderer", + replacement: `${rendererSrc}/index.ts`, + }, + { + find: /^ignite-element\/(.+)$/, + replacement: `${igniteElementSrc}$1`, + }, + { + find: "ignite-element", + replacement: `${igniteElementSrc}index.ts`, + }, + ], + }, + test: { + // `node`, NOT jsdom — the smart-home agent runs fully headless. This is the + // end-to-end proof of the DOM-free agent runtime: getSchema/execute/on/ + // watchView work here with zero DOM polyfill. + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/ignite-element/src/tests/tools.anthropic.test.ts b/packages/ignite-element/src/tests/tools.anthropic.test.ts index ad3a3ab5..9cac7163 100644 --- a/packages/ignite-element/src/tests/tools.anthropic.test.ts +++ b/packages/ignite-element/src/tests/tools.anthropic.test.ts @@ -125,12 +125,20 @@ describe("anthropic.toolResult (neutral result -> Anthropic tool_result block)", const result: NeutralToolResult = { id: "toolu_1", name: "setLimit", - result: ok({ snapshot: { count: 7 }, events: [] }), + result: ok({ + snapshot: { count: 7 }, + view: { atLimit: false }, + events: [], + }), }; expect(anthropic.toolResult(result)).toEqual({ type: "tool_result", tool_use_id: "toolu_1", - content: JSON.stringify({ snapshot: { count: 7 }, events: [] }), + content: JSON.stringify({ + snapshot: { count: 7 }, + view: { atLimit: false }, + events: [], + }), is_error: false, }); }); @@ -157,7 +165,11 @@ describe("anthropic.toolResult (neutral result -> Anthropic tool_result block)", it("throws when the neutral result has no id (Anthropic requires a tool_use_id)", () => { const result: NeutralToolResult = { name: "setLimit", - result: ok({ snapshot: { count: 7 }, events: [] }), + result: ok({ + snapshot: { count: 7 }, + view: { atLimit: false }, + events: [], + }), }; expect(() => anthropic.toolResult(result)).toThrow(/tool_use_id/); }); diff --git a/packages/ignite-element/src/tests/tools.test.ts b/packages/ignite-element/src/tests/tools.test.ts index 33e56d20..1ae6c6f6 100644 --- a/packages/ignite-element/src/tests/tools.test.ts +++ b/packages/ignite-element/src/tests/tools.test.ts @@ -80,6 +80,11 @@ function createFakeComponent( events: [{ type: "item-added", payload: { id: 1 } }], }; }) as FakeComponent["execute"], + // The derived read-model the agent grounds on; reflects the calls so far. + getView: () => ({ + count: calls.length, + label: calls.length > 0 ? "active" : "zero", + }), }; if (options.canExecute) { component.canExecute = options.canExecute; @@ -87,6 +92,33 @@ function createFakeComponent( return component; } +class ThisBoundFakeComponent implements FakeComponent { + calls: Array<{ name: string; payload: unknown }> = []; + + execute = async function ( + this: ThisBoundFakeComponent, + name: string, + payload?: unknown, + ) { + this.calls.push({ name, payload }); + return { + state: { count: this.calls.length, last: name, payload }, + events: [{ type: "item-added", payload: { id: this.calls.length } }], + }; + } as FakeComponent["execute"]; + + getSchema() { + return fakeSchema; + } + + getView() { + return { + count: this.calls.length, + label: this.calls.length > 0 ? "active" : "zero", + }; + } +} + // A minimal fake provider dialect: proves the ToolDialect port wiring without // any real provider SDK. Its wire shapes are intentionally invented. type FakeToolDefs = Array<{ tool: string; schema: unknown }>; @@ -333,7 +365,7 @@ describe("igniteTools (neutral, no dialect)", () => { expect("tools" in tools).toBe(false); }); - it("run routes a valid call through execute and returns { snapshot, events }", async () => { + it("run routes a valid call through execute and returns { snapshot, view, events }", async () => { const component = createFakeComponent(); const { run } = igniteTools(component); const result = await run({ name: "setLimit", input: 7 }); @@ -345,12 +377,30 @@ describe("igniteTools (neutral, no dialect)", () => { last: "setLimit", payload: 7, }); + // The observation carries the derived view (post-command), so the agent + // can ground on the read-model, not just the raw snapshot. + expect(result.value.view).toEqual({ count: 1, label: "active" }); expect(result.value.events).toEqual([ { type: "item-added", payload: { id: 1 } }, ]); } }); + it("binds runtime methods before calling execute and getView", async () => { + const component = new ThisBoundFakeComponent(); + const { run } = igniteTools(component); + const result = await run({ name: "setLimit", input: 7 }); + expect(component.calls).toEqual([{ name: "setLimit", payload: 7 }]); + expect(isOk(result)).toBe(true); + if (isOk(result)) { + expect(result.value.snapshot).toMatchObject({ + count: 1, + last: "setLimit", + }); + expect(result.value.view).toEqual({ count: 1, label: "active" }); + } + }); + it("run does not call execute on an unknown command", async () => { const component = createFakeComponent(); const { run } = igniteTools(component); diff --git a/packages/ignite-element/src/tests/types/tools.types.test.ts b/packages/ignite-element/src/tests/types/tools.types.test.ts index 39bced80..7dcf56c6 100644 --- a/packages/ignite-element/src/tests/types/tools.types.test.ts +++ b/packages/ignite-element/src/tests/types/tools.types.test.ts @@ -51,6 +51,9 @@ describe("igniteTools types", () => { if (result.ok) { expectTypeOf(result.value.snapshot).toEqualTypeOf(); + // The observation also carries the derived view, typed from the + // component's `view` projection. + expectTypeOf(result.value.view).toEqualTypeOf<{ isOn: boolean }>(); expectTypeOf(result.value.events).toEqualTypeOf< Array<{ type: "toggled"; payload: { isOn: boolean } }> >(); diff --git a/packages/ignite-element/src/tools/igniteTools.ts b/packages/ignite-element/src/tools/igniteTools.ts index 0a0887e3..11c2c42a 100644 --- a/packages/ignite-element/src/tools/igniteTools.ts +++ b/packages/ignite-element/src/tools/igniteTools.ts @@ -19,25 +19,26 @@ import type { } from "./types"; /** The neutral core surface, usable directly without a provider dialect. */ -export type IgniteToolsNeutral = { +export type IgniteToolsNeutral = { manifest: NeutralManifest; resolveCall(name: string, input: unknown): Result; run( call: NeutralToolCall, - ): Promise, ToolError>>; + ): Promise, ToolError>>; }; /** The neutral core plus a dialect's provider-shaped tools + translators. */ export type IgniteToolsWithDialect< State, + View, Events extends EventMap, Tools, Response, ResultBlock, -> = IgniteToolsNeutral & { +> = IgniteToolsNeutral & { tools: Tools; toolCalls(response: Response): NeutralToolCall[]; - toolResult(result: NeutralToolResult): ResultBlock; + toolResult(result: NeutralToolResult): ResultBlock; }; export function igniteTools< @@ -48,7 +49,7 @@ export function igniteTools< View extends Record, >( runtime: IgniteToolsRuntime, -): IgniteToolsNeutral; +): IgniteToolsNeutral; export function igniteTools< State, Commands extends FacadeCommandResult, @@ -61,7 +62,7 @@ export function igniteTools< >( runtime: IgniteToolsRuntime, dialect: ToolDialect, -): IgniteToolsWithDialect; +): IgniteToolsWithDialect; /** * Bridge the agent-runtime contract to LLM tool-use. The pure core builds a * neutral manifest from `getSchema()` and routes validated calls; the shell @@ -82,11 +83,15 @@ export function igniteTools( // The model supplies dynamic command names, so treat `execute` as the loose // runtime contract at this boundary. - const execute = runtime.execute as unknown as ( + const execute = runtime.execute.bind(runtime) as unknown as ( name: string, payload?: unknown, ) => Promise>; + // Captured post-command (at acknowledgement) into each observation so the + // agent grounds on the derived view, not just the raw snapshot. + const getView = runtime.getView.bind(runtime) as unknown as () => unknown; + const boundResolveCall = ( name: string, input: unknown, @@ -94,7 +99,9 @@ export function igniteTools( const run = async ( call: NeutralToolCall, - ): Promise, ToolError>> => { + ): Promise< + Result, ToolError> + > => { const routed = boundResolveCall(call.name, call.input); if (!routed.ok) { return routed; @@ -105,7 +112,7 @@ export function igniteTools( routed.value.command, routed.value.payload, ); - return ok({ snapshot: state, events }); + return ok({ snapshot: state, view: getView(), events }); } catch (cause) { return err({ kind: "ExecuteFailed", diff --git a/packages/ignite-element/src/tools/types.ts b/packages/ignite-element/src/tools/types.ts index 635fdd4b..932f9f60 100644 --- a/packages/ignite-element/src/tools/types.ts +++ b/packages/ignite-element/src/tools/types.ts @@ -45,13 +45,18 @@ export type NeutralToolCall = { /** * The observation an agent gets back after a successful tool call: the - * post-command snapshot plus the events emitted during the command window. + * post-command `snapshot` (raw state), the derived `view` (the read-model the + * agent should ground on — distinct from the raw snapshot), and the events + * emitted during the command window. Both snapshot and view are captured at + * command-acknowledgement (see the act+ack note in `docs/ignite-tools.md`). */ export type ToolObservation< Snapshot, + View = unknown, Events extends EventMap = EmptyEventMap, > = { snapshot: Snapshot; + view: View; events: RuntimeEvent[]; }; @@ -81,11 +86,12 @@ export type Route = { */ export type NeutralToolResult< Snapshot = unknown, + View = unknown, Events extends EventMap = EmptyEventMap, > = { id?: string; name: string; - result: Result, ToolError>; + result: Result, ToolError>; }; /** @@ -120,11 +126,12 @@ export interface ToolDialect< export type AvailabilityPredicate = (name: string) => boolean; /** - * The minimal slice of the agent runtime that `igniteTools` depends on — just - * `getSchema` (the contract) and `execute` (the single side effect). Any - * `igniteCore(...)` return satisfies it. `canExecute` is optional and - * duck-typed: present once it ships on the runtime, it gates the manifest; - * absent today, all commands are offered. + * The minimal slice of the agent runtime that `igniteTools` depends on: + * `getSchema` (the contract), `execute` (the single side effect), and `getView` + * (the derived read-model captured into each observation so the agent grounds on + * the view, not just the raw snapshot). Any `igniteCore(...)` return satisfies + * it. `canExecute` is optional and duck-typed: present once it ships on the + * runtime, it gates the manifest; absent today, all commands are offered. */ export type IgniteToolsRuntime< State = unknown, @@ -134,7 +141,7 @@ export type IgniteToolsRuntime< View extends Record = Record, > = Pick< IgniteAgentRuntime, - "getSchema" | "execute" + "getSchema" | "execute" | "getView" > & { canExecute?: AvailabilityPredicate; };