Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/ignitetools-view-in-observation.md
Original file line number Diff line number Diff line change
@@ -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<Snapshot, Events>` gains a `View` type parameter (`ToolObservation<Snapshot, View, Events>`) 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.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 )
Expand Down
22 changes: 14 additions & 8 deletions docs/ignite-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,17 +112,21 @@ two helpers; the OpenAI/Ollama dialect reuses them verbatim.

### Imperative shell

- `run(toolCall): Promise<Result<{ snapshot, events }, ToolError>>` — 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<Result<{ snapshot, view, events }, ToolError>>` — 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**;
Expand All @@ -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

Expand Down
60 changes: 60 additions & 0 deletions examples/agents/smart-home/GAPS.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 56 additions & 0 deletions examples/agents/smart-home/README.md
Original file line number Diff line number Diff line change
@@ -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).
32 changes: 32 additions & 0 deletions examples/agents/smart-home/package.json
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
"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"
}
}
Loading
Loading