@zhin.js/harness 0.0.0-stage → 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.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * ADR 0048 Agent Harness — shared contracts (H1).
3
+ *
4
+ * Everything here is plain, serializable data plus the interfaces that the
5
+ * harness components implement. Components (authority, budget, guard,
6
+ * journal, mailbox) depend on this file only, never on each other, so they
7
+ * can be built and tested independently and composed in `harness.ts`.
8
+ *
9
+ * Rules that hold across all components:
10
+ * - No platform (IM) types. Venues are opaque references.
11
+ * - Every value that crosses a mailbox is JSON-serializable (A2A in H5).
12
+ * - Pure functions never mutate their inputs; returned objects are frozen.
13
+ */
14
+ /**
15
+ * Trust levels, lowest first. Comparisons use this order only; never compare
16
+ * strings any other way.
17
+ */
18
+ export const TRUST_LEVELS = ['stranger', 'group_member', 'trusted', 'master'];
19
+ export const ALL = Object.freeze({ kind: 'all' });
20
+ export const NONE = Object.freeze({ kind: 'only', names: Object.freeze([]) });
21
+ export function only(names) {
22
+ return Object.freeze({ kind: 'only', names: Object.freeze([...new Set(names)].sort()) });
23
+ }
24
+ export function scopeHas(scope, name) {
25
+ return scope.kind === 'all' || scope.names.includes(name);
26
+ }
27
+ /** Set intersection. `all ∩ x = x`. Result names are sorted and unique. */
28
+ export function intersectScopes(a, b) {
29
+ if (a.kind === 'all')
30
+ return b.kind === 'all' ? ALL : only(b.names);
31
+ if (b.kind === 'all')
32
+ return only(a.names);
33
+ const right = new Set(b.names);
34
+ return only(a.names.filter((name) => right.has(name)));
35
+ }
36
+ /** `true` when every name `inner` allows is also allowed by `outer`. */
37
+ export function scopeWithin(inner, outer) {
38
+ if (outer.kind === 'all')
39
+ return true;
40
+ if (inner.kind === 'all')
41
+ return false;
42
+ return inner.names.every((name) => outer.names.includes(name));
43
+ }
44
+ export const BUDGET_KEYS = ['modelCalls', 'tokens', 'wallMs', 'sideEffects'];
45
+ export const DEFAULT_GUARD_LIMITS = Object.freeze({
46
+ maxDepth: 4,
47
+ maxFanout: 16,
48
+ maxPairRoundTrips: 3,
49
+ maxDuplicates: 2,
50
+ });
51
+ export const DEFAULT_JOURNAL_RETENTION = Object.freeze({
52
+ maxAgeMs: 30 * 24 * 60 * 60 * 1000,
53
+ maxEventsPerEngagement: 5000,
54
+ });
55
+ export class JournalFullError extends Error {
56
+ engagementId;
57
+ limit;
58
+ code = 'journal_full';
59
+ constructor(engagementId, limit) {
60
+ super(`Engagement ${engagementId} reached the journal limit of ${limit} events`);
61
+ this.engagementId = engagementId;
62
+ this.limit = limit;
63
+ this.name = 'JournalFullError';
64
+ }
65
+ }
66
+ /** Lane of a mail: `mail.lane`, or its engagement. */
67
+ export function laneOf(mail) {
68
+ return mail.lane ?? `engagement:${mail.engagementId}`;
69
+ }
70
+ export function mailboxKeyOf(mail) {
71
+ return { actor: mail.to, lane: laneOf(mail), engagementId: mail.engagementId };
72
+ }
73
+ /**
74
+ * Stable sender id the inbox policies compare: the human body's `senderId`,
75
+ * else the principal's id; the actor id for actor mail; `system:<source>`.
76
+ * Always transport-derived (invariant 6), never read from free text.
77
+ */
78
+ export function senderOf(mail) {
79
+ if (mail.body.kind === 'human' && mail.body.senderId)
80
+ return mail.body.senderId;
81
+ switch (mail.from.kind) {
82
+ case 'human': return mail.from.principal.id;
83
+ case 'actor': return mail.from.actor;
84
+ default: return `system:${mail.from.source}`;
85
+ }
86
+ }
package/lib/guard.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * ADR 0048 §3.4 Guard Kernel — mail admission (H1).
3
+ *
4
+ * `admitMail` is pure: it reads the mail, the engagement graph and the
5
+ * context, and returns the first denial in a fixed order (or allow). It never
6
+ * looks at prompt text except to normalize content for duplicate detection.
7
+ *
8
+ * Order (first failure wins):
9
+ * 1. engagement known → `unknown_engagement`
10
+ * 2. causation (invariant 3) → `missing_causation`
11
+ * 3. authority (invariant 1) → `authority_not_attenuated` / `missing_causation` (forged result / signal)
12
+ * 4. contacts and acceptance → `contact_not_allowed` / `not_accepted_by_target`
13
+ * 5. depth (invariant 5) → `depth_exceeded` (escalates)
14
+ * 6. fanout → `fanout_exceeded` (escalates)
15
+ * 7. pair round trips → `pair_round_trips_exceeded` (escalates)
16
+ * 8. duplicates → `duplicate_mail` (escalates)
17
+ */
18
+ import { type Authority, type Engagement, type EngagementGraphView, type GuardKernel, type Mail } from './contracts.js';
19
+ export interface GuardKernelOptions {
20
+ /** `true` when `child` grants nothing beyond `parent` (supplied by the authority module). */
21
+ isAttenuationOf(child: Authority, parent: Authority): boolean;
22
+ }
23
+ /** Builds a read-only, id-indexed view over an engagement's admitted mails. */
24
+ export declare function createEngagementGraph(engagement: Engagement, mails: readonly Mail[]): EngagementGraphView;
25
+ export declare function createGuardKernel(options: GuardKernelOptions): GuardKernel;
package/lib/guard.js ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * ADR 0048 §3.4 Guard Kernel — mail admission (H1).
3
+ *
4
+ * `admitMail` is pure: it reads the mail, the engagement graph and the
5
+ * context, and returns the first denial in a fixed order (or allow). It never
6
+ * looks at prompt text except to normalize content for duplicate detection.
7
+ *
8
+ * Order (first failure wins):
9
+ * 1. engagement known → `unknown_engagement`
10
+ * 2. causation (invariant 3) → `missing_causation`
11
+ * 3. authority (invariant 1) → `authority_not_attenuated` / `missing_causation` (forged result / signal)
12
+ * 4. contacts and acceptance → `contact_not_allowed` / `not_accepted_by_target`
13
+ * 5. depth (invariant 5) → `depth_exceeded` (escalates)
14
+ * 6. fanout → `fanout_exceeded` (escalates)
15
+ * 7. pair round trips → `pair_round_trips_exceeded` (escalates)
16
+ * 8. duplicates → `duplicate_mail` (escalates)
17
+ */
18
+ import { scopeHas, } from './contracts.js';
19
+ const ALLOW = Object.freeze({ allow: true });
20
+ function deny(code, reason, escalate = false) {
21
+ return Object.freeze({ allow: false, code, reason, escalate });
22
+ }
23
+ /** Builds a read-only, id-indexed view over an engagement's admitted mails. */
24
+ export function createEngagementGraph(engagement, mails) {
25
+ const list = Object.freeze([...mails]);
26
+ const byId = new Map();
27
+ for (const mail of list) {
28
+ if (!byId.has(mail.id))
29
+ byId.set(mail.id, mail);
30
+ }
31
+ return Object.freeze({
32
+ engagement,
33
+ mails: list,
34
+ get: (id) => byId.get(id),
35
+ });
36
+ }
37
+ /** Agent definition name of an actor id (`<agent>` or `<agent>#<instance>`). */
38
+ function agentOf(actor) {
39
+ const hash = actor.indexOf('#');
40
+ return hash === -1 ? actor : actor.slice(0, hash);
41
+ }
42
+ function describeSender(from) {
43
+ switch (from.kind) {
44
+ case 'actor':
45
+ return `actor ${from.actor}`;
46
+ case 'human':
47
+ return `human ${from.principal.id}`;
48
+ case 'system':
49
+ return `system ${from.source}`;
50
+ }
51
+ }
52
+ function senderKey(from) {
53
+ switch (from.kind) {
54
+ case 'actor':
55
+ return `actor:${from.actor}`;
56
+ case 'human':
57
+ return `human:${from.principal.id}`;
58
+ case 'system':
59
+ return `system:${from.source}`;
60
+ }
61
+ }
62
+ function isAskOrDelegate(mail) {
63
+ return mail.kind === 'ask' || mail.kind === 'delegate';
64
+ }
65
+ function isHop(mail) {
66
+ return mail.from.kind === 'actor' && isAskOrDelegate(mail);
67
+ }
68
+ function normalize(text) {
69
+ return text.trim().replace(/\s+/g, ' ').toLowerCase();
70
+ }
71
+ /** Text compared for duplicate detection; `undefined` for kinds that are not checked. */
72
+ function contentOf(mail) {
73
+ switch (mail.body.kind) {
74
+ case 'ask':
75
+ return normalize(mail.body.question);
76
+ case 'delegate':
77
+ return normalize(mail.body.brief);
78
+ case 'tell':
79
+ return normalize(mail.body.content);
80
+ default:
81
+ return undefined;
82
+ }
83
+ }
84
+ /** Number of actor-sent ask/delegate hops from `mail` back to the root, including `mail`. */
85
+ function depthOf(mail, graph) {
86
+ let depth = 0;
87
+ const seen = new Set();
88
+ let current = mail;
89
+ while (current && !seen.has(current.id)) {
90
+ seen.add(current.id);
91
+ if (isHop(current))
92
+ depth += 1;
93
+ current = current.causationId === undefined ? undefined : graph.get(current.causationId);
94
+ }
95
+ return depth;
96
+ }
97
+ function checkResult(mail, sender, graph) {
98
+ if (mail.body.kind !== 'result')
99
+ return undefined;
100
+ const original = graph.get(mail.body.inReplyTo);
101
+ if (!original) {
102
+ return deny('missing_causation', `Result ${mail.id} from actor ${sender} replies to unknown mail ${mail.body.inReplyTo}`);
103
+ }
104
+ if (!isAskOrDelegate(original)) {
105
+ return deny('missing_causation', `Result ${mail.id} from actor ${sender} replies to ${original.kind} mail ${original.id}, not an ask or delegate`);
106
+ }
107
+ if (original.to !== sender) {
108
+ return deny('missing_causation', `Result ${mail.id} from actor ${sender} replies to mail ${original.id} that was addressed to ${original.to}`);
109
+ }
110
+ const rootActor = graph.get(graph.engagement.rootMailId)?.to;
111
+ const sentByTarget = original.from.kind === 'actor' ? original.from.actor === mail.to : mail.to === rootActor;
112
+ if (!sentByTarget) {
113
+ return deny('missing_causation', `Result ${mail.id} from actor ${sender} is addressed to ${mail.to}, but mail ${original.id} was sent by ${describeSender(original.from)}`);
114
+ }
115
+ return undefined;
116
+ }
117
+ export function createGuardKernel(options) {
118
+ const { isAttenuationOf } = options;
119
+ function admitMail(mail, ctx) {
120
+ const { graph, limits, targetCeiling, senderAuthority } = ctx;
121
+ const who = describeSender(mail.from);
122
+ // 1. Engagement
123
+ if (!graph) {
124
+ return deny('unknown_engagement', `Mail ${mail.id} from ${who} names unknown engagement ${mail.engagementId}`);
125
+ }
126
+ if (mail.engagementId !== graph.engagement.id) {
127
+ return deny('unknown_engagement', `Mail ${mail.id} from ${who} names engagement ${mail.engagementId}, but was routed into ${graph.engagement.id}`);
128
+ }
129
+ // 2. Causation (invariant 3)
130
+ if (mail.causationId === undefined) {
131
+ if (mail.id !== graph.engagement.rootMailId) {
132
+ return deny('missing_causation', `Mail ${mail.id} from ${who} to ${mail.to} has no causation and is not the root mail of engagement ${graph.engagement.id}`);
133
+ }
134
+ }
135
+ else if (!graph.get(mail.causationId)) {
136
+ return deny('missing_causation', `Mail ${mail.id} from ${who} to ${mail.to} cites unknown causation ${mail.causationId}`);
137
+ }
138
+ // 3. Authority (invariant 1); results and signals are checked structurally instead
139
+ if (mail.from.kind === 'actor') {
140
+ const sender = mail.from.actor;
141
+ if (mail.body.kind === 'result') {
142
+ const forged = checkResult(mail, sender, graph);
143
+ if (forged)
144
+ return forged;
145
+ }
146
+ else if (mail.body.kind === 'signal') {
147
+ if (!graph.get(mail.body.target)) {
148
+ return deny('missing_causation', `Signal ${mail.id} from actor ${sender} targets unknown mail ${mail.body.target}`);
149
+ }
150
+ }
151
+ else if (!senderAuthority
152
+ || !isAttenuationOf(mail.authority, senderAuthority)
153
+ || mail.authority.chain.at(-1) !== sender) {
154
+ return deny('authority_not_attenuated', senderAuthority
155
+ ? `Mail ${mail.id} from actor ${sender} to ${mail.to} carries authority wider than the sender's own`
156
+ : `Mail ${mail.id} from actor ${sender} to ${mail.to} has no sender authority to attenuate from`);
157
+ }
158
+ }
159
+ // 4. Contacts and target acceptance
160
+ const targetAgent = agentOf(mail.to);
161
+ const contactKind = mail.kind === 'ask' || mail.kind === 'delegate' || mail.kind === 'tell';
162
+ if (mail.from.kind === 'actor' && contactKind) {
163
+ // senderAuthority is present here: step 3 denied otherwise.
164
+ if (!senderAuthority || !scopeHas(senderAuthority.contacts, targetAgent)) {
165
+ return deny('contact_not_allowed', `Actor ${mail.from.actor} may not contact agent ${targetAgent} (not in its contacts)`);
166
+ }
167
+ const fromAgent = agentOf(mail.from.actor);
168
+ if (!scopeHas(targetCeiling.acceptsFrom, fromAgent)) {
169
+ return deny('not_accepted_by_target', `Agent ${targetAgent} does not accept ${mail.kind} mail from agent ${fromAgent}`);
170
+ }
171
+ }
172
+ else if (mail.from.kind === 'human' && mail.kind === 'human') {
173
+ if (!scopeHas(targetCeiling.acceptsFrom, 'human')) {
174
+ return deny('not_accepted_by_target', `Agent ${targetAgent} does not accept mail from humans (sender ${mail.from.principal.id})`);
175
+ }
176
+ }
177
+ // 5–7 apply to ask / delegate only
178
+ if (isAskOrDelegate(mail)) {
179
+ // 5. Depth (invariant 5)
180
+ const depth = depthOf(mail, graph);
181
+ if (depth > limits.maxDepth) {
182
+ return deny('depth_exceeded', `${mail.kind} from ${who} to ${mail.to} would reach delegation depth ${depth}, above the limit of ${limits.maxDepth}`, true);
183
+ }
184
+ // 6. Fanout
185
+ const fanout = graph.mails.filter(isAskOrDelegate).length + 1;
186
+ if (fanout > limits.maxFanout) {
187
+ return deny('fanout_exceeded', `${mail.kind} from ${who} to ${mail.to} would be ask/delegate #${fanout} in engagement ${graph.engagement.id}, above the limit of ${limits.maxFanout}`, true);
188
+ }
189
+ // 7. Pair round trips
190
+ if (mail.from.kind === 'actor') {
191
+ const a = mail.from.actor;
192
+ const b = mail.to;
193
+ const exchanged = graph.mails.filter((m) => isAskOrDelegate(m) &&
194
+ m.from.kind === 'actor' &&
195
+ ((m.from.actor === a && m.to === b) || (m.from.actor === b && m.to === a))).length;
196
+ if (exchanged + 1 > limits.maxPairRoundTrips) {
197
+ return deny('pair_round_trips_exceeded', `Actors ${a} and ${b} would exchange ask/delegate #${exchanged + 1}, above the limit of ${limits.maxPairRoundTrips}`, true);
198
+ }
199
+ }
200
+ }
201
+ // 8. Duplicates
202
+ const content = contentOf(mail);
203
+ if (content !== undefined) {
204
+ const key = senderKey(mail.from);
205
+ const sent = graph.mails.filter((m) => m.kind === mail.kind && m.to === mail.to && senderKey(m.from) === key && contentOf(m) === content).length;
206
+ if (sent + 1 > limits.maxDuplicates) {
207
+ return deny('duplicate_mail', `${who} would send the same ${mail.kind} to ${mail.to} ${sent + 1} times, above the limit of ${limits.maxDuplicates}`, true);
208
+ }
209
+ }
210
+ return ALLOW;
211
+ }
212
+ return Object.freeze({ admitMail });
213
+ }
@@ -0,0 +1,197 @@
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 { type ActorId, type ActorTurnResult, type ActorTurnRunner, type AgentCeiling, type AgentName, type Authority, type AuthorityNarrowing, type BoardEntry, type BoardReadFilter, type BoardWriteInput, type BudgetAmounts, type BudgetLedger, type BudgetResult, type BudgetSnapshot, type Engagement, type EngagementGraphView, type EngagementId, type EngagementJournal, type FollowUpInput, type GuardDenyCode, type GuardKernel, type GuardLimits, type Mail, type MailBody, type MailId, type Mailbox, type InboxDropReason, type InboxPolicy, type InboxRejectCode, type InboxSetTimer, type MailboxOptions, type PostResult, type Principal, type RemoteMailTransport, type SendAck, type VenueRef } from './contracts.js';
39
+ export type HarnessDenyCode = GuardDenyCode | InboxRejectCode | 'unknown_agent' | 'wait_cycle' | 'budget_exceeded' | 'closed' | 'frozen';
40
+ export type SendOutcome = {
41
+ readonly ok: true;
42
+ readonly accepted?: true;
43
+ readonly mailId?: MailId;
44
+ readonly mail: Mail;
45
+ readonly reply?: {
46
+ readonly status: ActorTurnResult['status'];
47
+ readonly content: string;
48
+ };
49
+ readonly result?: {
50
+ readonly status: ActorTurnResult['status'] | 'rejected';
51
+ readonly content: string;
52
+ };
53
+ } | {
54
+ readonly ok: false;
55
+ readonly accepted?: false;
56
+ readonly mailId?: MailId;
57
+ readonly mail?: Mail;
58
+ readonly code: HarnessDenyCode;
59
+ readonly reason: string;
60
+ readonly escalated: boolean;
61
+ readonly reply?: {
62
+ readonly status: ActorTurnResult['status'];
63
+ readonly content: string;
64
+ };
65
+ readonly result?: {
66
+ readonly status: ActorTurnResult['status'] | 'rejected';
67
+ readonly content: string;
68
+ };
69
+ };
70
+ export interface SendRequest {
71
+ readonly kind: 'ask' | 'delegate' | 'tell';
72
+ readonly to: ActorId;
73
+ readonly text: string;
74
+ readonly narrowing?: AuthorityNarrowing;
75
+ readonly budget?: Partial<BudgetAmounts>;
76
+ /** For `delegate`: what the delegator expects back. */
77
+ readonly deliverable?: string;
78
+ /** Optional timeout (ms) for remote transport calls. */
79
+ readonly timeoutMs?: number;
80
+ }
81
+ export interface OpenEngagementInput {
82
+ readonly principal: Principal;
83
+ /** Actor that receives the root mail (the venue's entry actor). */
84
+ readonly entry: ActorId;
85
+ readonly body: Extract<MailBody, {
86
+ kind: 'human' | 'timer' | 'event';
87
+ }>;
88
+ readonly budget: BudgetAmounts;
89
+ readonly approvers?: readonly string[];
90
+ readonly venue?: VenueRef;
91
+ /**
92
+ * Serial lane of the root mail inside the entry actor's inbox (D1): the IM
93
+ * session key for human and scene-event mail, `timer:<jobId>` for timers,
94
+ * `event:<source>` for webhooks. Omitted = the engagement's own lane.
95
+ */
96
+ readonly lane?: string;
97
+ /**
98
+ * Called synchronously with the admitted root mail right before it is
99
+ * posted. The inbox may start its turn in the next microtask, before
100
+ * `deliver` resolves, so a host that correlates turns with its own
101
+ * callers registers the mail here.
102
+ */
103
+ readonly onPosting?: (root: Mail) => void;
104
+ }
105
+ /** How the inbox took an inbound mail. `stopped`: it was a stop request and was consumed. */
106
+ export type DeliverDisposition = Extract<PostResult, {
107
+ accepted: true;
108
+ }>['disposition'];
109
+ export type OpenOutcome = {
110
+ readonly ok: true;
111
+ readonly engagement: Engagement;
112
+ readonly root: Mail;
113
+ readonly disposition: DeliverDisposition;
114
+ /** 1-based queue position; 0 when consumed (`stopped`). */
115
+ readonly position: number;
116
+ } | {
117
+ readonly ok: false;
118
+ readonly code: HarnessDenyCode;
119
+ readonly reason: string;
120
+ };
121
+ /** `deliver` and `open` are the same call. */
122
+ export type DeliverOutcome = OpenOutcome;
123
+ export interface EscalationNotice {
124
+ readonly engagement: Engagement;
125
+ readonly mail: Mail;
126
+ readonly code: HarnessDenyCode;
127
+ readonly reason: string;
128
+ }
129
+ export interface HarnessOptions {
130
+ readonly guard: GuardKernel;
131
+ readonly ledger: BudgetLedger;
132
+ readonly journal: EngagementJournal;
133
+ readonly createMailbox: (options: MailboxOptions) => Mailbox;
134
+ /**
135
+ * Agent definitions' ceilings; `undefined` = unknown agent. `engagementId`
136
+ * names the engagement asking, so a long-lived host can resolve agents per
137
+ * engagement (the directory of the turn that opened it).
138
+ */
139
+ readonly ceilingOf: (agent: AgentName, engagementId: EngagementId) => AgentCeiling | undefined;
140
+ readonly rootAuthority: (principal: Principal, ceiling: AgentCeiling, options?: {
141
+ approvers?: readonly string[];
142
+ }) => Authority;
143
+ readonly attenuate: (parent: Authority, narrowing: AuthorityNarrowing, target: AgentCeiling, via: ActorId) => Authority;
144
+ /** Runs one actor turn; the agent layer calls `harness.send` / `harness.charge` from inside it. */
145
+ readonly runTurn: ActorTurnRunner;
146
+ readonly limits?: GuardLimits;
147
+ readonly maxConcurrentTurns?: number;
148
+ readonly maxQueuedPerKey?: number;
149
+ /** Inbox scheduling (H3): policy (D2), queue TTL and the timer / clock windows use. */
150
+ readonly inbox?: Readonly<{
151
+ policy?: InboxPolicy;
152
+ queueTtlMs?: number;
153
+ now?: () => number;
154
+ setTimer?: InboxSetTimer;
155
+ }>;
156
+ readonly clock?: () => number;
157
+ readonly newId?: () => string;
158
+ readonly remoteTransport?: RemoteMailTransport;
159
+ readonly onEscalate?: (notice: EscalationNotice) => void | Promise<void>;
160
+ /** Called once when an engagement closed (after `engagement_closed` was journaled). */
161
+ readonly onEngagementClosed?: (engagement: Engagement) => void;
162
+ readonly onError?: (error: unknown) => void;
163
+ }
164
+ export type CreateHarnessOptions = HarnessOptions;
165
+ export interface Harness {
166
+ /**
167
+ * Delivers an inbound mail (`human` / `timer` / `event`): opens its
168
+ * engagement and posts the root mail to the entry actor's inbox lane. May be
169
+ * called at any time, including while a turn of that lane is running
170
+ * (mid-turn delivery): the inbox policy then decides whether the new mail
171
+ * queues, merges, supersedes or stops the running turn (whose stale reply
172
+ * must then not be sent; see `ActorTurnControl.staleCheck`).
173
+ */
174
+ deliver(input: OpenEngagementInput): Promise<DeliverOutcome>;
175
+ /** Same as `deliver`. */
176
+ open(input: OpenEngagementInput): Promise<OpenOutcome>;
177
+ /** Remaining budget of the grant `current` runs on. */
178
+ remaining(current: Mail): BudgetAmounts | undefined;
179
+ /** Called by collaboration tools while `current` (the mail being processed) is running. */
180
+ send(from: ActorId, current: Mail, request: SendRequest): Promise<SendOutcome>;
181
+ /** Follows up on previously delegated / asked work within the same engagement. */
182
+ followUp(input: FollowUpInput): Promise<SendAck>;
183
+ /** Writes an artifact, note, or todo item to the engagement's shared blackboard. */
184
+ writeBoard(engagementId: EngagementId, input: BoardWriteInput): Promise<BoardEntry>;
185
+ /** Reads entries from the engagement's shared blackboard, reconstructing from journal if needed. */
186
+ readBoard(engagementId: EngagementId, filter?: BoardReadFilter): Promise<readonly BoardEntry[]>;
187
+ /** Records model / tool usage against the grant of `current`. */
188
+ charge(current: Mail, usage: Partial<BudgetAmounts>): Promise<BudgetResult<BudgetSnapshot>>;
189
+ cancel(mailId: MailId, reason?: string): boolean;
190
+ /** Drops every queued mail of `senderId` on one lane of `actor` (reported `cancelled` with `reason`). */
191
+ cancelSender(actor: ActorId, lane: string, senderId: string, reason?: InboxDropReason): number;
192
+ engagement(id: EngagementId): Engagement | undefined;
193
+ graph(id: EngagementId): EngagementGraphView | undefined;
194
+ idle(): Promise<void>;
195
+ close(reason?: string): Promise<void>;
196
+ }
197
+ export declare function createHarness(options: HarnessOptions): Harness;