@noetaris/harness 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,201 @@
1
+ # @noetaris/harness
2
+
3
+ Agent execution framework — the harness implementation.
4
+
5
+ ```
6
+ agent = llm + harness
7
+ ```
8
+
9
+ The harness is the reusable artifact: execution loop, state management, routing, and provider abstraction. The LLM is a swappable commodity component.
10
+
11
+ ## Installation
12
+
13
+ ```sh
14
+ pnpm add @noetaris/harness
15
+ ```
16
+
17
+ Requires Node.js ≥ 22.
18
+
19
+ ## Quick Start
20
+
21
+ ```ts
22
+ import { createHarness, createAgent, field, required, runtime } from '@noetaris/harness'
23
+
24
+ // 1. Define the dependency interface your steps will need
25
+ interface Ctx {
26
+ model: { invoke(messages: string[], opts: any): Promise<{ text: string; toolCalls: any[] }> }
27
+ tools: Record<string, any>
28
+ prompts: { system: string }
29
+ }
30
+
31
+ // 2. Create the harness — fixes Ctx and infers State from the field schema
32
+ const h = createHarness<Ctx>()({
33
+ messages: field<string[]>({ default: () => [], reduce: (a, b) => [...a, ...b] }),
34
+ toolCalls: field<any[]> ({ default: () => [] }),
35
+ })
36
+
37
+ // 3. Define the loop — validated immediately at call time
38
+ h.loop(l =>
39
+ l.start()
40
+ .step('think', {
41
+ run: async (state, ctx) => {
42
+ const result = await ctx.model.invoke(state.messages, {
43
+ tools: Object.values(ctx.tools),
44
+ })
45
+ return { messages: [result.text], toolCalls: result.toolCalls }
46
+ },
47
+ route: (state) => state.toolCalls.length > 0 ? 'call_tools' : 'complete',
48
+ })
49
+ .on('call_tools').to('action')
50
+ .on('complete').end()
51
+ .step('action', {
52
+ run: async (state, ctx) => ({
53
+ toolResults: await runTools(state.toolCalls, ctx.tools),
54
+ }),
55
+ })
56
+ .next('think')
57
+ )
58
+
59
+ // 4. Declare providers
60
+ h.provide('tools', { search: new MySearchTool() }) // hard-coded
61
+ h.provide('prompts', required()) // must be supplied at createAgent()
62
+ h.provide('model', runtime()) // must be supplied at agent.run()
63
+
64
+ // 5. Create an agent — assigns an ID and fills required() slots
65
+ const agent = createAgent('my-agent', h, {
66
+ prompts: { system: 'You are a helpful assistant.' },
67
+ })
68
+
69
+ // 6. Run
70
+ const run = agent.run(
71
+ { messages: ['What is the weather in Paris?'] },
72
+ { model: new MyLLMAdapter() },
73
+ )
74
+
75
+ const outcome = await run
76
+ console.log(outcome.signal, outcome.state)
77
+ ```
78
+
79
+ ## Concepts
80
+
81
+ ### The Loop
82
+
83
+ A harness loop is a directed graph. Steps are nodes; transitions are edges.
84
+
85
+ - **`run`** — transforms state. The only place state changes.
86
+ - **`route`** — reads post-run state, emits a named signal. Pure — no `ctx`, no mutation.
87
+
88
+ Three step patterns:
89
+
90
+ | Pattern | `run` | `route` | Transition |
91
+ |---|---|---|---|
92
+ | Transform + route | ✅ | ✅ | `.on(signal).to(step)` |
93
+ | Transform + next | ✅ | ❌ | `.next(name)` or implicit |
94
+ | Decision node | ❌ | ✅ | `.on(signal).to(step)` |
95
+
96
+ The loop structure is **validated at `h.loop()` call time**. Violations are thrown together as a `LoopValidationError` with a `violations: readonly string[]` property.
97
+
98
+ ### State
99
+
100
+ State is defined as a schema of `field<T>()` declarations. The framework infers the `State` type — no separate interface needed.
101
+
102
+ ```ts
103
+ const h = createHarness<Ctx>()({
104
+ messages: field<string[]>({
105
+ default: () => [],
106
+ reduce: (accumulated, update) => [...accumulated, ...update],
107
+ }),
108
+ count: field<number>({ default: () => 0 }),
109
+ })
110
+ // State = { messages: string[], count: number }
111
+ ```
112
+
113
+ Steps return a `Partial<State>` update. The `reduce` function merges accumulating fields; absent fields are replaced directly.
114
+
115
+ ### Providers
116
+
117
+ `h.provide()` is the single extension point — everything on `ctx` comes through it.
118
+
119
+ ```ts
120
+ h.provide('tools', { search: myTool }) // hard-coded — shared by all agents
121
+ h.provide('prompts', required()) // build-time — supplied at createAgent()
122
+ h.provide('model', runtime()) // per-run — supplied at agent.run()
123
+ ```
124
+
125
+ ### Session Store
126
+
127
+ `h.store()` adds persistence. The reserved `session` key is used by the framework to save and restore state across runs; all other keys are surfaced as `ctx.store.<name>`.
128
+
129
+ ```ts
130
+ import { InMemorySessionStore } from '@noetaris/harness-store'
131
+
132
+ h.store({
133
+ session: new InMemorySessionStore(), // framework-managed lifecycle
134
+ knowledge: new MyKnowledgeGraph(), // available as ctx.store.knowledge
135
+ })
136
+ ```
137
+
138
+ The framework injects `ctx.sessionId` automatically on every run — no declaration in `Ctx` needed.
139
+
140
+ ### Interrupts
141
+
142
+ A run can be stopped or resumed:
143
+
144
+ ```ts
145
+ const run = agent.run(initialState, slots)
146
+ run.stop() // request graceful stop at the next step boundary
147
+
148
+ // When a step calls ctx.interrupt(), the run settles with signal "$interrupt".
149
+ // Resume in the same process:
150
+ const resumed = run.resume(response, interruptId)
151
+
152
+ // Or cross-process (requires a session store):
153
+ const resumed = agent.resume(response, sessionId, interruptId)
154
+ ```
155
+
156
+ ## API
157
+
158
+ | Export | Description |
159
+ |---|---|
160
+ | `createHarness<Ctx>()(schema)` | Creates a harness. Fixes `Ctx`, infers `State` from schema. |
161
+ | `createAgent(id, h, slots)` | Assigns the agent an ID and fills `required()` slots. Returns an `Agent`. |
162
+ | `field<T>(opts)` | Declares a state field with a default and optional reduce function. |
163
+ | `required()` | Marks a provider slot as required at `createAgent()`. |
164
+ | `runtime()` | Marks a provider slot as required at `agent.run()`. |
165
+ | `composeObservers(...observers)` | Merges multiple `Observer` instances into one fan-out observer. |
166
+ | `SessionStore` | Interface for session persistence backends. |
167
+ | `StoredRun` | Type for a persisted run snapshot. Includes `agentId`, `runId`, `sessionId`, `phase`, and state. |
168
+ | `Observer` | Interface for telemetry hooks on run and step lifecycle events. |
169
+ | `ObserverAware` | Interface for provider objects that accept an `Observer` binding via `bindObserver()`. |
170
+ | `RunContext` | Context passed to run-level observer hooks — `agentId`, `sessionId`. |
171
+ | `StepContext` | Context passed to step-level observer hooks — `agentId`, `sessionId`, `stepName`. |
172
+ | `NoInterruptError` | Thrown when `resume()` is called but the session is not paused on a matching interrupt. |
173
+ | `SessionInFlightError` | Thrown when a session is already running. |
174
+ | `SessionPendingInterruptError` | Thrown when a session is paused on a pending interrupt — use `agent.resume()` instead of `agent.run()`. |
175
+ | `StoreLoadError` | Thrown when the session store fails to load state. |
176
+
177
+ ## Design Principles
178
+
179
+ - **The loop is a contract, not optional.** Every agent is a loop with defined entry, routing, and exit. Structure is validated at definition time.
180
+ - **State is the only spine.** Steps do not call each other — they write to state; the loop routes based on state.
181
+ - **The framework owns what is universal.** Loop lifecycle, state management, session store lifecycle. Never prompt content, tool behaviour, or provider APIs.
182
+ - **Built-ins are sugar, not magic.** `h.store()` is `h.provide()` plus lifecycle hooks — no hidden mechanism.
183
+
184
+ ## Requirements
185
+
186
+ - Node.js ≥ 22
187
+ - ESM only (`"type": "module"`)
188
+ - Zero runtime dependencies
189
+
190
+ ## Related Packages
191
+
192
+ - [`@noetaris/harness-store`](https://github.com/noetaris-lab/harness-store) — session store implementations (`InMemorySessionStore`, `LocalFileSessionStore`, etc.)
193
+ - [`@noetaris/harness-types`](https://github.com/noetaris-lab/harness-types) — shared LLM type contract (`LLM`, `Message`, `Tool`, `ToolCall`, `LLMResponse`)
194
+ - [`@noetaris/harness-anthropic`](https://github.com/noetaris-lab/harness-anthropic) — Anthropic Claude adapter
195
+ - [`@noetaris/harness-openai`](https://github.com/noetaris-lab/harness-openai) — OpenAI adapter
196
+ - [`@noetaris/harness-google`](https://github.com/noetaris-lab/harness-google) — Google Gemini adapter
197
+ - [`@noetaris/harness-otel`](https://github.com/noetaris-lab/harness-otel) — OpenTelemetry observer bridge
198
+
199
+ ## License
200
+
201
+ MIT