@zhin.js/harness 0.0.0 → 1.0.1

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/src/harness.ts DELETED
@@ -1,975 +0,0 @@
1
- /**
2
- * ADR 0048 Agent Harness — composition (H1).
3
- *
4
- * Wires the components behind the contracts: every mail is attenuated,
5
- * funded from its sender's grant, admitted by the guard kernel, journaled,
6
- * and only then posted to the mailbox. The agent layer supplies the turn
7
- * runner and calls `send` from its collaboration tools.
8
- *
9
- * Semantics:
10
- * - `ask` is synchronous for the asker: `send` resolves with the target's
11
- * reply; the reply is journaled as a `result` mail but not posted to the
12
- * asker's mailbox (the asker's turn is still running and consumes it).
13
- * - `delegate` is asynchronous: `send` resolves once the work is admitted;
14
- * the target's reply is posted to the delegator's mailbox as a `result`
15
- * mail, which wakes it with a new turn.
16
- * - `tell` expects nothing back.
17
- * - An `ask` whose target is (transitively) waiting on the asker would
18
- * deadlock the serial mailbox; it is denied as `wait_cycle` and escalated.
19
- * - A denial with `escalate: true` freezes the whole engagement: an
20
- * `escalated` event is journaled, `onEscalate` is called once so the host
21
- * can ask the principal, every outstanding ask / delegate is cancelled
22
- * (askers get a `cancelled` reply), and later sends are denied as `frozen`.
23
- * - An engagement closes when it has no running or queued turns and no
24
- * outstanding ask / delegate; its root grant is closed then.
25
- * - Inbound mail (`human` / `timer` / `event`) enters through `deliver`
26
- * (`open` is the same call): it opens an engagement and posts the root mail
27
- * into the entry actor's inbox lane (D1). The inbox policy (`inbox.policy`,
28
- * D2) decides what the mail does to that lane, also while a turn of the
29
- * lane is running (mid-turn): queue, merge, supersede the running turn,
30
- * stop it, or reject. Every turn of every actor runs inside the mailbox;
31
- * there is no externally running root turn (H3, Q6).
32
- * - A merged batch (Q2) may span several engagements (one per human
33
- * message); each engagement is accounted for by its own mails.
34
- * - A `result` for a mail that ran on an explicit lane returns on that lane,
35
- * so delegated results wait for the lane's running turn (the root turn)
36
- * instead of running beside it.
37
- */
38
- import {
39
- ALL,
40
- DEFAULT_GUARD_LIMITS,
41
- type ActorId,
42
- type ActorTurnResult,
43
- type ActorTurnRunner,
44
- type AgentCeiling,
45
- type AgentName,
46
- type Authority,
47
- type AuthorityNarrowing,
48
- type BoardEntry,
49
- type BoardEntryKind,
50
- type BoardReadFilter,
51
- type BoardWriteInput,
52
- type BudgetAmounts,
53
- type BudgetLedger,
54
- type BudgetResult,
55
- type BudgetSnapshot,
56
- type Engagement,
57
- type EngagementGraphView,
58
- type EngagementId,
59
- type EngagementJournal,
60
- type FollowUpInput,
61
- type GrantId,
62
- type GuardDenyCode,
63
- type GuardKernel,
64
- type GuardLimits,
65
- type Mail,
66
- type MailBody,
67
- type MailId,
68
- type MailSender,
69
- type Mailbox,
70
- type MailboxKey,
71
- type InboxDropReason,
72
- type InboxPolicy,
73
- type InboxRejectCode,
74
- type InboxSetTimer,
75
- type MailboxOptions,
76
- type PostResult,
77
- type Principal,
78
- type RemoteMailResult,
79
- type RemoteMailTransport,
80
- type SendAck,
81
- type VenueRef,
82
- } from './contracts.js';
83
- import { agentOf } from './authority.js';
84
- import { reconstructBoardFromEvents } from './journal.js';
85
-
86
- export type HarnessDenyCode =
87
- | GuardDenyCode
88
- | InboxRejectCode
89
- | 'unknown_agent'
90
- | 'wait_cycle'
91
- | 'budget_exceeded'
92
- | 'closed'
93
- | 'frozen';
94
-
95
- export type SendOutcome =
96
- | {
97
- readonly ok: true;
98
- readonly accepted?: true;
99
- readonly mailId?: MailId;
100
- readonly mail: Mail;
101
- readonly reply?: { readonly status: ActorTurnResult['status']; readonly content: string };
102
- readonly result?: { readonly status: ActorTurnResult['status'] | 'rejected'; readonly content: string };
103
- }
104
- | {
105
- readonly ok: false;
106
- readonly accepted?: false;
107
- readonly mailId?: MailId;
108
- readonly mail?: Mail;
109
- readonly code: HarnessDenyCode;
110
- readonly reason: string;
111
- readonly escalated: boolean;
112
- readonly reply?: { readonly status: ActorTurnResult['status']; readonly content: string };
113
- readonly result?: { readonly status: ActorTurnResult['status'] | 'rejected'; readonly content: string };
114
- };
115
-
116
- export interface SendRequest {
117
- readonly kind: 'ask' | 'delegate' | 'tell';
118
- readonly to: ActorId;
119
- readonly text: string;
120
- readonly narrowing?: AuthorityNarrowing;
121
- readonly budget?: Partial<BudgetAmounts>;
122
- /** For `delegate`: what the delegator expects back. */
123
- readonly deliverable?: string;
124
- /** Optional timeout (ms) for remote transport calls. */
125
- readonly timeoutMs?: number;
126
- }
127
-
128
- export interface OpenEngagementInput {
129
- readonly principal: Principal;
130
- /** Actor that receives the root mail (the venue's entry actor). */
131
- readonly entry: ActorId;
132
- readonly body: Extract<MailBody, { kind: 'human' | 'timer' | 'event' }>;
133
- readonly budget: BudgetAmounts;
134
- readonly approvers?: readonly string[];
135
- readonly venue?: VenueRef;
136
- /**
137
- * Serial lane of the root mail inside the entry actor's inbox (D1): the IM
138
- * session key for human and scene-event mail, `timer:<jobId>` for timers,
139
- * `event:<source>` for webhooks. Omitted = the engagement's own lane.
140
- */
141
- readonly lane?: string;
142
- /**
143
- * Called synchronously with the admitted root mail right before it is
144
- * posted. The inbox may start its turn in the next microtask, before
145
- * `deliver` resolves, so a host that correlates turns with its own
146
- * callers registers the mail here.
147
- */
148
- readonly onPosting?: (root: Mail) => void;
149
- }
150
-
151
- /** How the inbox took an inbound mail. `stopped`: it was a stop request and was consumed. */
152
- export type DeliverDisposition = Extract<PostResult, { accepted: true }>['disposition'];
153
-
154
- export type OpenOutcome =
155
- | {
156
- readonly ok: true;
157
- readonly engagement: Engagement;
158
- readonly root: Mail;
159
- readonly disposition: DeliverDisposition;
160
- /** 1-based queue position; 0 when consumed (`stopped`). */
161
- readonly position: number;
162
- }
163
- | { readonly ok: false; readonly code: HarnessDenyCode; readonly reason: string };
164
-
165
- /** `deliver` and `open` are the same call. */
166
- export type DeliverOutcome = OpenOutcome;
167
-
168
- export interface EscalationNotice {
169
- readonly engagement: Engagement;
170
- readonly mail: Mail;
171
- readonly code: HarnessDenyCode;
172
- readonly reason: string;
173
- }
174
-
175
- export interface HarnessOptions {
176
- readonly guard: GuardKernel;
177
- readonly ledger: BudgetLedger;
178
- readonly journal: EngagementJournal;
179
- readonly createMailbox: (options: MailboxOptions) => Mailbox;
180
- /**
181
- * Agent definitions' ceilings; `undefined` = unknown agent. `engagementId`
182
- * names the engagement asking, so a long-lived host can resolve agents per
183
- * engagement (the directory of the turn that opened it).
184
- */
185
- readonly ceilingOf: (agent: AgentName, engagementId: EngagementId) => AgentCeiling | undefined;
186
- readonly rootAuthority: (principal: Principal, ceiling: AgentCeiling, options?: { approvers?: readonly string[] }) => Authority;
187
- readonly attenuate: (parent: Authority, narrowing: AuthorityNarrowing, target: AgentCeiling, via: ActorId) => Authority;
188
- /** Runs one actor turn; the agent layer calls `harness.send` / `harness.charge` from inside it. */
189
- readonly runTurn: ActorTurnRunner;
190
- readonly limits?: GuardLimits;
191
- readonly maxConcurrentTurns?: number;
192
- readonly maxQueuedPerKey?: number;
193
- /** Inbox scheduling (H3): policy (D2), queue TTL and the timer / clock windows use. */
194
- readonly inbox?: Readonly<{
195
- policy?: InboxPolicy;
196
- queueTtlMs?: number;
197
- now?: () => number;
198
- setTimer?: InboxSetTimer;
199
- }>;
200
- readonly clock?: () => number;
201
- readonly newId?: () => string;
202
- readonly remoteTransport?: RemoteMailTransport;
203
- readonly onEscalate?: (notice: EscalationNotice) => void | Promise<void>;
204
- /** Called once when an engagement closed (after `engagement_closed` was journaled). */
205
- readonly onEngagementClosed?: (engagement: Engagement) => void;
206
- readonly onError?: (error: unknown) => void;
207
- }
208
-
209
- export type CreateHarnessOptions = HarnessOptions;
210
-
211
- export interface Harness {
212
- /**
213
- * Delivers an inbound mail (`human` / `timer` / `event`): opens its
214
- * engagement and posts the root mail to the entry actor's inbox lane. May be
215
- * called at any time, including while a turn of that lane is running
216
- * (mid-turn delivery): the inbox policy then decides whether the new mail
217
- * queues, merges, supersedes or stops the running turn (whose stale reply
218
- * must then not be sent; see `ActorTurnControl.staleCheck`).
219
- */
220
- deliver(input: OpenEngagementInput): Promise<DeliverOutcome>;
221
- /** Same as `deliver`. */
222
- open(input: OpenEngagementInput): Promise<OpenOutcome>;
223
- /** Remaining budget of the grant `current` runs on. */
224
- remaining(current: Mail): BudgetAmounts | undefined;
225
- /** Called by collaboration tools while `current` (the mail being processed) is running. */
226
- send(from: ActorId, current: Mail, request: SendRequest): Promise<SendOutcome>;
227
- /** Follows up on previously delegated / asked work within the same engagement. */
228
- followUp(input: FollowUpInput): Promise<SendAck>;
229
- /** Writes an artifact, note, or todo item to the engagement's shared blackboard. */
230
- writeBoard(engagementId: EngagementId, input: BoardWriteInput): Promise<BoardEntry>;
231
- /** Reads entries from the engagement's shared blackboard, reconstructing from journal if needed. */
232
- readBoard(engagementId: EngagementId, filter?: BoardReadFilter): Promise<readonly BoardEntry[]>;
233
- /** Records model / tool usage against the grant of `current`. */
234
- charge(current: Mail, usage: Partial<BudgetAmounts>): Promise<BudgetResult<BudgetSnapshot>>;
235
- cancel(mailId: MailId, reason?: string): boolean;
236
- /** Drops every queued mail of `senderId` on one lane of `actor` (reported `cancelled` with `reason`). */
237
- cancelSender(actor: ActorId, lane: string, senderId: string, reason?: InboxDropReason): number;
238
- engagement(id: EngagementId): Engagement | undefined;
239
- graph(id: EngagementId): EngagementGraphView | undefined;
240
- idle(): Promise<void>;
241
- close(reason?: string): Promise<void>;
242
- }
243
-
244
-
245
- interface EngagementState {
246
- readonly engagement: Engagement;
247
- readonly initialBudget: BudgetAmounts;
248
- readonly mails: Mail[];
249
- readonly byId: Map<MailId, Mail>;
250
- /** Mails posted to the mailbox whose turn has not finished. */
251
- pendingTurns: number;
252
- /** ask / delegate mails without a reply yet. */
253
- readonly outstanding: Set<MailId>;
254
- closed: boolean;
255
- /** Set by the first escalation; the engagement accepts no new collaboration after it. */
256
- frozen?: { readonly code: HarnessDenyCode; readonly reason: string };
257
- }
258
-
259
- interface DelegatedMailRecord {
260
- readonly id: MailId;
261
- readonly engagementId: EngagementId;
262
- readonly from: ActorId;
263
- readonly to: ActorId;
264
- readonly authority: Authority;
265
- readonly parentMail: Mail;
266
- readonly lane?: string;
267
- }
268
-
269
- interface Waiter {
270
- readonly asker: ActorId;
271
- readonly engagementId: EngagementId;
272
- readonly resolve: (reply: { status: ActorTurnResult['status']; content: string }) => void;
273
- }
274
-
275
- export function createHarness(options: HarnessOptions): Harness {
276
- const limits = options.limits ?? DEFAULT_GUARD_LIMITS;
277
- const clock = options.clock ?? Date.now;
278
- let counter = 0;
279
- const newId = options.newId ?? (() => `m${++counter}`);
280
- const engagements = new Map<EngagementId, EngagementState>();
281
- const allEngagements = new Map<EngagementId, EngagementState>();
282
- const sentDelegations = new Map<MailId, DelegatedMailRecord>();
283
- const boards = new Map<EngagementId, Map<string, BoardEntry>>();
284
- const askWaiters = new Map<MailId, Waiter>();
285
- /** asker actor → actors it is currently waiting on (per engagement). */
286
- const waitingOn = new Map<string, Set<ActorId>>();
287
- let closed = false;
288
-
289
- const reportError = (error: unknown): void => {
290
- try { options.onError?.(error); } catch { /* reporting must not throw */ }
291
- };
292
-
293
- const mailbox = options.createMailbox({
294
- run: async (actor, batch, signal, control) => {
295
- const engagementId = batch[0]?.engagementId;
296
- if (engagementId) {
297
- await record(engagementId, 'turn_started', { actor, mails: batch.map((mail) => mail.id) }).catch(reportError);
298
- }
299
- return options.runTurn(actor, batch, signal, control);
300
- },
301
- maxConcurrentTurns: options.maxConcurrentTurns ?? 8,
302
- maxQueuedPerKey: options.maxQueuedPerKey ?? 16,
303
- ...(options.inbox?.policy ? { policy: options.inbox.policy } : {}),
304
- ...(options.inbox?.queueTtlMs !== undefined ? { queueTtlMs: options.inbox.queueTtlMs } : {}),
305
- ...(options.inbox?.now ? { now: options.inbox.now } : {}),
306
- ...(options.inbox?.setTimer ? { setTimer: options.inbox.setTimer } : {}),
307
- onTurnFinished: (key, batch, result) => onTurnFinished(key, batch, result),
308
- });
309
-
310
- function graphOf(state: EngagementState): EngagementGraphView {
311
- return {
312
- engagement: state.engagement,
313
- get mails() { return state.mails; },
314
- get: (id) => state.byId.get(id),
315
- };
316
- }
317
-
318
- async function record(engagementId: EngagementId, type: Parameters<EngagementJournal['append']>[0]['type'], data: Record<string, unknown>): Promise<void> {
319
- await options.journal.append({ engagementId, at: clock(), type, data });
320
- }
321
-
322
- function admit(state: EngagementState, mail: Mail): void {
323
- state.mails.push(mail);
324
- state.byId.set(mail.id, mail);
325
- }
326
-
327
- function waitKey(engagementId: EngagementId, actor: ActorId): string {
328
- return `${engagementId}\u0000${actor}`;
329
- }
330
-
331
- /** True when `target` is, directly or transitively, waiting on `asker`. */
332
- function wouldCycle(engagementId: EngagementId, asker: ActorId, target: ActorId): boolean {
333
- if (asker === target) return true;
334
- const seen = new Set<ActorId>();
335
- const stack = [target];
336
- for (let actor = stack.pop(); actor !== undefined; actor = stack.pop()) {
337
- if (actor === asker) return true;
338
- if (seen.has(actor)) continue;
339
- seen.add(actor);
340
- for (const next of waitingOn.get(waitKey(engagementId, actor)) ?? []) stack.push(next);
341
- }
342
- return false;
343
- }
344
-
345
- function mailSummary(mail: Mail): Record<string, unknown> {
346
- return {
347
- mailId: mail.id,
348
- kind: mail.kind,
349
- from: mail.from.kind === 'actor' ? mail.from.actor : mail.from.kind === 'human' ? `human:${mail.from.principal.id}` : `system:${mail.from.source}`,
350
- to: mail.to,
351
- causationId: mail.causationId ?? null,
352
- grantId: mail.grantId,
353
- tools: mail.authority.tools.kind === 'all' ? '*' : [...mail.authority.tools.names],
354
- trust: mail.authority.trust,
355
- };
356
- }
357
-
358
- async function escalate(state: EngagementState, mail: Mail, code: HarnessDenyCode, reason: string): Promise<void> {
359
- if (state.frozen) return;
360
- state.frozen = { code, reason };
361
- await record(state.engagement.id, 'escalated', { ...mailSummary(mail), code, reason });
362
- // Stop every branch still in flight; their askers receive a cancelled reply.
363
- for (const id of [...state.outstanding]) {
364
- if (!mailbox.cancel(id, `engagement frozen: ${reason}`)) {
365
- const waiter = askWaiters.get(id);
366
- if (waiter) {
367
- askWaiters.delete(id);
368
- state.outstanding.delete(id);
369
- waiter.resolve({ status: 'cancelled', content: `engagement frozen: ${reason}` });
370
- }
371
- }
372
- }
373
- try {
374
- await options.onEscalate?.({ engagement: state.engagement, mail, code, reason });
375
- } catch (error) {
376
- reportError(error);
377
- }
378
- }
379
-
380
- async function maybeCloseEngagement(state: EngagementState): Promise<void> {
381
- if (state.closed || state.pendingTurns > 0 || state.outstanding.size > 0) return;
382
- state.closed = true;
383
- const snapshot = options.ledger.close(state.engagement.rootGrantId);
384
- await record(state.engagement.id, 'engagement_closed', {
385
- status: state.frozen ? 'escalated' : 'completed',
386
- ...(state.frozen ? { reason: state.frozen.reason } : {}),
387
- budget: snapshot.ok ? { spent: snapshot.value.spent, granted: snapshot.value.granted } : null,
388
- });
389
- await options.journal.compact?.(state.engagement.id).catch(reportError);
390
- engagements.delete(state.engagement.id);
391
- try { options.onEngagementClosed?.(state.engagement); } catch (error) { reportError(error); }
392
- }
393
-
394
- type Posted =
395
- | { readonly ok: true; readonly disposition: DeliverDisposition; readonly position: number }
396
- | { readonly ok: false; readonly code: InboxRejectCode | 'closed'; readonly reason: string };
397
-
398
- function post(state: EngagementState, mail: Mail): Posted {
399
- const posted = mailbox.post(mail);
400
- if (!posted.accepted) {
401
- return { ok: false, code: posted.code === 'mailbox_closed' ? 'closed' : posted.code, reason: posted.message };
402
- }
403
- // A stop request is consumed by the inbox: it never becomes a turn.
404
- if (posted.disposition !== 'stopped') state.pendingTurns += 1;
405
- return { ok: true, disposition: posted.disposition, position: posted.position };
406
- }
407
-
408
- async function onTurnFinished(key: MailboxKey, batch: readonly Mail[], result: ActorTurnResult): Promise<void> {
409
- // A merged batch (Q2) may carry mails of several engagements.
410
- const byEngagement = new Map<EngagementId, Mail[]>();
411
- for (const mail of batch) {
412
- const list = byEngagement.get(mail.engagementId);
413
- if (list) list.push(mail);
414
- else byEngagement.set(mail.engagementId, [mail]);
415
- }
416
- for (const [engagementId, mails] of byEngagement) {
417
- const state = engagements.get(engagementId);
418
- if (!state) continue;
419
- state.pendingTurns -= mails.length;
420
- try {
421
- await record(state.engagement.id, 'turn_finished', {
422
- actor: key.actor,
423
- mails: mails.map((mail) => mail.id),
424
- status: result.status,
425
- ...(result.reason ? { reason: result.reason } : {}),
426
- ...(engagementId !== key.engagementId ? { carriedBy: key.engagementId } : {}),
427
- });
428
- for (const mail of mails) {
429
- if (mail.body.kind === 'tell') options.ledger.close(mail.grantId);
430
- if (mail.body.kind !== 'ask' && mail.body.kind !== 'delegate') continue;
431
- await reply(state, mail, result);
432
- }
433
- } catch (error) {
434
- reportError(error);
435
- } finally {
436
- await maybeCloseEngagement(state).catch(reportError);
437
- }
438
- }
439
- }
440
-
441
- /** Sends the target's reply for an ask / delegate back to its sender. */
442
- async function reply(state: EngagementState, request: Mail, result: ActorTurnResult): Promise<void> {
443
- if (request.from.kind !== 'actor') return;
444
- const requester = request.from.actor;
445
- const content = result.content ?? '';
446
- const parent = request.causationId ? state.byId.get(request.causationId) : undefined;
447
- const back: Mail = Object.freeze({
448
- id: newId(),
449
- kind: 'result',
450
- engagementId: state.engagement.id,
451
- causationId: request.id,
452
- from: Object.freeze({ kind: 'actor', actor: request.to }) as MailSender,
453
- to: requester,
454
- // Back on the requester's lane, behind the turn that asked (it may still be running).
455
- ...(parent?.lane !== undefined && parent.to === requester ? { lane: parent.lane } : {}),
456
- // The reply runs with the requester's own authority and grant, never the replier's.
457
- authority: parent?.authority ?? request.authority,
458
- grantId: parent?.grantId ?? request.grantId,
459
- body: Object.freeze({ kind: 'result', inReplyTo: request.id, status: result.status, content }),
460
- createdAt: clock(),
461
- });
462
- options.ledger.close(request.grantId);
463
- state.outstanding.delete(request.id);
464
- const ceiling = options.ceilingOf(agentOf(requester), state.engagement.id);
465
- const decision = ceiling
466
- ? options.guard.admitMail(back, { graph: graphOf(state), targetCeiling: ceiling, limits })
467
- : { allow: false as const, code: 'not_accepted_by_target' as const, reason: `unknown agent ${requester}`, escalate: false };
468
- if (!decision.allow) {
469
- await record(state.engagement.id, 'mail_denied', { ...mailSummary(back), code: decision.code, reason: decision.reason });
470
- askWaiters.get(request.id)?.resolve({ status: 'failed', content: decision.reason });
471
- askWaiters.delete(request.id);
472
- return;
473
- }
474
- admit(state, back);
475
- await record(state.engagement.id, 'mail_admitted', mailSummary(back));
476
- const waiter = askWaiters.get(request.id);
477
- if (waiter) {
478
- askWaiters.delete(request.id);
479
- waitingOn.get(waitKey(state.engagement.id, waiter.asker))?.delete(request.to);
480
- waiter.resolve({ status: result.status, content });
481
- return;
482
- }
483
- const posted = post(state, back);
484
- if (!posted.ok) await record(state.engagement.id, 'mail_denied', { ...mailSummary(back), code: posted.code, reason: posted.reason });
485
- }
486
-
487
- async function deliver(input: OpenEngagementInput): Promise<DeliverOutcome> {
488
- if (closed) return { ok: false, code: 'closed', reason: 'harness is closed' };
489
- const engagementId = newId();
490
- const ceiling = options.ceilingOf(agentOf(input.entry), engagementId);
491
- if (!ceiling) return { ok: false, code: 'unknown_agent', reason: `unknown agent ${agentOf(input.entry)}` };
492
- const rootGrantId = options.ledger.openRoot(input.budget);
493
- const rootMailId = newId();
494
- const engagement: Engagement = Object.freeze({
495
- id: engagementId,
496
- principal: input.principal,
497
- rootMailId,
498
- rootGrantId,
499
- venue: input.venue,
500
- openedAt: clock(),
501
- });
502
- const from: MailSender = input.body.kind === 'human'
503
- ? { kind: 'human', principal: input.principal }
504
- : { kind: 'system', source: input.body.kind === 'timer' ? 'timer' : 'event' };
505
- const root: Mail = Object.freeze({
506
- id: rootMailId,
507
- kind: input.body.kind,
508
- engagementId,
509
- from: Object.freeze(from),
510
- to: input.entry,
511
- ...(input.lane !== undefined ? { lane: input.lane } : {}),
512
- authority: options.rootAuthority(input.principal, ceiling, { approvers: input.approvers }),
513
- grantId: rootGrantId,
514
- body: input.body,
515
- createdAt: clock(),
516
- });
517
- const state: EngagementState = {
518
- engagement,
519
- initialBudget: input.budget,
520
- mails: [],
521
- byId: new Map(),
522
- pendingTurns: 0,
523
- outstanding: new Set(),
524
- closed: false,
525
- };
526
- engagements.set(engagementId, state);
527
- allEngagements.set(engagementId, state);
528
- await record(engagementId, 'engagement_opened', {
529
- principal: input.principal.id,
530
- initiator: input.principal,
531
- ...(input.venue ? { venue: input.venue } : {}),
532
- entry: input.entry,
533
- rootMailId,
534
- rootGrantId,
535
- budget: input.budget,
536
- ...(input.lane !== undefined ? { lane: input.lane } : {}),
537
- });
538
- const decision = options.guard.admitMail(root, { graph: graphOf(state), targetCeiling: ceiling, limits });
539
- if (!decision.allow) {
540
- await record(engagementId, 'mail_denied', { ...mailSummary(root), code: decision.code, reason: decision.reason });
541
- await maybeCloseEngagement(state);
542
- return { ok: false, code: decision.code, reason: decision.reason };
543
- }
544
- admit(state, root);
545
- await record(engagementId, 'mail_admitted', mailSummary(root));
546
- if (closed) {
547
- await maybeCloseEngagement(state);
548
- return { ok: false, code: 'closed', reason: 'harness is closed' };
549
- }
550
- try {
551
- input.onPosting?.(root);
552
- } catch (error) {
553
- reportError(error);
554
- }
555
- const posted = post(state, root);
556
- if (!posted.ok) {
557
- await record(engagementId, 'mail_denied', { ...mailSummary(root), code: posted.code, reason: posted.reason });
558
- await maybeCloseEngagement(state);
559
- return { ok: false, code: posted.code, reason: posted.reason };
560
- }
561
- if (posted.disposition === 'stopped') await maybeCloseEngagement(state);
562
- return { ok: true, engagement, root, disposition: posted.disposition, position: posted.position };
563
- }
564
-
565
- async function sendInternal(
566
- from: ActorId,
567
- current: Mail,
568
- request: SendRequest,
569
- overrides?: { readonly causationId?: MailId; readonly authority?: Authority },
570
- ): Promise<SendOutcome> {
571
- const state = engagements.get(current.engagementId);
572
- const deny = async (code: HarnessDenyCode, reason: string, mail?: Mail, escalateIt = false): Promise<SendOutcome> => {
573
- if (state && mail) {
574
- await record(state.engagement.id, 'mail_denied', { ...mailSummary(mail), code, reason });
575
- if (escalateIt) await escalate(state, mail, code, reason);
576
- }
577
- return { ok: false, accepted: false, code, reason, escalated: escalateIt && Boolean(state && mail) };
578
- };
579
- if (closed) return deny('closed', 'harness is closed');
580
- if (!state || state.closed) return deny('unknown_engagement', `engagement ${current.engagementId} is not open`);
581
- if (state.frozen) {
582
- return {
583
- ok: false,
584
- accepted: false,
585
- code: 'frozen',
586
- reason: `collaboration is paused for the human to decide: ${state.frozen.reason}`,
587
- escalated: true,
588
- };
589
- }
590
- if (current.to !== from || !state.byId.has(current.id)) {
591
- return deny('authority_not_attenuated', `${from} is not processing mail ${current.id}`);
592
- }
593
- const isRemote = Boolean(options.remoteTransport?.canRoute(request.to));
594
- const ceiling = options.ceilingOf(agentOf(request.to), state.engagement.id)
595
- ?? (isRemote
596
- ? {
597
- agent: agentOf(request.to),
598
- tools: ALL,
599
- contacts: ALL,
600
- maxTrust: 'master' as const,
601
- egress: { network: true, hosts: ALL },
602
- acceptsFrom: ALL,
603
- }
604
- : undefined);
605
- if (!ceiling) return deny('unknown_agent', `unknown agent ${agentOf(request.to)}`);
606
-
607
- const budgetRequest = request.budget ?? ceiling.defaultBudget ?? {};
608
- const granted = options.ledger.grant(current.grantId, budgetRequest);
609
- const grantId: GrantId = granted.ok ? granted.value : current.grantId;
610
- const body: MailBody = request.kind === 'ask'
611
- ? { kind: 'ask', question: request.text }
612
- : request.kind === 'delegate'
613
- ? { kind: 'delegate', brief: request.text, ...(request.deliverable ? { deliverable: request.deliverable } : {}) }
614
- : { kind: 'tell', content: request.text };
615
- const mail: Mail = Object.freeze({
616
- id: newId(),
617
- kind: request.kind,
618
- engagementId: state.engagement.id,
619
- causationId: overrides?.causationId ?? current.id,
620
- from: Object.freeze({ kind: 'actor', actor: from }) as MailSender,
621
- to: request.to,
622
- authority: overrides?.authority ?? options.attenuate(current.authority, request.narrowing ?? {}, ceiling, from),
623
- grantId,
624
- body: Object.freeze(body),
625
- createdAt: clock(),
626
- });
627
- if (!granted.ok) return deny('budget_exceeded', granted.message, mail);
628
-
629
- const decision = options.guard.admitMail(mail, {
630
- graph: graphOf(state),
631
- targetCeiling: ceiling,
632
- senderAuthority: current.authority,
633
- limits,
634
- });
635
- if (!decision.allow) {
636
- options.ledger.close(grantId);
637
- return deny(decision.code, decision.reason, mail, decision.escalate);
638
- }
639
- if (request.kind === 'ask' && wouldCycle(state.engagement.id, from, request.to)) {
640
- options.ledger.close(grantId);
641
- return deny('wait_cycle', `${request.to} is waiting on ${from}; asking it back would deadlock`, mail, true);
642
- }
643
-
644
- admit(state, mail);
645
- await record(state.engagement.id, 'mail_admitted', mailSummary(mail));
646
- const snapshot = options.ledger.snapshot(grantId);
647
- if (granted.ok && request.kind !== 'tell') {
648
- await record(state.engagement.id, 'budget_granted', {
649
- grantId,
650
- parentId: current.grantId,
651
- granted: snapshot?.granted ?? null,
652
- });
653
- }
654
- if (request.kind === 'ask' || request.kind === 'delegate') {
655
- sentDelegations.set(mail.id, {
656
- id: mail.id,
657
- engagementId: state.engagement.id,
658
- from,
659
- to: request.to,
660
- authority: mail.authority,
661
- parentMail: current,
662
- ...(current.lane !== undefined ? { lane: current.lane } : {}),
663
- });
664
- }
665
-
666
- if (isRemote && options.remoteTransport) {
667
- const remoteTransport = options.remoteTransport;
668
- const grantedAmounts: BudgetAmounts = snapshot?.granted ?? {
669
- modelCalls: 0,
670
- tokens: 0,
671
- wallMs: 0,
672
- sideEffects: 0,
673
- };
674
- if (request.kind === 'tell') {
675
- void Promise.resolve()
676
- .then(() => remoteTransport.send(mail, grantedAmounts))
677
- .then(async (remoteRes) => {
678
- if (remoteRes?.consumed && Object.keys(remoteRes.consumed).length > 0) {
679
- const ch = options.ledger.charge(grantId, remoteRes.consumed);
680
- await record(state.engagement.id, ch.ok ? 'budget_charged' : 'budget_exceeded', {
681
- grantId,
682
- mailId: mail.id,
683
- usage: remoteRes.consumed,
684
- ...(ch.ok ? {} : { exhausted: ch.exhausted ?? [] }),
685
- });
686
- }
687
- })
688
- .catch(reportError)
689
- .finally(() => {
690
- options.ledger.close(grantId);
691
- void maybeCloseEngagement(state).catch(reportError);
692
- });
693
- return { ok: true, accepted: true, mailId: mail.id, mail };
694
- }
695
-
696
- state.outstanding.add(mail.id);
697
- let remoteResult: RemoteMailResult;
698
- try {
699
- const sendPromise = options.remoteTransport.send(mail, grantedAmounts);
700
- if (request.timeoutMs !== undefined && request.timeoutMs > 0) {
701
- let timer: ReturnType<typeof setTimeout> | undefined;
702
- const timeoutPromise = new Promise<RemoteMailResult>((resolve) => {
703
- timer = setTimeout(() => {
704
- resolve({
705
- status: 'cancelled',
706
- content: `remote mail ${mail.id} timed out after ${request.timeoutMs}ms`,
707
- });
708
- }, request.timeoutMs);
709
- timer.unref?.();
710
- });
711
- remoteResult = await Promise.race([sendPromise, timeoutPromise]).finally(() => {
712
- if (timer !== undefined) clearTimeout(timer);
713
- });
714
- } else {
715
- remoteResult = await sendPromise;
716
- }
717
- } catch (error) {
718
- remoteResult = {
719
- status: 'rejected',
720
- content: error instanceof Error ? error.message : String(error),
721
- };
722
- } finally {
723
- state.outstanding.delete(mail.id);
724
- }
725
-
726
- const consumed = remoteResult.consumed ?? { modelCalls: 1 };
727
- const chargeRes = options.ledger.charge(grantId, consumed);
728
- await record(state.engagement.id, chargeRes.ok ? 'budget_charged' : 'budget_exceeded', {
729
- grantId,
730
- mailId: mail.id,
731
- usage: consumed,
732
- ...(chargeRes.ok ? {} : { exhausted: chargeRes.exhausted ?? [] }),
733
- });
734
- options.ledger.close(grantId);
735
- await record(state.engagement.id, 'mail_delivered', { mailId: mail.id, actor: request.to });
736
- if (remoteResult.status === 'completed') {
737
- await record(state.engagement.id, 'mail_completed', {
738
- mailId: mail.id,
739
- actor: request.to,
740
- status: 'completed',
741
- content: remoteResult.content,
742
- });
743
- } else {
744
- await record(state.engagement.id, 'mail_cancelled', {
745
- mailId: mail.id,
746
- actor: request.to,
747
- status: remoteResult.status,
748
- reason: remoteResult.content,
749
- });
750
- }
751
- await maybeCloseEngagement(state).catch(reportError);
752
- const turnStatus: ActorTurnResult['status'] =
753
- remoteResult.status === 'completed'
754
- ? 'completed'
755
- : remoteResult.status === 'cancelled'
756
- ? 'cancelled'
757
- : 'failed';
758
- return {
759
- ok: true,
760
- accepted: true,
761
- mailId: mail.id,
762
- mail,
763
- reply: { status: turnStatus, content: remoteResult.content },
764
- result: { status: remoteResult.status, content: remoteResult.content },
765
- };
766
- }
767
-
768
- if (request.kind !== 'tell') state.outstanding.add(mail.id);
769
-
770
- let replied: Promise<{ status: ActorTurnResult['status']; content: string }> | undefined;
771
- if (request.kind === 'ask') {
772
- replied = new Promise((resolve) => askWaiters.set(mail.id, { asker: from, engagementId: state.engagement.id, resolve }));
773
- const key = waitKey(state.engagement.id, from);
774
- const set = waitingOn.get(key) ?? new Set<ActorId>();
775
- set.add(request.to);
776
- waitingOn.set(key, set);
777
- }
778
- const posted = post(state, mail);
779
- if (!posted.ok) {
780
- state.outstanding.delete(mail.id);
781
- askWaiters.delete(mail.id);
782
- waitingOn.get(waitKey(state.engagement.id, from))?.delete(request.to);
783
- options.ledger.close(grantId);
784
- return deny(posted.code, posted.reason, mail);
785
- }
786
- if (!replied) return { ok: true, accepted: true, mailId: mail.id, mail };
787
- const replyVal = await replied;
788
- return { ok: true, accepted: true, mailId: mail.id, mail, reply: replyVal, result: replyVal };
789
- }
790
-
791
- return {
792
- deliver,
793
-
794
- open: deliver,
795
-
796
- remaining(current) {
797
- return options.ledger.snapshot(current.grantId)?.remaining;
798
- },
799
-
800
- send(from, current, request) {
801
- return sendInternal(from, current, request);
802
- },
803
-
804
- async followUp(input) {
805
- const original = sentDelegations.get(input.mailId);
806
- if (!original || original.from !== input.from) {
807
- return Object.freeze({
808
- ok: false,
809
- accepted: false,
810
- mailId: input.mailId,
811
- code: 'missing_causation',
812
- reason: Object.freeze({
813
- code: 'missing_causation',
814
- detail: !original
815
- ? `unknown delegated mail ${input.mailId}`
816
- : `mail ${input.mailId} was sent by ${original.from}, not ${input.from}`,
817
- }),
818
- escalated: false,
819
- });
820
- }
821
- const state = engagements.get(original.engagementId) ?? allEngagements.get(original.engagementId);
822
- if (!state) {
823
- return Object.freeze({
824
- ok: false,
825
- accepted: false,
826
- mailId: input.mailId,
827
- code: 'unknown_engagement',
828
- reason: Object.freeze({
829
- code: 'unknown_engagement',
830
- detail: `engagement ${original.engagementId} does not exist`,
831
- }),
832
- escalated: false,
833
- });
834
- }
835
- const wasClosed = state.closed;
836
- let carrierMail = original.parentMail;
837
- if (wasClosed) {
838
- state.closed = false;
839
- engagements.set(state.engagement.id, state);
840
- }
841
- const snap = options.ledger.snapshot(carrierMail.grantId);
842
- if (!snap || snap.closed) {
843
- const reopenedGrant = options.ledger.openRoot(state.initialBudget);
844
- carrierMail = Object.freeze({ ...carrierMail, grantId: reopenedGrant });
845
- state.byId.set(carrierMail.id, carrierMail);
846
- }
847
-
848
- const outcome = await sendInternal(
849
- input.from,
850
- carrierMail,
851
- {
852
- kind: 'ask',
853
- to: original.to,
854
- text: `[Follow-up on ${input.mailId}]\n${input.content}`,
855
- ...(input.budget ? { budget: input.budget } : {}),
856
- ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
857
- },
858
- {
859
- causationId: input.mailId,
860
- authority: original.authority,
861
- },
862
- );
863
-
864
- if (wasClosed) {
865
- await maybeCloseEngagement(state).catch(reportError);
866
- }
867
-
868
- if (!outcome.ok) {
869
- return Object.freeze({
870
- ok: false,
871
- accepted: false,
872
- mailId: input.mailId,
873
- code: outcome.code,
874
- reason: Object.freeze({ code: outcome.code, detail: outcome.reason }),
875
- escalated: outcome.escalated,
876
- });
877
- }
878
- return Object.freeze({
879
- ok: true,
880
- accepted: true,
881
- mailId: outcome.mail.id,
882
- mail: outcome.mail,
883
- ...(outcome.reply ? { reply: outcome.reply } : {}),
884
- ...(outcome.result ? { result: outcome.result } : outcome.reply ? { result: outcome.reply } : {}),
885
- });
886
- },
887
-
888
- async writeBoard(engagementId, input) {
889
- const key = typeof input.key === 'string' ? input.key.trim() : '';
890
- if (!key) throw new TypeError('Board entry key must be a non-empty string');
891
- const kind: BoardEntryKind = input.kind ?? 'note';
892
- let board = boards.get(engagementId);
893
- if (!board) {
894
- board = new Map<string, BoardEntry>();
895
- boards.set(engagementId, board);
896
- }
897
- const existing = board.get(key);
898
- const version = (existing?.version ?? 0) + 1;
899
- const updatedAt = clock();
900
- const entry: BoardEntry = Object.freeze({
901
- key,
902
- kind,
903
- content: input.content,
904
- author: input.author,
905
- updatedAt,
906
- version,
907
- ...(input.status !== undefined ? { status: input.status } : {}),
908
- });
909
- board.set(key, entry);
910
- await record(engagementId, 'board_written', {
911
- actor: input.author,
912
- key,
913
- kind,
914
- content: input.content,
915
- ...(input.status !== undefined ? { status: input.status } : {}),
916
- ...(input.mailId !== undefined ? { mailId: input.mailId } : {}),
917
- version,
918
- });
919
- return entry;
920
- },
921
-
922
- async readBoard(engagementId, filter) {
923
- const board = boards.get(engagementId);
924
- if (board && board.size > 0) {
925
- const entries = [...board.values()].filter((entry) => {
926
- if (filter?.key !== undefined && entry.key !== filter.key) return false;
927
- if (filter?.kind !== undefined && entry.kind !== filter.kind) return false;
928
- return true;
929
- });
930
- return Object.freeze(entries);
931
- }
932
- const records = await options.journal.read(engagementId);
933
- return reconstructBoardFromEvents(records, filter);
934
- },
935
-
936
- async charge(current, usage) {
937
- const result = options.ledger.charge(current.grantId, usage);
938
- await record(current.engagementId, result.ok ? 'budget_charged' : 'budget_exceeded', {
939
- grantId: current.grantId,
940
- mailId: current.id,
941
- usage,
942
- ...(result.ok ? {} : { exhausted: result.exhausted ?? [] }),
943
- });
944
- return result;
945
- },
946
-
947
- cancel(mailId, reason) {
948
- return mailbox.cancel(mailId, reason);
949
- },
950
-
951
- cancelSender(actor, lane, senderId, reason) {
952
- return mailbox.cancelSender({ actor, lane }, senderId, reason);
953
- },
954
-
955
- engagement(id) {
956
- return engagements.get(id)?.engagement;
957
- },
958
-
959
- graph(id) {
960
- const state = engagements.get(id) ?? allEngagements.get(id);
961
- return state ? graphOf(state) : undefined;
962
- },
963
-
964
- idle() {
965
- return mailbox.idle();
966
- },
967
-
968
- async close(reason) {
969
- closed = true;
970
- await mailbox.close(reason);
971
- for (const waiter of askWaiters.values()) waiter.resolve({ status: 'cancelled', content: reason ?? 'harness closed' });
972
- askWaiters.clear();
973
- },
974
- };
975
- }