@slopus/happy-agent-base 0.0.2 → 0.0.3
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 +20 -635
- package/dist/Agent.d.ts +7 -0
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js +11 -0
- package/dist/Agent.js.map +1 -1
- package/dist/AgentBase.d.ts +15 -17
- package/dist/AgentBase.d.ts.map +1 -1
- package/dist/AgentBase.js +130 -229
- package/dist/AgentBase.js.map +1 -1
- package/dist/AgentBaseHooks.d.ts +1 -5
- package/dist/AgentBaseHooks.d.ts.map +1 -1
- package/dist/AgentBasePending.d.ts +3 -8
- package/dist/AgentBasePending.d.ts.map +1 -1
- package/dist/AgentBasePending.js +4 -13
- package/dist/AgentBasePending.js.map +1 -1
- package/dist/AgentFeature.d.ts +14 -1
- package/dist/AgentFeature.d.ts.map +1 -1
- package/dist/AgentKV.d.ts +0 -5
- package/dist/AgentKV.d.ts.map +1 -1
- package/dist/AgentKV.js +0 -8
- package/dist/AgentKV.js.map +1 -1
- package/dist/AgentPersistence.d.ts +0 -30
- package/dist/AgentPersistence.d.ts.map +1 -1
- package/dist/AgentProviders.d.ts +19 -9
- package/dist/AgentProviders.d.ts.map +1 -1
- package/dist/AgentProviders.js +15 -12
- package/dist/AgentProviders.js.map +1 -1
- package/dist/AgentStorage.d.ts +20 -0
- package/dist/AgentStorage.d.ts.map +1 -1
- package/dist/AgentStorage.js +33 -0
- package/dist/AgentStorage.js.map +1 -1
- package/dist/AgentSystem.d.ts +2 -0
- package/dist/AgentSystem.d.ts.map +1 -1
- package/dist/AgentSystemLocal.d.ts +12 -11
- package/dist/AgentSystemLocal.d.ts.map +1 -1
- package/dist/AgentSystemLocal.js +186 -67
- package/dist/AgentSystemLocal.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/AgentBaseStoreLock.d.ts +0 -16
- package/dist/AgentBaseStoreLock.d.ts.map +0 -1
- package/dist/AgentBaseStoreLock.js +0 -37
- package/dist/AgentBaseStoreLock.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,483 +1,31 @@
|
|
|
1
1
|
# @slopus/happy-agent-base
|
|
2
2
|
|
|
3
|
-
The minimal
|
|
3
|
+
The minimal durable runtime for Happy agents.
|
|
4
4
|
|
|
5
|
-
`AgentBase`
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
and feeds the results back. The conversation is observable only through hooks; there is no
|
|
10
|
-
external transcript or status surface.
|
|
5
|
+
`AgentBase` owns one agent's persistent inference and tool loop. It durably queues messages,
|
|
6
|
+
streams provider responses, executes tools, compacts history, resumes interrupted work, and
|
|
7
|
+
keeps inference, tool results, and settlement transactionally consistent across process
|
|
8
|
+
restarts.
|
|
11
9
|
|
|
12
|
-
|
|
10
|
+
The package also provides the primitives needed to host that runtime:
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
boundary.
|
|
12
|
+
- `Agent`, `AgentSystem`, and `AgentSystemLocal` for composing and addressing agents;
|
|
13
|
+
- `AgentPersistence`, `AgentStorage`, and `AgentKV` for durable state;
|
|
14
|
+
- `AgentProviders` for resolving provider/model routes;
|
|
15
|
+
- `AgentTool` and lifecycle hooks for extending the loop.
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
One `AgentSystem` exclusively owns one durable store. Every `AgentStorage` adapter must acquire a
|
|
18
|
+
hard database-level lock before the system starts; `AgentSystem.close()` stops its agents and
|
|
19
|
+
releases that lock. The runtime intentionally contains no CAS or multi-owner coordination.
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
Features may implement async `beforeStart(ctx, agents)` and `afterStart(ctx, agents)` hooks.
|
|
22
|
+
Every `beforeStart` settles successfully before active agents are restored; every `afterStart`
|
|
23
|
+
runs after those agents are restored and started. Both receive the system's `AgentSystemRef`.
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
readonly id: string;
|
|
30
|
-
readonly state: AgentBaseState; // the agent's own copy, mutable directly
|
|
31
|
-
|
|
32
|
-
steer(
|
|
33
|
-
ctx: Context,
|
|
34
|
-
message: SessionUserMessage,
|
|
35
|
-
options?: AgentBaseMessageOptions & AgentBaseAwaitOptions,
|
|
36
|
-
): Promise<void>;
|
|
37
|
-
send(
|
|
38
|
-
ctx: Context,
|
|
39
|
-
message: SessionUserMessage,
|
|
40
|
-
options?: AgentBaseMessageOptions & AgentBaseAwaitOptions,
|
|
41
|
-
): Promise<void>;
|
|
42
|
-
start(): void;
|
|
43
|
-
abort(ctx: Context, options?: AgentBaseAwaitOptions): Promise<void>;
|
|
44
|
-
compact(ctx: Context, options?: AgentBaseAwaitOptions): Promise<void>;
|
|
45
|
-
waitForIdle(): Promise<void>;
|
|
46
|
-
close(): Promise<void>;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface AgentBaseAwaitOptions {
|
|
50
|
-
await?: boolean; // default false: return once the agent has taken the request on
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
interface AgentBaseOptions {
|
|
54
|
-
id: string;
|
|
55
|
-
providers: AgentProviders;
|
|
56
|
-
provider: string; // registry ID; serializable alongside model and effort
|
|
57
|
-
persistence: AgentBasePersistence;
|
|
58
|
-
hooks?: AgentBaseHooks;
|
|
59
|
-
initialState?: Partial<AgentBaseState>; // copied into the agent's own state
|
|
60
|
-
model?: string;
|
|
61
|
-
effort?: SessionReasoningEffort;
|
|
62
|
-
serviceTier?: SessionServiceTier;
|
|
63
|
-
steeringMode?: "one-at-a-time" | "all"; // default "one-at-a-time"
|
|
64
|
-
sendMode?: "one-at-a-time" | "all"; // default "one-at-a-time"
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
interface AgentBaseState {
|
|
68
|
-
instructions: string;
|
|
69
|
-
tools: AnyAgentTool[];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
interface AgentBaseHooks {
|
|
73
|
-
onEvent?: (ctx: Context, event: SessionEvent) => void;
|
|
74
|
-
instructions?: (ctx: Context) => MaybePromise<string>; // extends state.instructions
|
|
75
|
-
tools?: (ctx: Context) => MaybePromise<readonly AnyAgentTool[]>; // extends state.tools
|
|
76
|
-
aroundToolExecution?: (
|
|
77
|
-
ctx: Context,
|
|
78
|
-
execution: AgentBaseToolExecution,
|
|
79
|
-
) => MaybePromise<unknown>; // after argument validation, immediately around execute
|
|
80
|
-
modelChanged?: (
|
|
81
|
-
ctx: Context,
|
|
82
|
-
change: AgentBaseModelChange,
|
|
83
|
-
) => MaybePromise<SessionSystemMessage | undefined>;
|
|
84
|
-
beforeAgentLoop?: (ctx: Context) => void;
|
|
85
|
-
beforeTurn?: (
|
|
86
|
-
ctx: Context,
|
|
87
|
-
turn: AgentBaseTurnStart,
|
|
88
|
-
) => MaybePromise<readonly AgentFeatureAction[] | undefined>;
|
|
89
|
-
beforeInference?: (ctx: Context) => void;
|
|
90
|
-
afterInference?: (ctx: Context, inference: AgentBaseInference) => MaybePromise<void>;
|
|
91
|
-
afterTurn?: (
|
|
92
|
-
ctx: Context,
|
|
93
|
-
turn: AgentBaseTurn,
|
|
94
|
-
) => MaybePromise<readonly AgentFeatureAction[] | undefined>;
|
|
95
|
-
afterAgentLoop?: (ctx: Context) => MaybePromise<readonly AgentFeatureAction[] | undefined>;
|
|
96
|
-
afterAgentSettled?: (ctx: Context) => MaybePromise<void>;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
interface AgentBaseToolExecution {
|
|
100
|
-
callId: string;
|
|
101
|
-
tool: AnyAgentTool;
|
|
102
|
-
arguments: unknown;
|
|
103
|
-
execute(): Promise<unknown>; // repeated calls join the same downstream execution
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
interface AgentBaseInference {
|
|
107
|
-
state: SessionDoneState | undefined;
|
|
108
|
-
tokens: SessionTokens | undefined; // absent when the response was cancelled or failed
|
|
109
|
-
errorMessage?: string;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
interface AgentBaseTurnStart {
|
|
113
|
-
contextTokens: number | undefined; // measured size of the context this turn runs on
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
interface AgentBaseTurn extends AgentBaseTurnStart {
|
|
117
|
-
aborted: boolean;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
interface AgentEnvironment {
|
|
121
|
-
osVersion: string;
|
|
122
|
-
platform: AgentPlatform; // the platforms Node reports
|
|
123
|
-
workingDirectory: string;
|
|
124
|
-
shell: string;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
interface AgentConfig {
|
|
128
|
-
environment?: AgentEnvironment; // all of it, or none of it
|
|
129
|
-
features?: { [featureName: string]: { [key: string]: unknown } };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
interface AgentFeature {
|
|
133
|
-
name: string;
|
|
134
|
-
// Plus any subset of AgentBaseHooks, each taking its AgentFeatureScope after the context.
|
|
135
|
-
// aroundToolExecution wrappers nest in feature order.
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
interface AgentFeatureScope {
|
|
139
|
-
agent: AgentFeatureAgent;
|
|
140
|
-
kv: AgentBaseKV; // this feature's store for this agent, outliving every run
|
|
141
|
-
sharedKV: AgentBaseKV; // this feature's store, shared by every agent in the collection
|
|
142
|
-
runKV: AgentBaseKV; // this feature's store for the run, erased when the agent settles
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
interface AgentFeatureAgent {
|
|
146
|
-
id: string;
|
|
147
|
-
provider: string; // registry ID
|
|
148
|
-
providerKind: ProviderModelCompatibilityType | undefined; // how that ID was registered
|
|
149
|
-
model: string | undefined;
|
|
150
|
-
effort: SessionReasoningEffort | undefined;
|
|
151
|
-
tier: SessionServiceTier | undefined;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
class AgentBaseKV {
|
|
155
|
-
readonly prefix: string; // absolute key prefix of this scope, ending with "."
|
|
156
|
-
scoped(...segments: string[]): AgentBaseKV; // narrower store under `segments`
|
|
157
|
-
read(ctx: Context, key: string): Promise<unknown>;
|
|
158
|
-
list(ctx: Context, prefix?: string): Promise<readonly { key: string; value: unknown }[]>;
|
|
159
|
-
write(ctx: Context, key: string, value: unknown): Promise<void>;
|
|
160
|
-
delete(ctx: Context, key: string): Promise<void>;
|
|
161
|
-
clear(ctx: Context): Promise<void>; // every entry in the scope, including narrower ones
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
interface AgentBaseModelChange {
|
|
165
|
-
previousModel: string | undefined;
|
|
166
|
-
model: string;
|
|
167
|
-
previousProvider: string;
|
|
168
|
-
provider: string;
|
|
169
|
-
providers: AgentProviders;
|
|
170
|
-
previousProviderInstance: BaseProvider | null;
|
|
171
|
-
providerInstance: BaseProvider | null;
|
|
172
|
-
wasReset: boolean; // the change was incompatible and the history was erased
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
interface AgentBaseMessageOptions {
|
|
176
|
-
provider?: string; // registry ID to switch to
|
|
177
|
-
model?: string;
|
|
178
|
-
effort?: SessionReasoningEffort;
|
|
179
|
-
serviceTier?: SessionServiceTier;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
type AgentFeatureAction =
|
|
183
|
-
| { type: "steer"; message: SessionUserMessage }
|
|
184
|
-
| { type: "send"; message: SessionUserMessage }
|
|
185
|
-
| { type: "compact" };
|
|
186
|
-
|
|
187
|
-
interface AgentTool<Args extends TSchema = TSchema, Result extends TSchema = TSchema> {
|
|
188
|
-
// The provider-facing descriptor fields of SessionTool, with parameters typed as Args, plus:
|
|
189
|
-
durable?: boolean;
|
|
190
|
-
autoPermissionInstructions?: string;
|
|
191
|
-
describeAutoPermissionAction?: (args: Static<Args>, ctx: Context) => string;
|
|
192
|
-
requiresAutoOrFullAccess?: boolean;
|
|
193
|
-
shouldReviewInAutoMode: (args: Static<Args>, ctx: Context) => boolean | Promise<boolean>;
|
|
194
|
-
shouldRunInFullAccessInAutoMode?: (
|
|
195
|
-
args: Static<Args>,
|
|
196
|
-
ctx: Context,
|
|
197
|
-
) => boolean | Promise<boolean>;
|
|
198
|
-
returnType: Result;
|
|
199
|
-
execute(ctx: Context, args: Static<Args>): Promise<Static<Result>>;
|
|
200
|
-
toLLM(result: Static<Result>): readonly SessionOutputBlock[];
|
|
201
|
-
isError?(result: Static<Result>): boolean;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function defineAgentTool<const Args extends TSchema, const Result extends TSchema>(
|
|
205
|
-
tool: AgentTool<Args, Result>,
|
|
206
|
-
): AgentTool<Args, Result>;
|
|
207
|
-
|
|
208
|
-
interface AgentBasePersistence {
|
|
209
|
-
transaction<Result>(ctx: Context, work: (ctx: Context) => Promise<Result>): Promise<Result>;
|
|
210
|
-
load(ctx: Context): Promise<readonly AgentBaseRecord[]>;
|
|
211
|
-
append(ctx: Context, record: AgentBaseRecord): Promise<void>;
|
|
212
|
-
clearRecords(ctx: Context): Promise<void>; // physical delete, used inside the compaction transaction
|
|
213
|
-
readValues(ctx: Context, prefix: string): Promise<readonly { key: string; value: unknown }[]>;
|
|
214
|
-
writeValue(ctx: Context, key: string, value: unknown): Promise<void>;
|
|
215
|
-
writeValueIfAbsent(ctx: Context, key: string, value: unknown): Promise<boolean>; // claim
|
|
216
|
-
writeValueIfUnchanged( // compare and set
|
|
217
|
-
ctx: Context,
|
|
218
|
-
key: string,
|
|
219
|
-
expected: unknown,
|
|
220
|
-
value: unknown,
|
|
221
|
-
): Promise<boolean>;
|
|
222
|
-
deleteValue(ctx: Context, key: string): Promise<void>;
|
|
223
|
-
deleteValueIfPresent(ctx: Context, key: string): Promise<boolean>; // claim
|
|
224
|
-
}
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
The three conditional operations are what make one storage safe to share between owners who
|
|
228
|
-
cannot see each other: each performs its check and its write as a single atomic step, so of two
|
|
229
|
-
owners racing for one key exactly one is told it won.
|
|
230
|
-
|
|
231
|
-
Persistence is an append-only main context store plus a sorted key-value store alongside it. The
|
|
232
|
-
agent serializes every operation through one internal lock (configured to crash on re-entry), so
|
|
233
|
-
implementations never see concurrent calls and need no locking of their own.
|
|
234
|
-
|
|
235
|
-
A queued message is written under a `steering.` or `send.` key ordered by append time;
|
|
236
|
-
nothing that is not yet part of the context reaches the main store. When a queue drains, each
|
|
237
|
-
consumed message is appended as a user record and its queue key deleted, all inside one
|
|
238
|
-
`transaction` so a crash or failure can never leave a message in both stores or neither, and
|
|
239
|
-
only then does inference run on the resulting context. Transactions are completely transparent
|
|
240
|
-
to the agent: the implementation opens one, hands work a derived context its own operations
|
|
241
|
-
recognize, and carries the transaction on that context however it likes; work resolving
|
|
242
|
-
commits, a thrown error rolls back. Assistant output is appended one finished block at a time as
|
|
243
|
-
it streams, so main-store records always arrive in context order and consecutive block records
|
|
244
|
-
reassemble into one assistant message on load. The agent loop loads everything once, on the first
|
|
245
|
-
inference attempt; the load result replaces the in-memory state, including leftover queued
|
|
246
|
-
messages from an earlier process, which join the next turn. A failed load is reported as an
|
|
247
|
-
`internal_error` done event and is not sticky: the next requested turn retries it, with every
|
|
248
|
-
queued message still safely waiting.
|
|
249
|
-
|
|
250
|
-
The two queues give four delivery strategies, mirroring Pi:
|
|
251
|
-
|
|
252
|
-
| Strategy | Behavior |
|
|
253
|
-
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
254
|
-
| Steering + one-at-a-time | `steer` queues FIFO. After the current assistant response and all its tool calls finish, the oldest message injects, gets a response, then the next is handled. |
|
|
255
|
-
| Steering + all | After the current response and tool batch finish, every queued steering message injects together before one response. |
|
|
256
|
-
| Send + one-at-a-time | `send` waits until the agent would otherwise stop — no tool calls or steering remain — then injects one message and waits for its response before draining another. |
|
|
257
|
-
| Send + all | Once the agent would otherwise stop, every queued sent message injects together before one response. |
|
|
258
|
-
|
|
259
|
-
Both modes default to `"one-at-a-time"`, and steering always takes precedence over sent messages.
|
|
260
|
-
Queue consumption happens only between inferences — never mid-stream and never during a tool
|
|
261
|
-
batch — so an injected message can never interleave with an active response's block records.
|
|
262
|
-
|
|
263
|
-
`steer` and `send` resolve once the durable queue write lands, so a failed write keeps the
|
|
264
|
-
message out of the conversation; they wait neither for the history load nor for the turn. Each
|
|
265
|
-
message may carry its own inference settings — provider, model, effort, and service tier —
|
|
266
|
-
which take effect when the message is consumed and stay effective for every later message that does not
|
|
267
|
-
override them, surviving restarts through a durable settings entry. A message without settings
|
|
268
|
-
uses the previously effective values, or the constructor defaults when nothing was ever carried;
|
|
269
|
-
relying on those defaults is discouraged — prefer sending settings with the message.
|
|
270
|
-
|
|
271
|
-
A provider or model change is checked against the provider-model compatibility matrix from
|
|
272
|
-
`@slopus/happy-providers`, using the compatibility types the providers were registered with. A
|
|
273
|
-
compatible change keeps the conversation; a compatible provider change still gets a fresh
|
|
274
|
-
session on the new provider, since a session is bound to the provider that created it. An
|
|
275
|
-
incompatible change — including a switch to a different provider of the same type, or to an
|
|
276
|
-
unregistered ID — resets the conversation: the durable history is erased completely, the old
|
|
277
|
-
provider session is destroyed, and a fresh session serves the new selection. The `modelChanged`
|
|
278
|
-
hook fires on every selection change with the old and new model, both provider IDs and live
|
|
279
|
-
instances, the registry, and the `wasReset` flag; on a reset the handoff system message it
|
|
280
|
-
returns is injected at the very beginning of the fresh context — without one the context starts
|
|
281
|
-
completely empty. The consumed message that carried the new selection follows the handoff. A
|
|
282
|
-
hook failure during an incompatible change rejects the switch outright: the previous selection
|
|
283
|
-
stays effective and the history is not cleared. A
|
|
284
|
-
thrown provider or load failure is reported to the `onEvent` hook as an `internal_error` done
|
|
285
|
-
event instead of rejecting the loop. The agent never retries inference itself — providers own
|
|
286
|
-
retry semantics and surface them as `retrying` events. A provider-reported error response ends
|
|
287
|
-
that response but not the turn: messages still queued drain into a fresh inference, each drain
|
|
288
|
-
consuming from a finite queue, so a persistently failing provider cannot loop.
|
|
289
|
-
|
|
290
|
-
A turn that ends failed surfaces its error to the context as a durable system message
|
|
291
|
-
(`The last turn failed: <message>`), so the next inference sees what went wrong. Only
|
|
292
|
-
unrecovered failures leave this trace: a provider-reported error followed by a successful
|
|
293
|
-
response in the same turn recovers silently, and a failed history load appends nothing since
|
|
294
|
-
there is no loaded context to append to.
|
|
295
|
-
|
|
296
|
-
When a turn stops for tool calls, every call in the batch runs in parallel. Arguments are
|
|
297
|
-
validated against the tool's TypeBox `parameters` schema before `execute` runs, so `execute`
|
|
298
|
-
receives them as `Static<Args>` rather than unknown. `execute` returns a structured result that
|
|
299
|
-
is validated against `returnType` and then rendered into output blocks for the model with
|
|
300
|
-
`toLLM`; an optional `isError` predicate marks a structured result as an error. A missing tool,
|
|
301
|
-
invalid JSON arguments, arguments that fail the schema, an incomplete call, a thrown `execute`,
|
|
302
|
-
or a result that fails `returnType` becomes an error tool result
|
|
303
|
-
(`isError: true`) for the model instead of failing the run; provider-settled server calls are
|
|
304
|
-
never executed by the agent, and their streamed `toolcall_result_*` events are simply ignored —
|
|
305
|
-
the server call block stays in the history, the events reach the hooks, and no tool result
|
|
306
|
-
message is stored or owed.
|
|
307
|
-
|
|
308
|
-
Before any call in a batch executes, the whole batch is committed to the sorted store under
|
|
309
|
-
`tool.` keys ordered by position, so a crash mid-batch leaves a durable record of the calls still
|
|
310
|
-
owed a result. Calls run in parallel, but results land strictly in call order: a finished result
|
|
311
|
-
waits until every earlier call in the batch has committed, and each commit appends the `tool`
|
|
312
|
-
record and deletes the pending entry in one transaction. Once the batch is complete the loop runs
|
|
313
|
-
inference again with the full context.
|
|
314
|
-
|
|
315
|
-
`start` begins the loop without a new message: it loads the durable state and continues a turn
|
|
316
|
-
that was cut off by a crash — leftover queued messages are consumed, a dispatched `tool.`
|
|
317
|
-
batch is settled, and an unanswered user or tool message gets its inference. When an interrupted
|
|
318
|
-
batch resumes, only tools marked `durable: true` execute again; every other interrupted call
|
|
319
|
-
becomes an error tool result, since the agent cannot know whether its side effects already
|
|
320
|
-
happened. On an idle history `start` loads and does nothing more.
|
|
321
|
-
|
|
322
|
-
`compact` compacts the conversation through the provider session. It waits for the active turn
|
|
323
|
-
to end — including queued messages already draining — or runs right away when idle, snapshots
|
|
324
|
-
the history, and asks the provider to compact it. The completed replacement context supersedes
|
|
325
|
-
the compacted history while any message that joined after the snapshot is kept. In one atomic
|
|
326
|
-
transaction the superseded records are physically deleted and the replacement — the messages
|
|
327
|
-
that stay — is appended as a `compaction` record, which then opens the store while later
|
|
328
|
-
records append as usual. `compact` returns once the compaction has been asked for; with `await: true` it returns once it
|
|
329
|
-
has run, and callers waiting while one is pending or running all wait for that same shared
|
|
330
|
-
compaction, which resolves on completion and rejects when the provider reports failure, leaving
|
|
331
|
-
the history untouched.
|
|
332
|
-
|
|
333
|
-
Hooks receive the agent's context first. That context — shared by tool executions — is derived
|
|
334
|
-
once at construction and carries the agent's ID, provider registry ID, model, effort, and
|
|
335
|
-
service tier — all serializable values — readable through the exported `agentBaseId`,
|
|
336
|
-
`agentBaseProvider`, `agentBaseModel`, `agentBaseEffort`, and `agentBaseServiceTier` accessors.
|
|
337
|
-
The
|
|
338
|
-
`instructions` and `tools` hooks extend the mutable state — the state comes first, the hook's
|
|
339
|
-
answer follows — and are consulted for session creation, every inference request, compaction,
|
|
340
|
-
and tool lookup. They are correctness hooks: a failure there, including two tools sharing one
|
|
341
|
-
name and namespace in the merged list, fails the turn loudly instead of silently running with a
|
|
342
|
-
wrong configuration. Before every inference the provider-facing configuration is compared with
|
|
343
|
-
the one the session was created with, and a changed prompt or changed tool descriptors recreate
|
|
344
|
-
the provider session so the model always sees the tools the agent would actually execute. Both
|
|
345
|
-
hooks — like `modelChanged`, `afterTurn`, and `afterAgentLoop` — may return promises.
|
|
346
|
-
|
|
347
|
-
The context also carries a scoped key-value store, readable through the exported `agentBaseKV`
|
|
348
|
-
accessor. The store is an `AgentBaseKV` view over the agent's sorted store under
|
|
349
|
-
`kv.<agent id>.`, executing every operation through the agent's own persistence lock, with keys
|
|
350
|
-
always relative to the scope — a holder can neither see nor touch anything outside it, and
|
|
351
|
-
`scoped(segment)` narrows further. Hooks receive the session scope; a tool execution receives
|
|
352
|
-
the store narrowed to `call.<call ID>`, so a tool call persists under its own call ID and never
|
|
353
|
-
in another call's scope. Beside it the context carries a second store of the same shape, under
|
|
354
|
-
`kv.<agent id>.run.` and readable through `agentRunKV`, which belongs to the run rather than to
|
|
355
|
-
the conversation: the transaction that settles the agent erases the whole of it, so what a run
|
|
356
|
-
wrote about itself never reaches the next one. The `modelChanged` hook fires while the agent
|
|
357
|
-
holds its persistence lock, so its stores execute directly on the held lock instead of
|
|
358
|
-
deadlocking.
|
|
359
|
-
|
|
360
|
-
The lifecycle hooks bracket the loop's own structure. `beforeAgentLoop` fires when the loop
|
|
361
|
-
leaves the settled state and begins working, and `afterAgentLoop` fires when it would settle
|
|
362
|
-
back to idle; between them, each turn is bracketed by `beforeTurn` and `afterTurn`, and each
|
|
363
|
-
inference request inside a turn by `beforeInference` and `afterInference`. `beforeTurn`,
|
|
364
|
-
`afterTurn`, and `afterAgentLoop` may return an array of `AgentFeatureAction`s, all applied
|
|
365
|
-
together before the loop continues: `steer` and `send` queue a message through the ordinary
|
|
366
|
-
durable queues exactly as the public methods do, and `compact` triggers the shared compaction.
|
|
367
|
-
Actions from `beforeTurn` are carried out by the turn that is about to run — a compaction it
|
|
368
|
-
asks for happens before that turn's first inference; actions from `afterTurn` drive the loop
|
|
369
|
-
into another turn within the same loop span; actions from `afterAgentLoop` reopen the loop
|
|
370
|
-
instead of settling. Like every hook, a thrown lifecycle hook — or a failing action —
|
|
371
|
-
never fails the run.
|
|
372
|
-
|
|
373
|
-
The agent tracks the conversation's true size from the provider's own token counts, so nothing
|
|
374
|
-
has to estimate it or watch the event stream for it. `afterInference` receives how each response
|
|
375
|
-
ended and the counts it measured — the complete input context the provider received plus the
|
|
376
|
-
output it generated, which is where the next request starts from. Their sum becomes the agent's
|
|
377
|
-
context size, persisted under the `context` key and restored on load, so a restarted agent knows
|
|
378
|
-
how large its conversation is before it runs anything. A cancelled or failed response measures
|
|
379
|
-
nothing and reports no counts, leaving the last real measurement in place; a completed
|
|
380
|
-
compaction clears it, since the conversation it described is gone. Both turn hooks carry that
|
|
381
|
-
size as `contextTokens`, allowing an external feature to return a `compact` action from
|
|
382
|
-
`beforeTurn` when its own threshold is reached.
|
|
383
|
-
|
|
384
|
-
`abort` cancels the active turn; when idle it is a no-op. It returns once the cancellation has
|
|
385
|
-
been signalled, and with `await: true` once the loop has actually unwound.
|
|
386
|
-
The inference stream is abandoned and asked to close, a `done` event with state `cancelled` is
|
|
387
|
-
emitted, blocks that already finished stay in the history while an unfinished block is dropped
|
|
388
|
-
everywhere, and each still-running tool call settles as an error tool result saying it was
|
|
389
|
-
aborted — consuming its pending `tool.` entry so the batch leaves a complete context behind.
|
|
390
|
-
The queued turn request is dropped too, but messages still waiting in the steering and
|
|
391
|
-
send queues stay durable and join the next requested turn.
|
|
392
|
-
|
|
393
|
-
`Agent` is a thin wrapper around `AgentBase` that assembles its behavior from an array of
|
|
394
|
-
`AgentFeature`s instead of one hooks object. Each feature carries a required stable `name` and
|
|
395
|
-
implements any subset of the hooks; the agent merges them, in array order, into the singular
|
|
396
|
-
private hooks its internal base runs with. Every hook receives the agent's context first and its
|
|
397
|
-
own `AgentFeatureScope` second: the agent it is serving — identity, provider registry ID and
|
|
398
|
-
kind, model, effort, and tier — and its three stores, each narrowed to `feature.<name>`, so
|
|
399
|
-
features never see each other's persisted entries and renaming a feature orphans everything it
|
|
400
|
-
stored. `kv` belongs to that one agent's conversation, `sharedKV` to the whole collection, and
|
|
401
|
-
`runKV` to the run in progress and is erased when it settles. They are handed over rather than
|
|
402
|
-
read off the context, so a hook is given exactly what it is entitled to and can never be passed
|
|
403
|
-
a context that quietly means another agent. Features are independent: observing hooks — events and lifecycle brackets — fan out with
|
|
404
|
-
per-feature isolation so one throwing feature never silences the others, and lifecycle actions
|
|
405
|
-
concatenate with a failing feature losing only its own actions. Instructions and tools
|
|
406
|
-
concatenate after the base state and stay loud: a failing feature fails the turn. For a model
|
|
407
|
-
change every feature observes the change, the first returned handoff wins, and a feature
|
|
408
|
-
failure during an incompatible change rejects the switch so the history survives. `feature(name)`
|
|
409
|
-
hands back the instance running under that name, which is how the owner of an agent reaches what
|
|
410
|
-
belongs to it — a goal to pause, for instance.
|
|
411
|
-
|
|
412
|
-
`AgentSystem` is the type of a collection of agents, and `AgentSystemLocal` is the implementation
|
|
413
|
-
that lazily resolves and owns the `Agent` instances of one. An agent exists only
|
|
414
|
-
once `create(ctx, config)` has generated its cuid2 identity: the `AgentConfig` is validated,
|
|
415
|
-
persisted under the collection's storage, and then stays in effect for the agent's whole life,
|
|
416
|
-
so `resolve` on an ID that was never created is an error and `create` on one that already
|
|
417
|
-
exists is too. The configuration carries the environment the agent works on — `osVersion`,
|
|
418
|
-
`platform`, `workingDirectory`, and `shell`, all of them or none, so nothing the agent is told
|
|
419
|
-
about its machine is ever half-true — plus one opaque settings map per feature, keyed by feature
|
|
420
|
-
name; the agent never looks inside a feature's entry, so a feature validates its own against its
|
|
421
|
-
own schema. Every context the agent derives carries the configuration, readable
|
|
422
|
-
through the exported `agentConfig` and `agentFeatureConfig` accessors, from the first hook of a
|
|
423
|
-
feature all the way down to a tool execution.
|
|
424
|
-
|
|
425
|
-
A collection is given its features as instances the caller has already built and which are ready
|
|
426
|
-
to serve — there is no load step. One instance serves every agent the collection builds, so it
|
|
427
|
-
learns which agent a hook is running for from the scope it is handed rather than from anything
|
|
428
|
-
it was constructed with, and keeps what one run remembers keyed by that ID, dropping it when the
|
|
429
|
-
agent settles.
|
|
430
|
-
|
|
431
|
-
`start(ctx)` resolves and resumes every agent that was still
|
|
432
|
-
working when the previous process stopped, and `steer`, `send`, `abort`, and `compact` resolve
|
|
433
|
-
an agent by ID before acting on it, forwarding the same options the agent takes.
|
|
434
|
-
A feature's `sharedKV` is durable storage shared by every agent in the collection and outliving
|
|
435
|
-
all of them — an agent's own store belongs to its conversation and is cleared when the ID is
|
|
436
|
-
created again, so work one agent owes another lives here instead. `delete` closes an
|
|
437
|
-
agent and releases its identity while leaving what it wrote in place; creating the ID again is
|
|
438
|
-
what clears the store, so the new agent never wakes up inside its predecessor's conversation.
|
|
439
|
-
|
|
440
|
-
Asking and waiting are separate everywhere. `steer`, `send`, `abort`, and `compact` all return
|
|
441
|
-
once the agent has taken the request on, and `await: true` asks for the part only the run loop
|
|
442
|
-
can give — the durable write, the finished compaction, the unwound turn. That flag is refused,
|
|
443
|
-
with an error naming the problem, when the caller's context says it is running inside the loop of
|
|
444
|
-
the agent it is asking: a hook or a tool runs while its own agent's loop waits for it, so waiting
|
|
445
|
-
for that agent is waiting for itself. The check is per agent, so work inside one agent's loop may
|
|
446
|
-
still wait on another's — which is what makes a subagent's report to its parent safe. Two agents
|
|
447
|
-
each inside a tool are the one cross-agent case that cannot be allowed to wait: a waited-for
|
|
448
|
-
`compact` on an agent that is running a tool, asked from inside a turn, is refused outright,
|
|
449
|
-
because the tool it would wait for may be waiting for the caller. Note that
|
|
450
|
-
an operation nobody waits for still reports nothing: a fire-and-forget `send` whose durable write
|
|
451
|
-
fails is a message that silently never arrives.
|
|
452
|
-
|
|
453
|
-
`AgentSystemRef` and `AgentRef` are the same collection seen from inside an agent, and the only
|
|
454
|
-
form of it a context ever carries: a collection puts a reference on every context it derives, so
|
|
455
|
-
a feature hook or a tool — code some loop is waiting for — can never reach an operation that
|
|
456
|
-
waits for a loop. `close`, `waitForIdle`, `delete`, and `start` are absent entirely, `create` and
|
|
457
|
-
`resolve` hand back an `AgentRef` rather than the `Agent` that would carry them, and `compact`
|
|
458
|
-
and `abort` are requests that return once they have been made. The returned `AgentRef.id` is the
|
|
459
|
-
same system-generated cuid2 used for all later addressing.
|
|
460
|
-
|
|
461
|
-
A message is the one thing a caller here is told about, because accepting one is a durable queue
|
|
462
|
-
write rather than a turn: addressed to another agent, `steer` and `send` resolve once the message
|
|
463
|
-
really is part of that agent's conversation and reject when the write fails, which is what lets a
|
|
464
|
-
child know its parent has its report. Addressed to the agent the caller is running inside — whose
|
|
465
|
-
loop would have to make that write — the message is queued and nothing is waited for. The
|
|
466
|
-
context decides, since it names the agent the caller is in; a context naming none proves nothing
|
|
467
|
-
and waits for nothing. The agent's own `await: true`, which also covers a finished compaction and
|
|
468
|
-
an unwound turn, is never offered here and never passed on.
|
|
469
|
-
|
|
470
|
-
`currentAgentEnvironment()` reads a complete environment from the running process, ready to hand
|
|
471
|
-
to `create`.
|
|
472
|
-
|
|
473
|
-
`AgentProviders` is a mutable registry of provider instances keyed by caller-supplied IDs, so the
|
|
474
|
-
same provider class can be registered under several IDs. `add(id, provider, type)` registers an
|
|
475
|
-
instance together with its compatibility type (`"claude"`, `"codex"`, `"grok"`, `"bedrock"`, or
|
|
476
|
-
`"gym"`), `get(id)` returns the provider or null, and `typeOf(id)` returns the registered type
|
|
477
|
-
or null. The agent is configured entirely with serializable values — a
|
|
478
|
-
provider registry ID, a model name, an effort level, and a service tier — and resolves the live provider from
|
|
479
|
-
the registry when the session is first created; an ID that is not registered at that moment
|
|
480
|
-
fails the turn like any thrown error.
|
|
25
|
+
This package contains no ready-made product features. Reusable tools, hooks, permissions,
|
|
26
|
+
workspaces, search, workflows, and other capabilities belong in
|
|
27
|
+
[`@slopus/happy-agent-features`](../happy-agent-features). Provider protocols and vendor
|
|
28
|
+
implementations belong in [`@slopus/happy-providers`](../happy-providers).
|
|
481
29
|
|
|
482
30
|
## Validation
|
|
483
31
|
|
|
@@ -486,166 +34,3 @@ pnpm --filter @slopus/happy-agent-base check
|
|
|
486
34
|
pnpm --filter @slopus/happy-agent-base test
|
|
487
35
|
pnpm --filter @slopus/happy-agent-base build
|
|
488
36
|
```
|
|
489
|
-
|
|
490
|
-
## Chaos tests
|
|
491
|
-
|
|
492
|
-
`tests/chaos/` breaks the agent on purpose, in every way the machinery claims to survive, and
|
|
493
|
-
checks that the durable state keeps its promises anyway. The suites share one harness: a disk
|
|
494
|
-
that outlives each process, a store that can die or misbehave on a seeded schedule, and a model
|
|
495
|
-
that answers from the conversation it is handed rather than from a script — so a restarted agent
|
|
496
|
-
hears exactly what its dead predecessor did, down to the call IDs. Tools refuse to run for a
|
|
497
|
-
process that has already died, so a zombie's side effect is never mistaken for the world's.
|
|
498
|
-
|
|
499
|
-
| Suite | What it breaks |
|
|
500
|
-
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
501
|
-
| `crashRecovery` | the process dies at a random operation, again and again, until one is finally left alone to finish |
|
|
502
|
-
| `queues` | a mixture of sent and steering messages, each accepted just before a crash |
|
|
503
|
-
| `compaction` | the one operation that destroys history, interrupted inside its transaction |
|
|
504
|
-
| `compactionUnderLoad` | the same, with messages still waiting in both queues and calls in flight |
|
|
505
|
-
| `interruption` | the turn is aborted at a random point in the event stream, sometimes while crashing too |
|
|
506
|
-
| `flakyStore` | writes fail and the process keeps running, so the agent has to cope rather than stop |
|
|
507
|
-
| `concurrency` | nothing crashes; messages, interruptions and starts simply arrive all at once |
|
|
508
|
-
| `modelSwitch` | an incompatible model change, which resets the conversation, crashed around |
|
|
509
|
-
| `ownershipRaces` | boundaries one owner's lock cannot cover: two live instances on one store, two managers claiming one identity, and lifecycle calls racing the work they own |
|
|
510
|
-
| `managerRecoveryRaces` | the seam between a live agent, its collection's discovery index, and a second collection over the same durable identity |
|
|
511
|
-
| `coreLoopConsistencyGaps` | the loop stopped at exact ownership handoffs: a lock that escapes its hook, a batch split in half, a phantom turn, a reentrant close, a contradicted terminal event |
|
|
512
|
-
| `featureConsistencyRaces` | features under partial failure: a half-finished spawn, a lost completion, a goal completed and paused at once, and scopes that collide through a dot |
|
|
513
|
-
|
|
514
|
-
Each suite runs 60 seeds by default. `CHAOS_SEEDS=5000 pnpm test` turns the same suites into a
|
|
515
|
-
long hunt for rarer interleavings; the whole set has been swept clean at that size.
|
|
516
|
-
|
|
517
|
-
What the seeds prove, however the run was broken:
|
|
518
|
-
|
|
519
|
-
- every message reaches the conversation exactly once — none lost between the durable queue and
|
|
520
|
-
the context, none replayed by the resume that followed, and each queue keeps its order across
|
|
521
|
-
restarts;
|
|
522
|
-
- every call is settled exactly once, no result exists without its call, and the conversation
|
|
523
|
-
never keeps a tool call the model will not get an answer for;
|
|
524
|
-
- a non-durable tool never runs twice, while a durable one may;
|
|
525
|
-
- nothing is reported as finished before it is durable;
|
|
526
|
-
- a compacted conversation comes back whole or replaced, never half-erased, and a message still
|
|
527
|
-
waiting in a queue survives the replacement;
|
|
528
|
-
- an incompatible model switch lands on one side or the other, never the old history under the
|
|
529
|
-
new model;
|
|
530
|
-
- the agent settles owing nothing: no queued message, no pending call, and a final answer — and
|
|
531
|
-
says so durably, so another process reads a committed fact rather than guessing;
|
|
532
|
-
- one accepted message is answered once even when two live owners hold the same store, and a
|
|
533
|
-
turn always answers the durable conversation rather than the one its instance remembers;
|
|
534
|
-
- one response has exactly one terminal event, and a store handed to a hook is a capability that
|
|
535
|
-
expires with the call.
|
|
536
|
-
|
|
537
|
-
Breaking things on purpose has paid for itself several times over. Every bug below was found by
|
|
538
|
-
a chaos seed and has its own focused test in `tests/blackbox/`:
|
|
539
|
-
|
|
540
|
-
- **Queue keys collided across a restart.** The sequence counter began again at zero in each
|
|
541
|
-
process, so two messages accepted in the same millisecond by two processes shared one key and
|
|
542
|
-
the first was overwritten. Keys are now ordered by what the store already holds.
|
|
543
|
-
- **A crash between a response's last block and the batch commit left a call nobody would ever
|
|
544
|
-
answer.** There was no pending entry to resume from. Such calls have certainly not run, since
|
|
545
|
-
the commit precedes every execution, so they are recovered as the batch they never got to be.
|
|
546
|
-
- **A turn that failed after emitting a call left it unanswered**, and the next message was
|
|
547
|
-
appended behind it. A turn now settles its own calls with an error result before it gives up.
|
|
548
|
-
- **A conversation could still be stranded** when even that failed, with the gap buried under
|
|
549
|
-
later messages. Load repairs it by rewriting the conversation atomically — the only way, since
|
|
550
|
-
the answer belongs beside its call rather than at the end.
|
|
551
|
-
- **A failed turn was never retried.** The note it leaves behind means the question was never
|
|
552
|
-
answered, so a restarted agent now owes a response and has the note for context.
|
|
553
|
-
- **A restart could answer a compaction.** Whether a restart owes a response is now decided by
|
|
554
|
-
the last durable record rather than the message it ends on: a consumed message, a tool result
|
|
555
|
-
or a failure note is owed an answer, while a replacement written by a compaction is not a
|
|
556
|
-
question and gets none. A replacement also keeps whatever joined the conversation after its
|
|
557
|
-
snapshot, and a consumed message kept that way is still owed an answer — which is not visible
|
|
558
|
-
in the messages, so the rewrite that knew records it on the record it writes.
|
|
559
|
-
- **Two owners of one agent ID could collide on a queue key**, and the second write silently
|
|
560
|
-
replaced a message whose `send` had already resolved. Every key now ends in a segment
|
|
561
|
-
identifying its writer, so simultaneous acceptance can order two messages arbitrarily but can
|
|
562
|
-
never lose one.
|
|
563
|
-
- **Compaction erased work it had never seen.** The suffix it preserved came from its own
|
|
564
|
-
memory, so a record another owner committed while the provider was summarizing was wiped by
|
|
565
|
-
the clear-and-replace. The boundary is now a record count taken from the store, and the suffix
|
|
566
|
-
is rebuilt from the store inside the commit.
|
|
567
|
-
- **Abort did not own the whole turn.** The abort scope now opens before the turn's hooks and
|
|
568
|
-
loading rather than at its first inference, so a turn cancelled during startup never reaches
|
|
569
|
-
the model at all.
|
|
570
|
-
- **A compaction requested during an aborted turn never settled.** Dropping the turn request
|
|
571
|
-
dropped the only thing that would have run it, leaving every `compact()` caller waiting for
|
|
572
|
-
ever. Abort and close now settle a compaction nobody will carry out.
|
|
573
|
-
- **A response's leftovers overlapped the next request.** A stream still closing, or a tool that
|
|
574
|
-
an abort settled in the conversation but which is still running, kept hold of a stateful
|
|
575
|
-
session while the next request went out. The next request now waits for that work — but an
|
|
576
|
-
abort does not, so a stream or tool that ignores cancellation can never hold a cancellation
|
|
577
|
-
open.
|
|
578
|
-
- **Close raced the work it had already accepted.** A `send` that had been admitted could still
|
|
579
|
-
be writing when `close()` resolved. Close is now a barrier: nothing new is admitted, and
|
|
580
|
-
everything already admitted is written, answered, and only then is the session destroyed.
|
|
581
|
-
- **Two managers could both create one agent ID**, each returning a live agent while storage
|
|
582
|
-
kept a single configuration. Creation now claims the identity with an atomic write-if-absent,
|
|
583
|
-
so exactly one creator is told it won.
|
|
584
|
-
- **A creation that failed halfway still took the name.** The configuration was committed before
|
|
585
|
-
the agent was built, so a feature that refused to load left an identity behind that no agent
|
|
586
|
-
answered to and no caller could claim again. A creation that produces no agent now rolls its
|
|
587
|
-
identity back, and a subagent spawn whose initial task never reached the child does the same.
|
|
588
|
-
- **A message could be acknowledged before anything could find the agent that owed it.** The
|
|
589
|
-
agent now records that it owes an answer in the same transaction that accepts the message, and
|
|
590
|
-
the collection publishes it in its discovery index before the send resolves.
|
|
591
|
-
- **Settling was inferred rather than committed.** The index deletion at the end of a settle
|
|
592
|
-
could erase a marker owed to a message accepted while that deletion was in flight. Settling is
|
|
593
|
-
now committed under the persistence lock and only when the durable queues really are empty, so
|
|
594
|
-
an agent that owes an answer is never durably described as settled — and discovery asks that
|
|
595
|
-
committed fact instead of reading another component's state.
|
|
596
|
-
- **One durable message could be answered twice.** Two live owners over one store each loaded it
|
|
597
|
-
into memory and each consumed it. A consumption now claims every entry with an atomic delete
|
|
598
|
-
inside its transaction, and a batch that wins nothing rolls back untouched.
|
|
599
|
-
- **A live owner could answer a conversation that no longer existed**, having loaded the durable
|
|
600
|
-
state once and kept it. Every turn now reloads before it decides anything, so a model switch
|
|
601
|
-
or an appended message from another owner is in force by the next turn.
|
|
602
|
-
- **A hook could keep the store it was lent.** `modelChanged` runs inside the persistence lock
|
|
603
|
-
and receives a store bound to that hold; retaining it let later writes bypass the lock
|
|
604
|
-
entirely. The store is now a capability released when the hook returns.
|
|
605
|
-
- **An external send could land in the middle of a hook's decision.** Two messages returned by
|
|
606
|
-
one hook were written one lock hold at a time. They are now accepted as one batch, so a caller
|
|
607
|
-
arriving during it lands after all of it.
|
|
608
|
-
- **A send accepted mid-inference bought an empty turn.** The running turn drained it but left
|
|
609
|
-
the request flag raised, so the loop ran again with nothing to do and fired a full set of
|
|
610
|
-
lifecycle hooks around it. Consuming the last queued work now clears the request it answered.
|
|
611
|
-
- **Close could destroy one session twice.** An idle agent reached `destroy()` before the shared
|
|
612
|
-
shutdown promise had been assigned, so a participant reentering from inside `destroy` started
|
|
613
|
-
a second shutdown. The barrier is now published before any of the shutdown runs.
|
|
614
|
-
- **One response could report two terminal outcomes.** An abort observed after a normal `done`
|
|
615
|
-
appended a contradictory `cancelled` event for the same response. A cancellation is now only
|
|
616
|
-
reported when the turn actually had something left to cancel.
|
|
617
|
-
- **Two scopes could collide through a dot.** A feature named `alpha.beta` and the relative key
|
|
618
|
-
`beta.state` under the feature `alpha` produced the same absolute key, so one silently read
|
|
619
|
-
and overwrote the other's state. Scope segments are now escaped, and each is exactly one level.
|
|
620
|
-
|
|
621
|
-
## Real gym
|
|
622
|
-
|
|
623
|
-
`tests/real-gym/` runs the agent against live models instead of scripted ones. Each scenario
|
|
624
|
-
goes through an `AgentSystemLocal` collection, exactly the way the product assembles an agent: the agent
|
|
625
|
-
is created with its configuration — the real machine environment and the gym's own feature
|
|
626
|
-
config — and resolved from the collection, which runs one shared `FeatureGymHarness` that
|
|
627
|
-
contributes a real `record_answer` tool. So the assembled instructions, tool set, loop, and
|
|
628
|
-
durable records are all the product's own, and the credentials are the ones the installed Codex
|
|
629
|
-
and Claude Code assistants already manage, resolved in the same order Rig itself resolves them.
|
|
630
|
-
Nothing is mocked, so the suite runs only when it is asked for by name:
|
|
631
|
-
|
|
632
|
-
```sh
|
|
633
|
-
pnpm --filter @slopus/happy-agent-base test:real
|
|
634
|
-
```
|
|
635
|
-
|
|
636
|
-
The ordinary `test` script skips it. A machine that is not signed in to a vendor fails that
|
|
637
|
-
vendor's scenario with a message saying so rather than pretending the product is broken.
|
|
638
|
-
|
|
639
|
-
A `TracingProvider` wraps the real provider and records, at the wire boundary, the session's
|
|
640
|
-
system prompt and tools, every request's complete conversation, every streamed event with the
|
|
641
|
-
millisecond it arrived, the token counts, and any failure. After the run, every trace is
|
|
642
|
-
rendered into one self-contained page at `.context/real-gym/report.html`: a summary table of
|
|
643
|
-
each scenario's vendor, model, outcome, duration, inferences, tool calls, and tokens, then for
|
|
644
|
-
each scenario the agent's facts — its features, the model catalog it was offered, and its
|
|
645
|
-
environment — the assembled system prompt with the tools the model was shown, every inference's
|
|
646
|
-
reasoning, text, tool calls, conversation, and event stream, and finally the durable transcript
|
|
647
|
-
a restart would rebuild. The renderer has its own ordinary unit test, so the report stays
|
|
648
|
-
covered without spending tokens.
|
|
649
|
-
|
|
650
|
-
The live suite has already earned its keep twice: it exposed a provider stream left open after
|
|
651
|
-
`done`, and a lost message when a `send` landed while the agent was loading its history.
|