react-observer-agent 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ This is an experimental project by a solo developer. I am exploring whether an A
10
10
 
11
11
  - Zero runtime dependencies (adapters use raw `fetch`, no SDKs)
12
12
  - TypeScript, dual ESM/CJS builds with types included
13
- - React >= 18 (peer dependency)
13
+ - React >= 18 (peer dependency); CI runs the suite against React 18 and 19
14
14
  - Works with any state manager: Zustand, Redux, vanilla React state
15
15
 
16
16
  ## Install
@@ -19,18 +19,35 @@ This is an experimental project by a solo developer. I am exploring whether an A
19
19
  npm install react-observer-agent
20
20
  ```
21
21
 
22
+ The bundle starts with a `'use client'` directive, so importing it from a Next.js App Router component needs no wrapper file.
23
+
22
24
  ## Quick start
23
25
 
24
26
  ```tsx
25
27
  import { AIAgentProvider, registerTool, openAIAdapter, useAgent } from 'react-observer-agent';
26
28
  import { useStore } from './store';
27
29
 
28
- // 1. Register tools: actions the agent is allowed to perform
30
+ // 1. Register tools: actions the agent is allowed to perform.
31
+ // A handler receives (args, context); context?.signal aborts when the
32
+ // interaction is cancelled, so forward it to anything long-running.
29
33
  const tools = [
30
34
  registerTool('goToPage', (args: { path: string }) => navigate(args.path), {
31
35
  description: 'Navigate to a page in the app',
32
36
  parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
33
37
  }),
38
+ registerTool(
39
+ 'searchProducts',
40
+ async (args: { query: string }, context) => {
41
+ const res = await fetch(`/api/search?q=${encodeURIComponent(args.query)}`, {
42
+ signal: context?.signal,
43
+ });
44
+ return res.json();
45
+ },
46
+ {
47
+ description: 'Search the product catalog',
48
+ parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
49
+ },
50
+ ),
34
51
  registerTool('submitForm', () => handleSubmit(), {
35
52
  description: 'Submit the current form',
36
53
  confirm: true, // requires user approval before executing
@@ -55,7 +72,7 @@ export default function App() {
55
72
  tools={tools}
56
73
  permissions={{
57
74
  canAccess: ['user', 'cart'],
58
- canExecute: ['goToPage', 'submitForm'],
75
+ canExecute: ['goToPage', 'searchProducts', 'submitForm'],
59
76
  stateDescriptions: {
60
77
  user: 'Current logged-in user profile',
61
78
  cart: 'Shopping cart items and quantities',
@@ -76,6 +93,7 @@ function ChatPanel() {
76
93
  // send("What's in my cart?") -> agent reads state, responds with text
77
94
  // send("Go to settings") -> agent calls goToPage({ path: '/settings' })
78
95
  // send(text, { signal }) -> pass an AbortSignal to cancel mid-flight
96
+ // Two sends in a row queue up and run one at a time, in call order.
79
97
  }
80
98
  ```
81
99
 
@@ -109,7 +127,9 @@ Two things fall out of this:
109
127
  - **Token cost scales with what the agent actually reads**, not with the size of your state tree.
110
128
  - **Unread state never leaves the client.** A key the agent does not ask for is never serialized into a request.
111
129
 
112
- `__readState` is invisible to you as a consumer. It never appears in `AgentResponse.toolCalls`, the `onToolCall` callback, or `history`. The rationale and a worked example are in [docs/internals.md](docs/internals.md).
130
+ A read is resolved from your state source at the moment the model asks, so the model sees current values, and `options.maxStateBytes` can cap how much any single key contributes (see [Security model](#security-model)).
131
+
132
+ `__readState` is invisible to you as a consumer. It never appears in `AgentResponse.toolCalls`, the `onToolCall` callback, or `history`. The `state_read` event (below) is the one place you can watch it happen. The rationale and a worked example are in [docs/internals.md](docs/internals.md).
113
133
 
114
134
  ## Security model
115
135
 
@@ -122,9 +142,34 @@ The library treats the LLM as an untrusted planner inside a capability sandbox.
122
142
  1. *Visibility*: the model never sees unlisted keys or tools, so it cannot request what it cannot see.
123
143
  2. *Execution*: names are re-validated after the model responds. A hallucinated or injected tool name is rejected with status `denied`, and `__readState` requests are re-filtered against `canAccess`.
124
144
 
125
- **Argument validation.** Tool arguments are checked against the tool's `parameters` JSON Schema before the handler runs, and before the confirmation prompt, so nobody is asked to approve a malformed call. Validation covers a deliberate subset (`type`, `properties`, `required`, `items`, `enum`) and ignores keywords outside it, so a richer schema validates on the parts the library understands instead of failing outright. Handlers should still treat args as untrusted, since unvalidated keywords pass through.
145
+ **Argument validation.** Tool arguments are checked before the handler runs, and before the confirmation prompt, so nobody is asked to approve a malformed call. Two validators are available:
146
+
147
+ - The default checks arguments against the tool's `parameters` JSON Schema. It covers a deliberate subset (`type`, `properties`, `required`, `items`, `enum`) and ignores keywords outside it, so a richer schema validates on the parts the library understands instead of failing outright. Handlers should still treat args as untrusted, since unvalidated keywords pass through.
148
+ - Passing a [Standard Schema](https://standardschema.dev) validator as `schema` (Zod 3.24+, Valibot 1+, ArkType 2+) replaces the subset check with the real thing. The handler receives the validated value, so defaults and transforms apply, and its argument type is inferred from the schema output. `parameters` still supplies the JSON Schema the model sees; the library does not derive one from the schema.
149
+
150
+ ```ts
151
+ import { z } from 'zod';
152
+
153
+ const AddToCart = z.object({ productId: z.string(), qty: z.number().int().positive().default(1) });
154
+
155
+ registerTool('addToCart', (args) => addToCart(args.productId, args.qty), {
156
+ description: 'Add a product to the cart',
157
+ parameters: {
158
+ type: 'object',
159
+ properties: { productId: { type: 'string' }, qty: { type: 'integer' } },
160
+ required: ['productId'],
161
+ },
162
+ schema: AddToCart, // args is { productId: string; qty: number }
163
+ });
164
+ ```
165
+
166
+ Annotating the handler is allowed when the annotation matches the schema output. An annotation that disagrees with it is a compile error rather than a silent fallback to the untyped signature. An explicit type argument (`registerTool<T>(...)`) next to an inline `schema` is also a compile error, whether or not the two agree, since the type argument turns inference off; drop it and let the schema supply the type.
167
+
168
+ **Human confirmation.** Tools registered with `confirm: true` route through your `onConfirm` handler before running. You own the UI: modal, toast, `window.confirm`, anything that resolves a boolean. The handler receives `{ toolName, args, description, signal }`, where `args` is the validated value and `signal` aborts if the interaction is cancelled while your prompt is open. If no handler is provided, the tool is skipped with status `cancelled`. Confirmation is never silently bypassed. Use it for anything irreversible or user-visible.
126
169
 
127
- **Human confirmation.** Tools registered with `confirm: true` route through your `onConfirm` handler before running. You own the UI: modal, toast, `window.confirm`, anything that resolves a boolean. If no handler is provided, the tool is skipped with status `cancelled`. Confirmation is never silently bypassed. Use it for anything irreversible or user-visible.
170
+ Abort and confirmation interact in a few ways worth knowing. An approval that arrives after the interaction was aborted does not run the tool; the call is recorded as `cancelled`. If `onConfirm` rejects with an `AbortError`, the call is cancelled the same way. Any other rejection counts as a tool error: the call is recorded with status `error`, the model is told, and the interaction continues.
171
+
172
+ **State size guard.** One oversized key (a product catalog, a log buffer) can spend the whole context window on a single read. `options.maxStateBytes` caps the serialized size of each key returned by `__readState`; a value over the cap is replaced with a marker carrying the size, the limit, and a preview of the JSON. Unset means no limit.
128
173
 
129
174
  **Prompt injection.** State often contains user-generated content (reviews, messages, profile fields). Once serialized into the conversation, that content can attempt prompt injection. The permission and confirmation layers are the backstop: an injected instruction can at worst invoke allowlisted tools, and confirmed tools still require a human yes.
130
175
 
@@ -143,21 +188,48 @@ The backend holds the real key, applies auth and rate limits, and forwards to th
143
188
 
144
189
  | Adapter | Status | Defaults |
145
190
  |---------|--------|----------|
146
- | `openAIAdapter` | Built in | OpenAI chat completions; model `gpt-4o`, temperature `0.2` |
147
- | `claudeAdapter` | Built in | Anthropic Messages API; model `claude-opus-5`, `maxTokens` `16000` |
148
- | `ollamaAdapter` | Planned | Local models via Ollama |
191
+ | `openAIAdapter` | Built in | OpenAI chat completions; model `gpt-4o`, temperature `0.2` (`null` omits it) |
192
+ | `claudeAdapter` | Built in | Anthropic Messages API; model `claude-opus-5`, `maxTokens` `16000`, prompt caching on |
193
+ | Ollama | Recipe below | `openAIAdapter` pointed at Ollama's OpenAI-compatible endpoint |
149
194
  | Custom | Supported | Implement `ModelAdapter` and pass it to the provider |
150
195
 
151
- Both built-in adapters are raw `fetch`, no SDK dependency. Both require either `apiKey` or `baseURL` and throw at construction with neither. `claudeAdapter` sends no sampling parameters, since current Claude models reject them.
196
+ Both built-in adapters are raw `fetch`, no SDK dependency. Both require either `apiKey` or `baseURL` and throw at construction with neither. A non-2xx response, a network failure, or an unparseable body throws an `AdapterError` carrying `status` and the raw `body` when there was a response; the provider surfaces it as `error.code: 'ADAPTER_ERROR'` with `error.status`, so a 401 and a 429 are telling apart without parsing messages. Both map the provider's stop reason so a truncated or refused answer is reported (see below), and both report cached prompt tokens in `usage.cacheReadTokens` when the API does.
197
+
198
+ `claudeAdapter` sends the system prompt as a text block with a `cache_control` breakpoint, which caches the tools and system prompt together across calls; `cache: false` sends a plain string instead. Its `promptTokens` is the total input for the call, cached tokens included, with `cacheReadTokens` and `cacheWriteTokens` broken out. The raw content blocks of each assistant response are kept as `providerData` and replayed verbatim on later turns, which is what keeps thinking blocks and their signatures intact (Anthropic rejects a modified one). Failed tool results go back flagged `is_error`. It sends no sampling parameters, since current Claude models reject them.
199
+
200
+ `openAIAdapter` sends `temperature: 0.2` unless you pass `temperature: null`, which omits the field; reasoning models reject any value other than their own default. Cached prompt tokens come from `prompt_tokens_details.cached_tokens` and are already inside `promptTokens`.
201
+
202
+ **Ollama.** Ollama exposes an OpenAI-compatible endpoint, so a local model needs no separate adapter:
203
+
204
+ ```ts
205
+ const model = openAIAdapter({
206
+ baseURL: 'http://localhost:11434/v1',
207
+ model: 'llama3.1', // pick a model whose Ollama page lists tool support
208
+ });
209
+ ```
210
+
211
+ The model has to support tool calling, or the agent cannot read state or run anything. I have not run this recipe myself; it follows from the endpoint being OpenAI-compatible, and reports either way are welcome.
152
212
 
153
213
  ## How `send()` behaves
154
214
 
155
215
  Each `send()` runs a turn loop of at most `options.maxTurns` model round trips (default 5). A few behaviors worth knowing:
156
216
 
157
- - **Conversation memory.** The prior LLM transcript is replayed with tool calls and their results intact across `send()` calls, so the agent remembers what it already did. `clearHistory()` resets it.
158
- - **Cancellation.** `send(message, { signal })` takes an `AbortSignal`. Aborts resolve with `error.code: 'ABORTED'` rather than throwing, and deliberately do not fire `onError`, since a cancel is a caller decision, not a failure.
159
- - **Turn budget.** When `maxTurns` runs out while the model is still calling tools, `send()` resolves with `error.code: 'MAX_TURNS'` and whatever tool calls accumulated.
160
- - **Token usage.** `AgentResponse.usage` totals prompt and completion tokens across every model call in the interaction, when the adapter reports them.
217
+ - **One at a time.** Concurrent `send()` calls queue and run strictly in call order, each starting from the transcript the previous one left. `history` therefore always alternates user, assistant, and `isProcessing` stays true from the first call until the last queued one settles.
218
+ - **Conversation memory.** The prior LLM transcript is replayed with tool calls and their results intact across `send()` calls, so the agent remembers what it already did. `clearHistory()` resets it. Calling `clearHistory()` while an interaction is running discards that interaction's records: its `send()` still resolves and `onError` still fires, but nothing it produced lands in `history`, `lastResponse`, or the transcript.
219
+ - **Events.** `options.onEvent` receives `turn_start`, `state_read`, `tool_start`, and `tool_end` as the loop runs, for progress UI and logging. Every `tool_start` gets exactly one `tool_end`; `tool_start` carries the raw arguments from the model and `tool_end` the validated value plus the final status.
220
+ - **Cancellation.** `send(message, { signal })` takes an `AbortSignal`. A signal linked to it reaches the adapter, `onConfirm`, and every tool handler as `context.signal`. An abort ends the wait on the adapter, on `onConfirm`, and on the handler even when they ignore the signal; forwarding the signal to `fetch` or whatever else the handler awaits is what stops the work itself, since a promise cannot be cancelled from outside. Aborts resolve with `error.code: 'ABORTED'` rather than throwing, and deliberately do not fire `onError`, since a cancel is a caller decision, not a failure.
221
+ - **Unmount.** Unmounting the provider aborts everything it owns. In-flight and queued interactions end with `error.code: 'ABORTED'` the same way a cancel does: `onConfirm`'s `signal` fires, handlers see `context.signal` aborted, `onError` is not called, and `send()` still settles, even when a confirmation UI unmounted without answering or a handler never looks at its signal. A `send()` reached through a stale reference after unmount resolves `ABORTED` without calling the model. StrictMode's simulated unmount in development gets a fresh controller and does not affect later sends.
222
+ - **Typed errors.** `error.code` is one of the codes below. Every code except `ABORTED` reaches `onError`. An exception that is not an `AdapterError` (a throwing `onToolCall` callback, for example) produces an error with no code. `send()` resolves with all of these; the one thing that makes it reject is `onError` itself throwing, and by then `history` and `lastResponse` are already written.
223
+ - **History carries errors.** The assistant entry of an interaction that ended with an error has that error on `entry.error`, `ABORTED` included, so a chat UI can render a failed turn in place.
224
+ - **Token usage.** `AgentResponse.usage` totals tokens across every model call in the interaction, when the adapter reports them. `promptTokens` is the total input including any cached portion; `cacheReadTokens` and `cacheWriteTokens` are subsets of it, present only when a provider reported them.
225
+
226
+ | `error.code` | Meaning |
227
+ |--------------|---------|
228
+ | `ABORTED` | The signal fired. Partial tool calls are kept; `onError` is not called |
229
+ | `MAX_TURNS` | `maxTurns` ran out while the model was still calling tools; `message` is empty |
230
+ | `ADAPTER_ERROR` | The adapter threw an `AdapterError`; `error.status` carries the HTTP status when there was one |
231
+ | `TRUNCATED` | The model hit its output token limit; `message` holds the partial text |
232
+ | `REFUSED` | The model declined to answer (Anthropic `refusal`, OpenAI `content_filter`); `message` holds whatever text came back |
161
233
 
162
234
  ## API reference
163
235
 
@@ -170,10 +242,12 @@ Everything the package exports:
170
242
  | `registerTool(name, handler, options?)` | Creates a validated tool definition |
171
243
  | `openAIAdapter(config)` | OpenAI chat completions adapter |
172
244
  | `claudeAdapter(config)` | Anthropic Messages API adapter |
245
+ | `AdapterError` | Error class thrown by the built-in adapters, with `status` and `body` |
246
+ | `validateToolArgs(tool, args)` | Runs a tool's `schema` or `parameters` check and returns the validated value; exported for custom wiring |
173
247
  | `validateToolNames`, `filterState`, `filterTools`, `validateToolCall` | Building blocks for testing and custom wiring; typical apps never call these |
174
- | Types | `ModelAdapter`, `AgentResponse`, `ToolDefinition`, and the rest of `src/types.ts` |
248
+ | Types | `ModelAdapter`, `AgentResponse`, `ToolDefinition`, `ToolHandler`, `ToolContext`, `AgentEvent`, `AgentErrorCode`, `TokenUsage`, `StopReason`, `StandardSchemaV1`, and the rest of `src/types.ts` |
175
249
 
176
- On `registerTool`: a tool needs a `description` to be shown to the model, and omitting `parameters` substitutes the empty object schema. Names beginning with `__` are reserved for internal tools (`__readState`) and rejected on mount, as are duplicate names.
250
+ On `registerTool`: a tool needs a `description` to be shown to the model, and omitting `parameters` substitutes the empty object schema. `schema` takes a Standard Schema validator and types the handler from its output. Names beginning with `__` are reserved for internal tools (`__readState`) and rejected on mount, as are duplicate names.
177
251
 
178
252
  ### `<AIAgentProvider>` props
179
253
 
@@ -200,22 +274,54 @@ On `registerTool`: a tool needs a `description` to be shown to the model, and om
200
274
  |-------|------|-------|
201
275
  | `debug` | `boolean` | Verbose console logging, prefixed `[react-observer-agent]` (default `false`) |
202
276
  | `maxTurns` | `number` | Max LLM round trips per `send()` (default `5`) |
277
+ | `maxStateBytes` | `number` | Per-key size cap on `__readState` results; oversized values become a truncation marker (default: no limit) |
203
278
  | `systemPrompt` | `string` | Prepended to the generated state manifest prompt |
204
279
  | `onError` | `(error: AgentError) => void` | Called when an interaction fails (except `ABORTED`) |
205
280
  | `onToolCall` | `(call: ToolCallEvent) => void` | Observer for every user-tool outcome |
206
- | `onConfirm` | `(call: PendingToolCall) => Promise<boolean>` | Approval handler for `confirm: true` tools |
281
+ | `onConfirm` | `(call: PendingToolCall) => Promise<boolean>` | Approval handler for `confirm: true` tools; `call.signal` aborts with the interaction |
282
+ | `onEvent` | `(event: AgentEvent) => void` | Observer for `turn_start`, `state_read`, `tool_start`, `tool_end` |
283
+
284
+ ### `AgentEvent`
285
+
286
+ | `type` | Fields | When |
287
+ |--------|--------|------|
288
+ | `turn_start` | `turn`, `maxTurns` | Right before each model call |
289
+ | `state_read` | `requested`, `keys` | After a `__readState` call resolves; `keys` is the allowed subset actually read |
290
+ | `tool_start` | `toolName`, `args` | Before the permission check, with the raw arguments |
291
+ | `tool_end` | `toolName`, `args`, `result`, `status` | Once per `tool_start`, with the validated arguments and final status |
207
292
 
208
293
  ### `useAgent()` returns
209
294
 
210
295
  | Field | Type | Notes |
211
296
  |-------|------|-------|
212
- | `send` | `(message, options?) => Promise<AgentResponse>` | `options.signal` cancels; resolves rather than rejects on errors |
213
- | `isProcessing` | `boolean` | True while an interaction is in flight |
214
- | `history` | `ConversationEntry[]` | User-facing conversation history for this provider instance |
297
+ | `send` | `(message, options?) => Promise<AgentResponse>` | Queued; `options.signal` cancels; resolves rather than rejects on errors, unless `onError` itself throws |
298
+ | `isProcessing` | `boolean` | True while any `send()` is pending or running |
299
+ | `history` | `ConversationEntry[]` | User-facing conversation history; assistant entries carry `error` when the interaction failed |
215
300
  | `clearHistory` | `() => void` | Resets history, the LLM transcript, and `lastResponse` |
216
301
  | `lastResponse` | `AgentResponse \| null` | Most recent response, including error responses |
217
302
 
218
- Tool call statuses in `AgentResponse.toolCalls` and `onToolCall`: `success`, `confirmed`, `cancelled`, `denied`, `error`.
303
+ ### `AgentError`
304
+
305
+ | Field | Type | Notes |
306
+ |-------|------|-------|
307
+ | `message` | `string` | |
308
+ | `code` | `AgentErrorCode` | `'ABORTED' \| 'MAX_TURNS' \| 'ADAPTER_ERROR' \| 'TRUNCATED' \| 'REFUSED'`; absent for other thrown exceptions |
309
+ | `status` | `number` | HTTP status, when the failure came from an adapter with one |
310
+ | `cause` | `unknown` | The original exception, when there was one |
311
+
312
+ ### Adapter config
313
+
314
+ | Option | `openAIAdapter` | `claudeAdapter` |
315
+ |--------|-----------------|-----------------|
316
+ | `apiKey` | Dev only | Dev only |
317
+ | `baseURL` | Proxy or alternate endpoint | Proxy or alternate endpoint |
318
+ | `model` | Default `gpt-4o` | Default `claude-opus-5` |
319
+ | `headers` | Extra request headers | Extra request headers, spread last |
320
+ | `temperature` | Default `0.2`; `null` omits the field | Not sent |
321
+ | `maxTokens` | Not sent | Default `16000` |
322
+ | `cache` | Not applicable | Default `true`; `false` disables prompt caching |
323
+
324
+ Tool call statuses in `AgentResponse.toolCalls`, `onToolCall`, and `tool_end`: `success`, `confirmed`, `cancelled`, `denied`, `error`. When a handler throws or `onConfirm` rejects, `result` keeps the text of what was thrown: an `Error`'s message, a thrown string as is, the `message` of a rejected object, other objects JSON-stringified, `'Unknown error'` for `null` or `undefined`. The model is told the same text.
219
325
 
220
326
  The full contracts, including the `ModelAdapter` interface for writing custom adapters, are in [SPEC.md](SPEC.md).
221
327
 
@@ -223,11 +329,9 @@ The full contracts, including the `ModelAdapter` interface for writing custom ad
223
329
 
224
330
  In rough priority order:
225
331
 
226
- 1. `ollamaAdapter` for local models
227
- 2. Streaming responses
228
- 3. Deeper argument validation
229
- 4. Transcript compaction, so long sessions stay under the context window
230
- 5. Per-tool permission scoping
332
+ 1. Streaming responses
333
+ 2. Transcript compaction, so long sessions stay under the context window
334
+ 3. Per-tool permission scoping
231
335
 
232
336
  Explicit non-goals for now: DOM awareness and page context mapping, automatic state detection, multi-agent orchestration, persistent memory, built-in rate limiting.
233
337
 
@@ -236,7 +340,7 @@ Explicit non-goals for now: DOM awareness and page context mapping, automatic st
236
340
  - [reactobserveragent.sudo-ezekiel.com](https://reactobserveragent.sudo-ezekiel.com): guides, API reference, and live examples you can click through
237
341
  - [sudo-ezekiel/react-observer-agent-examples](https://github.com/sudo-ezekiel/react-observer-agent-examples): the source for that site, including a runnable Zustand shopping app that proxies both providers
238
342
  - [SPEC.md](SPEC.md): the full technical spec
239
- - [docs/internals.md](docs/internals.md): pull-based state rationale and execution loop detail
343
+ - [docs/internals.md](docs/internals.md): pull-based state rationale, the interaction queue, events, and execution loop detail
240
344
  - [CHANGELOG.md](CHANGELOG.md)
241
345
 
242
346
  ## Disclaimer