@hunterzhu/pulse-runtime 0.1.5 → 0.1.6

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.
@@ -57,6 +57,8 @@ export interface FactInboxSnapshot<T extends JsonValue = JsonValue> {
57
57
  nextSeq: number;
58
58
  seen: string[];
59
59
  queue: FactEnvelope<T>[];
60
+ /** Number of queued urgent entries at the head of `queue` (v2+). */
61
+ urgentCount?: number;
60
62
  dedupeLedger?: FactInboxDedupeLedgerSnapshot;
61
63
  }
62
64
  export declare class FactInbox<T extends JsonValue = JsonValue> {
@@ -64,13 +66,17 @@ export declare class FactInbox<T extends JsonValue = JsonValue> {
64
66
  private readonly seen;
65
67
  private nextSeq;
66
68
  private archivedThrough;
69
+ private urgentCount;
67
70
  private dedupeArchive;
68
71
  constructor(options?: {
69
72
  dedupeArchive?: FactInboxDedupeArchive;
70
73
  });
71
74
  /** Attach a backend-provided archive before the first compaction. */
72
75
  attachDedupeArchive(archive: FactInboxDedupeArchive): void;
76
+ private enqueueInternal;
73
77
  enqueue(fact: T, eventId: string): FactEnvelope<T> | undefined;
78
+ /** Insert a host control fact ahead of already queued background work. */
79
+ enqueueUrgent(fact: T, eventId: string): FactEnvelope<T> | undefined;
74
80
  drain(limit?: number): FactEnvelope<T>[];
75
81
  get size(): number;
76
82
  get dedupeWatermark(): number;
@@ -14,6 +14,7 @@ export class FactInbox {
14
14
  seen = new Map();
15
15
  nextSeq = 1;
16
16
  archivedThrough = 0;
17
+ urgentCount = 0;
17
18
  dedupeArchive;
18
19
  constructor(options = {}) {
19
20
  this.dedupeArchive = options.dedupeArchive;
@@ -26,18 +27,28 @@ export class FactInbox {
26
27
  throw new Error('FACT_INBOX_DEDUPE_ARCHIVE_ALREADY_COMPACTED');
27
28
  this.dedupeArchive = archive;
28
29
  }
29
- enqueue(fact, eventId) {
30
+ enqueueInternal(fact, eventId, urgent) {
30
31
  if (!eventId || this.seen.has(eventId) || this.dedupeArchive?.contains(eventId))
31
32
  return undefined;
32
33
  const envelope = { eventId, receivedSeq: this.nextSeq++, fact: structuredClone(fact) };
33
34
  this.seen.set(eventId, envelope.receivedSeq);
34
- this.queue.push(envelope);
35
+ if (urgent) {
36
+ this.queue.splice(this.urgentCount, 0, envelope);
37
+ this.urgentCount++;
38
+ }
39
+ else
40
+ this.queue.push(envelope);
35
41
  return structuredClone(envelope);
36
42
  }
43
+ enqueue(fact, eventId) { return this.enqueueInternal(fact, eventId, false); }
44
+ /** Insert a host control fact ahead of already queued background work. */
45
+ enqueueUrgent(fact, eventId) { return this.enqueueInternal(fact, eventId, true); }
37
46
  drain(limit = Number.POSITIVE_INFINITY) {
38
47
  if (limit !== Number.POSITIVE_INFINITY && (!Number.isInteger(limit) || limit < 0))
39
48
  throw new Error('INVALID_FACT_DRAIN_LIMIT');
40
- return this.queue.splice(0, limit).map((envelope) => structuredClone(envelope));
49
+ const drained = this.queue.splice(0, limit);
50
+ this.urgentCount = Math.max(0, this.urgentCount - drained.length);
51
+ return drained.map((envelope) => structuredClone(envelope));
41
52
  }
42
53
  get size() { return this.queue.length; }
43
54
  get dedupeWatermark() { return this.archivedThrough; }
@@ -50,6 +61,7 @@ export class FactInbox {
50
61
  nextSeq: this.nextSeq,
51
62
  seen: [...this.seen.keys()],
52
63
  queue: this.queue.map((envelope) => structuredClone(envelope)),
64
+ ...(this.urgentCount === 0 ? {} : { urgentCount: this.urgentCount }),
53
65
  dedupeLedger: {
54
66
  schemaVersion: 1,
55
67
  archivedThrough: this.archivedThrough,
@@ -117,10 +129,11 @@ export class FactInbox {
117
129
  this.seen.set(eventId, receivedSeq);
118
130
  this.nextSeq = restored.nextSeq;
119
131
  this.archivedThrough = restored.archivedThrough;
132
+ this.urgentCount = restored.urgentCount;
120
133
  }
121
134
  static fromSnapshot(snapshot, options = {}) {
122
135
  const value = snapshot;
123
- if (!value || (value.schemaVersion !== 1 && value.schemaVersion !== 2) || !Number.isInteger(value.nextSeq) || value.nextSeq < 1 || !Array.isArray(value.seen) || value.seen.some((eventId) => typeof eventId !== 'string' || eventId.length === 0) || !Array.isArray(value.queue))
136
+ if (!value || (value.schemaVersion !== 1 && value.schemaVersion !== 2) || !Number.isInteger(value.nextSeq) || value.nextSeq < 1 || !Array.isArray(value.seen) || value.seen.some((eventId) => typeof eventId !== 'string' || eventId.length === 0) || !Array.isArray(value.queue) || (value.urgentCount !== undefined && (!Number.isInteger(value.urgentCount) || value.urgentCount < 0 || value.urgentCount > value.queue.length)))
124
137
  throw new Error('INVALID_FACT_INBOX_SNAPSHOT');
125
138
  if (new Set(value.seen).size !== value.seen.length)
126
139
  throw new Error('INVALID_FACT_INBOX_SNAPSHOT');
@@ -151,8 +164,8 @@ export class FactInbox {
151
164
  throw new Error('INVALID_FACT_INBOX_SNAPSHOT');
152
165
  if (inbox.queue.some((candidate) => candidate.eventId === envelope.eventId || candidate.receivedSeq === envelope.receivedSeq))
153
166
  throw new Error('INVALID_FACT_INBOX_SNAPSHOT');
154
- if (envelope.receivedSeq <= maxReceivedSeq)
155
- throw new Error('INVALID_FACT_INBOX_SNAPSHOT');
167
+ // Urgent host facts may be inserted ahead of older entries, so queue
168
+ // order is intentionally different from received sequence order.
156
169
  const knownSeq = seen.get(envelope.eventId);
157
170
  if (knownSeq !== undefined && knownSeq !== envelope.receivedSeq)
158
171
  throw new Error('INVALID_FACT_INBOX_SNAPSHOT');
@@ -161,14 +174,25 @@ export class FactInbox {
161
174
  inbox.queue.push({ eventId: envelope.eventId, receivedSeq: envelope.receivedSeq, fact: structuredClone(envelope.fact) });
162
175
  maxReceivedSeq = Math.max(maxReceivedSeq, envelope.receivedSeq);
163
176
  }
177
+ // Preserve the serialized queue order. Urgent entries are allowed to have
178
+ // newer receivedSeq values ahead of background entries, but each priority
179
+ // band must retain its own FIFO order. This rejects ambiguous snapshots
180
+ // instead of silently normalizing a caller-provided reorder.
181
+ const urgentCount = value.urgentCount ?? 0;
182
+ const urgentSeqs = value.queue.slice(0, urgentCount).map((entry) => entry.receivedSeq);
183
+ const backgroundSeqs = value.queue.slice(urgentCount).map((entry) => entry.receivedSeq);
184
+ const isAscending = (seqs) => seqs.every((seq, index) => index === 0 || seq > seqs[index - 1]);
185
+ if (!isAscending(urgentSeqs) || !isAscending(backgroundSeqs))
186
+ throw new Error('INVALID_FACT_INBOX_SNAPSHOT');
164
187
  if (value.nextSeq <= maxReceivedSeq)
165
188
  throw new Error('INVALID_FACT_INBOX_SNAPSHOT');
166
189
  for (const [eventId, receivedSeq] of seen)
167
190
  inbox.seen.set(eventId, receivedSeq);
168
191
  inbox.nextSeq = value.nextSeq;
192
+ inbox.urgentCount = urgentCount;
169
193
  return inbox;
170
194
  }
171
- clear() { this.queue.length = 0; }
195
+ clear() { this.queue.length = 0; this.urgentCount = 0; }
172
196
  }
173
197
  export class ObservationInbox {
174
198
  maxEntries;
@@ -1,4 +1,4 @@
1
- import type { RuntimeEventInput, RuntimeState, RuntimeError, ContextVersion, JsonValue, AgentRecord, LaneRecord, EffectRecord, WaitRecord, ResultRecord, FindingRecord, ArtifactRecord, LaneId, WaitId, EffectId, HistoryRecord, MergeProposal, ToolCallCorrelation, PrivacyMetadata } from './types.js';
1
+ import type { RuntimeEventInput, RuntimeState, RuntimeError, ContextVersion, JsonValue, AgentRecord, LaneRecord, EffectRecord, WaitRecord, ResultRecord, FindingRecord, ArtifactRecord, LaneId, WaitId, EffectId, HistoryRecord, MergeProposal, ToolCallCorrelation, PrivacyMetadata, HumanInputRecord } from './types.js';
2
2
  export type Mutation = {
3
3
  op: 'setAgent';
4
4
  agentId: string;
@@ -36,6 +36,10 @@ export type Mutation = {
36
36
  } | {
37
37
  op: 'setToolCallCorrelation';
38
38
  record: ToolCallCorrelation;
39
+ } | {
40
+ op: 'setHumanInput';
41
+ inputId: string;
42
+ record: HumanInputRecord;
39
43
  } | {
40
44
  op: 'insertMergeProposal';
41
45
  proposal: MergeProposal;
@@ -21,7 +21,7 @@ export function forkRuntimeStateForAdmission(state, mutations) {
21
21
  for (const id of dirtyLanes) {
22
22
  const lane = lanes.get(id);
23
23
  if (lane)
24
- lanes.set(id, { ...lane, ...(lane.visibleResultRefs === undefined ? {} : { visibleResultRefs: new Set(lane.visibleResultRefs) }) });
24
+ lanes.set(id, { ...lane, ...(lane.visibleResultRefs === undefined ? {} : { visibleResultRefs: new Set(lane.visibleResultRefs) }), ...(lane.pendingHumanInputs === undefined ? {} : { pendingHumanInputs: structuredClone(lane.pendingHumanInputs) }) });
25
25
  }
26
26
  return {
27
27
  ...state,
@@ -33,6 +33,7 @@ export function forkRuntimeStateForAdmission(state, mutations) {
33
33
  artifacts: new Map(state.artifacts),
34
34
  toolCallCorrelations: new Map(state.toolCallCorrelations),
35
35
  mergeProposals: new Map(state.mergeProposals),
36
+ humanInputs: new Map(state.humanInputs),
36
37
  events: state.events.slice(),
37
38
  nextIds: { ...state.nextIds },
38
39
  trustedSanitizerIds: new Set(state.trustedSanitizerIds),
@@ -91,6 +92,9 @@ export function apply(state, mutations, defaults = {}) {
91
92
  case 'setToolCallCorrelation':
92
93
  state.toolCallCorrelations.set(mutation.record.toolCallId, mutation.record);
93
94
  break;
95
+ case 'setHumanInput':
96
+ state.humanInputs.set(mutation.inputId, mutation.record);
97
+ break;
94
98
  case 'insertMergeProposal':
95
99
  state.mergeProposals.set(mutation.proposal.id, mutation.proposal);
96
100
  break;
@@ -123,6 +123,8 @@ export interface LaneRecord {
123
123
  resume: ResumePoint;
124
124
  series?: SeriesLaneSpec;
125
125
  pendingResumeInput?: ResumeInput;
126
+ /** Human messages steered into this lane while it was executing or waiting. */
127
+ pendingHumanInputs?: HumanInputRecord[];
126
128
  /** Control proposals that arrived while another ResumeInput occupied the slot. */
127
129
  pendingControlProposals?: ControlProposal[];
128
130
  contextSnapshotVersion: ContextVersion;
@@ -319,6 +321,9 @@ export type ResumeInput = {
319
321
  } | {
320
322
  type: 'submitted';
321
323
  targets: Record<string, TargetRef>;
324
+ } | {
325
+ type: 'human';
326
+ input: HumanInputRecord;
322
327
  } | {
323
328
  type: 'control_error';
324
329
  error: RuntimeError;
@@ -559,6 +564,7 @@ export interface RuntimeState {
559
564
  artifacts: Map<ArtifactRef, ArtifactRecord>;
560
565
  toolCallCorrelations: Map<string, ToolCallCorrelation>;
561
566
  mergeProposals: Map<string, MergeProposal>;
567
+ humanInputs: Map<string, HumanInputRecord>;
562
568
  events: RuntimeEvent[];
563
569
  eventsCompactedThrough?: number;
564
570
  nextIds: {
@@ -598,6 +604,20 @@ export interface ToolCallCorrelation {
598
604
  toolEffectId: EffectId;
599
605
  resultRef?: ResultRef;
600
606
  }
607
+ /** Durable external input accepted while other Effects are still running. */
608
+ export interface HumanInputRecord {
609
+ id: string;
610
+ agentId: AgentId;
611
+ value: JsonValue;
612
+ receivedAt: number;
613
+ status: 'pending' | 'consumed' | 'deferred';
614
+ targetEffectId?: EffectId;
615
+ handledByLaneId?: LaneId;
616
+ decision?: 'respond' | 'steer' | 'spawn' | 'defer' | 'cancel';
617
+ decisionReason?: string;
618
+ decisionModelId?: string;
619
+ decidedAt?: number;
620
+ }
601
621
  export declare function createRuntimeState(maxTotalLanes?: number, options?: {
602
622
  maxQueuedEffects?: number;
603
623
  maxRunning?: Partial<Record<ConcurrencyClass, number>>;
@@ -84,7 +84,7 @@ export function privacyTaintsForDerivedRefs(state, lane, refs) {
84
84
  return output;
85
85
  }
86
86
  export function createRuntimeState(maxTotalLanes = 64, options = {}) {
87
- return { now: 0, agents: new Map(), lanes: new Map(), effects: new Map(), waits: new Map(), results: new Map(), artifacts: new Map(), toolCallCorrelations: new Map(), mergeProposals: new Map(), events: [], nextIds: { agent: 1, lane: 1, effect: 1, wait: 1, result: 1, artifact: 1, proposal: 1, event: 1 }, maxTotalLanes, maxQueuedEffects: options.maxQueuedEffects ?? 256, maxRunning: { llm: 4, tool: 16, agent: 4, none: Number.POSITIVE_INFINITY, ...(options.maxRunning ?? {}) }, forkAffinity: options.forkAffinity ?? 'advise', historySoftTokens: options.historySoftTokens ?? 8_000, historyHardTokens: options.historyHardTokens ?? 16_000, maxResultSummaryBytes: options.maxResultSummaryBytes ?? 4_096, trustedSanitizerIds: new Set(options.trustedSanitizerIds ?? []) };
87
+ return { now: 0, agents: new Map(), lanes: new Map(), effects: new Map(), waits: new Map(), results: new Map(), artifacts: new Map(), toolCallCorrelations: new Map(), mergeProposals: new Map(), humanInputs: new Map(), events: [], nextIds: { agent: 1, lane: 1, effect: 1, wait: 1, result: 1, artifact: 1, proposal: 1, event: 1 }, maxTotalLanes, maxQueuedEffects: options.maxQueuedEffects ?? 256, maxRunning: { llm: 4, tool: 16, agent: 4, none: Number.POSITIVE_INFINITY, ...(options.maxRunning ?? {}) }, forkAffinity: options.forkAffinity ?? 'advise', historySoftTokens: options.historySoftTokens ?? 8_000, historyHardTokens: options.historyHardTokens ?? 16_000, maxResultSummaryBytes: options.maxResultSummaryBytes ?? 4_096, trustedSanitizerIds: new Set(options.trustedSanitizerIds ?? []) };
88
88
  }
89
89
  export function privacyRank(label) { return label === 'public' ? 0 : label === 'cloud_allowed' ? 1 : 2; }
90
90
  export function strictestPrivacy(labels) { return labels.reduce((current, next) => privacyRank(next) > privacyRank(current) ? next : current, 'public'); }
@@ -26,6 +26,7 @@ export interface PulseSessionSnapshot {
26
26
  waits: unknown[];
27
27
  results: unknown[];
28
28
  mergeProposals: unknown[];
29
+ humanInputs: unknown[];
29
30
  quarantine: unknown[];
30
31
  observationsPending: number;
31
32
  }
@@ -36,10 +37,16 @@ export declare class PulseSession {
36
37
  /** Stable session handle used by the explicit warm-start API. */
37
38
  readonly sessionId: string;
38
39
  constructor(runtime: PulseRuntime, agentId: string);
40
+ private ownsAgent;
39
41
  private ownsEvent;
42
+ private hasActiveDescendant;
40
43
  stream(fromSeq?: number): AsyncIterable<SessionEvent>;
41
44
  snapshot(): Promise<PulseSessionSnapshot>;
42
45
  outcome(): Promise<Outcome>;
43
46
  reply(effectId: string, value: JsonValue): Promise<void>;
47
+ /** Submit a human message while the agent is still running. The scheduler
48
+ * records it immediately; targetEffectId is optional for direct replies to
49
+ * a waiting Human Effect. */
50
+ submitHumanInput(inputId: string, value: JsonValue, targetEffectId?: string): Promise<void>;
44
51
  cancel(reason: string): Promise<void>;
45
52
  }
@@ -19,15 +19,29 @@ export class PulseSession {
19
19
  };
20
20
  });
21
21
  }
22
+ ownsAgent(agentId) {
23
+ let current = this.runtime.state.agents.get(agentId);
24
+ const seen = new Set();
25
+ while (current && !seen.has(current.id)) {
26
+ if (current.id === this.agentId)
27
+ return true;
28
+ seen.add(current.id);
29
+ current = current.parentAgentId === undefined ? undefined : this.runtime.state.agents.get(current.parentAgentId);
30
+ }
31
+ return false;
32
+ }
22
33
  ownsEvent(event) {
23
- if (event.agentId === this.agentId)
34
+ if (event.agentId !== undefined && this.ownsAgent(event.agentId))
35
+ return true;
36
+ if (event.laneId !== undefined && this.ownsAgent(this.runtime.state.lanes.get(event.laneId)?.agentId ?? ''))
37
+ return true;
38
+ if (event.effectId !== undefined && this.ownsAgent(this.runtime.state.effects.get(event.effectId)?.agentId ?? ''))
24
39
  return true;
25
- if (event.laneId !== undefined)
26
- return this.runtime.state.lanes.get(event.laneId)?.agentId === this.agentId;
27
- if (event.effectId !== undefined)
28
- return this.runtime.state.effects.get(event.effectId)?.agentId === this.agentId;
29
40
  return false;
30
41
  }
42
+ hasActiveDescendant() {
43
+ return [...this.runtime.state.agents.values()].some((agent) => this.ownsAgent(agent.id) && agent.id !== this.agentId && agent.state !== undefined && !['succeeded', 'failed', 'cancelled'].includes(agent.state));
44
+ }
31
45
  async *stream(fromSeq = 0) {
32
46
  let cursor = fromSeq;
33
47
  let observationCursor = 0;
@@ -55,7 +69,7 @@ export class PulseSession {
55
69
  yield { kind: 'observation', type: 'observation', seq: observation.seq, observation: observation };
56
70
  }
57
71
  const root = [...this.runtime.state.lanes.values()].find((lane) => lane.agentId === this.agentId && lane.ownerLaneId === undefined);
58
- if (root && ['succeeded', 'failed', 'cancelled'].includes(root.status) && (this.runtime.state.events.at(-1)?.seq ?? compactedThrough) === cursor)
72
+ if (root && ['succeeded', 'failed', 'cancelled'].includes(root.status) && !this.hasActiveDescendant() && (this.runtime.state.events.at(-1)?.seq ?? compactedThrough) === cursor)
59
73
  return;
60
74
  await new Promise((resolve) => setImmediate(resolve));
61
75
  }
@@ -71,11 +85,12 @@ export class PulseSession {
71
85
  now: this.runtime.state.now,
72
86
  eventSeq: this.runtime.state.events.at(-1)?.seq ?? 0,
73
87
  agent: agent ? structuredClone({ id: agent.id, goal: agent.goal ?? null, state: agent.state ?? null, latestGlobalVersion: agent.latestGlobalVersion, globalVersions: [...agent.globalVersions.entries()].map(([version, value]) => ({ version, value, ...(agent.globalPrivacy?.get(version) === undefined ? {} : { privacy: agent.globalPrivacy.get(version) }) })) }) : null,
74
- lanes: [...this.runtime.state.lanes.values()].filter((lane) => laneIds.has(lane.id)).map((lane) => structuredClone({ id: lane.id, agentId: lane.agentId, ownerLaneId: lane.ownerLaneId ?? null, status: lane.status, cancelReason: lane.cancelReason ?? null, failure: lane.failure ?? null, version: lane.version, goal: lane.goal, priority: lane.priority, inheritedFloor: lane.inheritedFloor ?? null, readySince: lane.readySince, resume: lane.resume, pendingResumeInput: lane.pendingResumeInput ?? null, contextSnapshotVersion: lane.contextSnapshotVersion, context: lane.context, visibleResultRefs: lane.visibleResultRefs ? [...lane.visibleResultRefs] : [], historyPressure: lane.historyPressure ?? null, activeWaitId: lane.activeWaitId ?? null, children: [...lane.children], ownedEffectIds: [...lane.ownedEffectIds], resultRef: lane.resultRef ?? null, closingResult: lane.closingResult ?? null, consecutiveControlErrors: lane.consecutiveControlErrors ?? 0, unresolvedEffectIds: lane.unresolvedEffectIds ?? [], progressWatchdog: lane.progressWatchdog ?? null })),
88
+ lanes: [...this.runtime.state.lanes.values()].filter((lane) => laneIds.has(lane.id)).map((lane) => structuredClone({ id: lane.id, agentId: lane.agentId, ownerLaneId: lane.ownerLaneId ?? null, status: lane.status, cancelReason: lane.cancelReason ?? null, failure: lane.failure ?? null, version: lane.version, goal: lane.goal, priority: lane.priority, inheritedFloor: lane.inheritedFloor ?? null, readySince: lane.readySince, resume: lane.resume, pendingResumeInput: lane.pendingResumeInput ?? null, pendingHumanInputs: lane.pendingHumanInputs ?? [], contextSnapshotVersion: lane.contextSnapshotVersion, context: lane.context, visibleResultRefs: lane.visibleResultRefs ? [...lane.visibleResultRefs] : [], historyPressure: lane.historyPressure ?? null, activeWaitId: lane.activeWaitId ?? null, children: [...lane.children], ownedEffectIds: [...lane.ownedEffectIds], resultRef: lane.resultRef ?? null, closingResult: lane.closingResult ?? null, consecutiveControlErrors: lane.consecutiveControlErrors ?? 0, unresolvedEffectIds: lane.unresolvedEffectIds ?? [], progressWatchdog: lane.progressWatchdog ?? null })),
75
89
  effects: effects.map((effect) => structuredClone(effect)),
76
90
  waits: [...this.runtime.state.waits.values()].filter((wait) => laneIds.has(wait.laneId)).map((wait) => structuredClone(wait)),
77
91
  results: [...this.runtime.state.results.values()].filter((result) => (result.effectId !== undefined && effectIds.has(result.effectId)) || [...this.runtime.state.lanes.values()].some((lane) => laneIds.has(lane.id) && lane.resultRef === result.id)).map((result) => structuredClone(result)),
78
92
  mergeProposals: [...this.runtime.state.mergeProposals.values()].filter((proposal) => proposal.agentId === this.agentId).map((proposal) => structuredClone(proposal)),
93
+ humanInputs: [...this.runtime.state.humanInputs.values()].filter((input) => input.agentId === this.agentId).map((input) => structuredClone(input)),
79
94
  quarantine: structuredClone(this.runtime.quarantine.snapshot().filter((entry) => effectIds.has(entry.effectId))),
80
95
  observationsPending: this.runtime.observationInbox.snapshot().filter((observation) => observation.agentId === this.agentId).length,
81
96
  };
@@ -89,5 +104,11 @@ export class PulseSession {
89
104
  throw new Error('EFFECT_NOT_REPLYABLE');
90
105
  this.runtime.enqueueHostCommand({ type: 'reply', agentId: this.agentId, effectId, value });
91
106
  }
107
+ /** Submit a human message while the agent is still running. The scheduler
108
+ * records it immediately; targetEffectId is optional for direct replies to
109
+ * a waiting Human Effect. */
110
+ async submitHumanInput(inputId, value, targetEffectId) {
111
+ this.runtime.submitHumanInput(this.agentId, inputId, value, targetEffectId);
112
+ }
92
113
  async cancel(reason) { this.runtime.requestCancel(this.agentId, reason); }
93
114
  }
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export * from './transitions/index.js';
7
7
  export * from './dependencies/index.js';
8
8
  export * from './scheduler/index.js';
9
9
  export * from './scheduler/runtime.js';
10
+ export * from './scheduler/human-arbitration.js';
10
11
  export * from './lifecycle/index.js';
11
12
  export * from './context/index.js';
12
13
  export * from './models/index.js';
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ export * from './transitions/index.js';
7
7
  export * from './dependencies/index.js';
8
8
  export * from './scheduler/index.js';
9
9
  export * from './scheduler/runtime.js';
10
+ export * from './scheduler/human-arbitration.js';
10
11
  export * from './lifecycle/index.js';
11
12
  export * from './context/index.js';
12
13
  export * from './models/index.js';
@@ -0,0 +1,59 @@
1
+ import type { EffectRecord, HumanInputRecord, JsonValue, LaneRecord } from '../core/types.js';
2
+ export type HumanArbitrationAction = 'respond' | 'steer' | 'spawn' | 'defer' | 'cancel';
3
+ export interface HumanArbitrationCandidate {
4
+ laneId: string;
5
+ agentId: string;
6
+ status: LaneRecord['status'];
7
+ priority: number;
8
+ goal?: string;
9
+ activeWaitId?: string;
10
+ }
11
+ export interface HumanArbitrationEffectCandidate {
12
+ effectId: string;
13
+ agentId: string;
14
+ laneId: string;
15
+ kind: EffectRecord['kind'];
16
+ state: EffectRecord['state'];
17
+ sideEffectPolicy?: EffectRecord['sideEffectPolicy'];
18
+ sideEffectState: EffectRecord['sideEffectState'];
19
+ }
20
+ export interface HumanArbitrationRequest {
21
+ schemaVersion: 1;
22
+ decisionId: string;
23
+ agentId: string;
24
+ input: HumanInputRecord;
25
+ lanes: readonly HumanArbitrationCandidate[];
26
+ effects: readonly HumanArbitrationEffectCandidate[];
27
+ availableLLMSlots: number;
28
+ }
29
+ export interface HumanArbitrationDecision {
30
+ schemaVersion?: 1;
31
+ decisionId: string;
32
+ inputId: string;
33
+ agentId: string;
34
+ action: HumanArbitrationAction;
35
+ targetLaneId?: string;
36
+ targetEffectId?: string;
37
+ reason?: string;
38
+ modelId: string;
39
+ }
40
+ export interface HumanArbitrationModel {
41
+ readonly id: string;
42
+ decide(request: HumanArbitrationRequest, signal: AbortSignal): Promise<HumanArbitrationDecision>;
43
+ }
44
+ export interface HumanArbitrationConfig {
45
+ model?: HumanArbitrationModel;
46
+ timeoutMs?: number;
47
+ }
48
+ export declare class HumanArbitrationCoordinator {
49
+ private readonly model;
50
+ private readonly timeoutMs;
51
+ private outstanding;
52
+ private readonly controllers;
53
+ constructor(model: HumanArbitrationModel, timeoutMs: number);
54
+ get pending(): number;
55
+ request(request: HumanArbitrationRequest, onDecision: (decision: HumanArbitrationDecision) => void, onFailure: () => void): boolean;
56
+ cancel(): void;
57
+ }
58
+ /** Deterministic, side-effect-safe control grammar used before model arbitration. */
59
+ export declare function ruleHumanArbitration(value: JsonValue, agentId: string, inputId: string, modelId?: string): HumanArbitrationDecision | undefined;
@@ -0,0 +1,54 @@
1
+ export class HumanArbitrationCoordinator {
2
+ model;
3
+ timeoutMs;
4
+ outstanding = 0;
5
+ controllers = new Set();
6
+ constructor(model, timeoutMs) {
7
+ this.model = model;
8
+ this.timeoutMs = timeoutMs;
9
+ }
10
+ get pending() { return this.outstanding; }
11
+ request(request, onDecision, onFailure) {
12
+ if (this.outstanding > 0)
13
+ return false;
14
+ const controller = new AbortController();
15
+ this.controllers.add(controller);
16
+ this.outstanding++;
17
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
18
+ void Promise.resolve().then(() => this.model.decide(structuredClone(request), controller.signal)).then((decision) => {
19
+ if (decision && typeof decision === 'object')
20
+ onDecision(structuredClone(decision));
21
+ else
22
+ onFailure();
23
+ }, () => onFailure()).finally(() => {
24
+ clearTimeout(timer);
25
+ this.controllers.delete(controller);
26
+ this.outstanding--;
27
+ });
28
+ return true;
29
+ }
30
+ cancel() { for (const controller of this.controllers)
31
+ controller.abort(); this.controllers.clear(); }
32
+ }
33
+ /** Deterministic, side-effect-safe control grammar used before model arbitration. */
34
+ export function ruleHumanArbitration(value, agentId, inputId, modelId = 'rules') {
35
+ const object = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
36
+ const text = typeof value === 'string' ? value.trim() : typeof object?.text === 'string' ? object.text.trim() : undefined;
37
+ const command = typeof object?.command === 'string' ? object.command : text?.startsWith('/') ? text.slice(1).split(/\s+/, 1)[0] : undefined;
38
+ if (!command)
39
+ return undefined;
40
+ const normalized = command.toLowerCase();
41
+ const targetLaneId = typeof object?.laneId === 'string' ? object.laneId : undefined;
42
+ const targetEffectId = typeof object?.effectId === 'string' ? object.effectId : undefined;
43
+ if (normalized === 'cancel' || normalized === 'stop' || normalized === 'abort')
44
+ return { schemaVersion: 1, decisionId: `rule:${inputId}`, inputId, agentId, action: 'cancel', ...(targetLaneId === undefined ? {} : { targetLaneId }), ...(targetEffectId === undefined ? {} : { targetEffectId }), reason: 'Human requested cancellation.', modelId };
45
+ if (normalized === 'steer' || normalized === 'redirect')
46
+ return { schemaVersion: 1, decisionId: `rule:${inputId}`, inputId, agentId, action: 'steer', ...(targetLaneId === undefined ? {} : { targetLaneId }), reason: 'Human requested steering.', modelId };
47
+ if (normalized === 'spawn' || normalized === 'parallel')
48
+ return { schemaVersion: 1, decisionId: `rule:${inputId}`, inputId, agentId, action: 'spawn', reason: 'Human requested a concurrent interaction.', modelId };
49
+ if (normalized === 'defer' || normalized === 'later')
50
+ return { schemaVersion: 1, decisionId: `rule:${inputId}`, inputId, agentId, action: 'defer', reason: 'Human requested deferral.', modelId };
51
+ if (normalized === 'respond' || normalized === 'reply')
52
+ return { schemaVersion: 1, decisionId: `rule:${inputId}`, inputId, agentId, action: 'respond', ...(targetEffectId === undefined ? {} : { targetEffectId }), modelId };
53
+ return undefined;
54
+ }
@@ -1,6 +1,6 @@
1
1
  import { MutationLog } from '../storage/mutation-log.js';
2
2
  import { PriorityInheritance, ReadyQueue, type RuntimeClock } from './index.js';
3
- import type { EffectRecord, EffectSubmission, EffectState, JsonValue, LaneRecord, LaneStepOutput, Outcome, ResumeInput, RuntimeState, RuntimeError, ForkAffinityMode, PrivacyLabel, PrivacyTaint, ProvenanceRef, ResumePoint, ResultRecord } from '../core/types.js';
3
+ import type { EffectRecord, EffectSubmission, EffectState, JsonValue, LaneRecord, LaneStepOutput, Outcome, ResumeInput, RuntimeState, RuntimeError, ForkAffinityMode, PrivacyLabel, PrivacyTaint, ProvenanceRef, ResumePoint, ResultRecord, HumanInputRecord } from '../core/types.js';
4
4
  import { QuarantineScope } from '../lifecycle/scopes.js';
5
5
  import { PulseSession } from '../dsl/session.js';
6
6
  import type { ProgramRef } from '../dsl/templates.js';
@@ -18,10 +18,12 @@ import { type ArtifactPublication } from '../storage/artifacts.js';
18
18
  import { type FindingPublication } from '../storage/findings.js';
19
19
  import { RuntimeToolRegistry } from '../tools/registry.js';
20
20
  import { type SchedulerDecisionConfig } from './decision.js';
21
+ import { type HumanArbitrationConfig } from './human-arbitration.js';
21
22
  export interface LaneStepContext {
22
23
  lane: Readonly<LaneRecord>;
23
24
  state: Readonly<RuntimeState>;
24
25
  resumeInput?: ResumeInput;
26
+ humanInputs?: readonly HumanInputRecord[];
25
27
  now: number;
26
28
  observe?: (event: {
27
29
  type: 'progress' | 'chunk' | 'trace' | 'warning' | 'diagnostic';
@@ -83,6 +85,12 @@ export type HostCommand = {
83
85
  agentId: string;
84
86
  effectId: string;
85
87
  value: JsonValue;
88
+ } | {
89
+ type: 'human_input';
90
+ agentId: string;
91
+ inputId: string;
92
+ value: JsonValue;
93
+ targetEffectId?: string;
86
94
  } | {
87
95
  type: 'cancel';
88
96
  agentId: string;
@@ -132,7 +140,12 @@ interface SchedulerDecisionFact {
132
140
  orderedLaneIds: string[];
133
141
  modelId: string;
134
142
  }
135
- type RuntimeFact = HostCommand | EffectCompletionFact | LLMPreparationFact | EffectReconcileFact | SchedulerDecisionFact;
143
+ interface HumanArbitrationFact {
144
+ [key: string]: JsonValue;
145
+ type: 'human_arbitration';
146
+ decision: JsonValue;
147
+ }
148
+ type RuntimeFact = HostCommand | EffectCompletionFact | LLMPreparationFact | EffectReconcileFact | SchedulerDecisionFact | HumanArbitrationFact;
136
149
  export interface RuntimeConfig {
137
150
  maxLaneStepsPerTick?: number;
138
151
  maxTickMs?: number;
@@ -187,6 +200,8 @@ export interface RuntimeConfig {
187
200
  persistenceExpectedDigest?: string;
188
201
  budget?: RuntimeBudgetConfig;
189
202
  schedulerDecision?: SchedulerDecisionConfig;
203
+ /** Optional model used for ordinary Human messages after deterministic control rules. */
204
+ humanArbitration?: HumanArbitrationConfig;
190
205
  }
191
206
  export interface RuntimeBudgetConfig {
192
207
  maxTotalAttempts?: number;
@@ -305,6 +320,7 @@ export declare class PulseRuntime {
305
320
  private factInboxDedupeArchive;
306
321
  private readonly customExecutor;
307
322
  private readonly builtinHumanEffects;
323
+ private humanInputProgram;
308
324
  private enqueueSeq;
309
325
  private readonly maxSteps;
310
326
  private readonly maxTickMs;
@@ -317,6 +333,10 @@ export declare class PulseRuntime {
317
333
  private readonly maxPreparedLLMs;
318
334
  private readonly effectSubmissionPreparer;
319
335
  private readonly schedulerDecisionCoordinator;
336
+ private readonly humanArbitrationCoordinator;
337
+ private readonly humanArbitrationModelId;
338
+ private humanArbitrationRequestSeq;
339
+ private readonly humanArbitrationRequests;
320
340
  private readonly schedulerDecisionConfig;
321
341
  private readonly schedulerDecisionModelId;
322
342
  private readonly schedulerDecisionMaxReorderDistance;
@@ -332,15 +352,32 @@ export declare class PulseRuntime {
332
352
  private hostCommandSeq;
333
353
  private factWaiters;
334
354
  private wakeScheduled;
355
+ /**
356
+ * A lock grant can happen synchronously while an effect completion is being
357
+ * applied inside tick(). scheduleWake() intentionally avoids re-entering the
358
+ * drain in that case, so remember forced wake requests and service them once
359
+ * the current drain has finished.
360
+ */
361
+ private wakeAfterDrain;
335
362
  private inDrain;
336
363
  private wakeError;
337
364
  private tickBudget;
338
365
  static restore(backend: RuntimePersistenceBackend, config?: Omit<RuntimeConfig, 'persistence'>): Promise<PulseRuntime>;
339
366
  constructor(config?: RuntimeConfig);
340
367
  register(program: LaneProgram): void;
368
+ /** Register the program used for urgent, concurrent human interactions. */
369
+ setHumanInputProgram(program: LaneProgram): void;
341
370
  createAgent(request: AgentCreateRequest): AgentHandle;
342
371
  createAgent(goal: string, program: LaneProgram, agentId?: string): AgentHandle;
343
372
  start(agentId: string): PulseSession;
373
+ /** Accept external human input without waiting for the current Effect to settle. */
374
+ submitHumanInput(agentId: string, inputId: string, value: JsonValue, targetEffectId?: string): void;
375
+ humanInputsFor(agentId: string): HumanInputRecord[];
376
+ private humanArbitrationRequest;
377
+ private ownsAgentForRuntime;
378
+ private commitHumanInputDecision;
379
+ private applyHumanArbitrationDecision;
380
+ private requestHumanArbitration;
344
381
  requestCancel(agentId: string, reason?: string): void;
345
382
  setLanePriority(laneId: string, priority: number): void;
346
383
  effectHandle(effectId: string): EffectHandle;
@@ -24,6 +24,7 @@ import { prepareFindingPublication } from '../storage/findings.js';
24
24
  import { runtimeErrorFromCause } from '../core/errors.js';
25
25
  import { RuntimeToolRegistry } from '../tools/registry.js';
26
26
  import { SchedulerDecisionCoordinator, schedulerDecisionCandidateFromLane } from './decision.js';
27
+ import { HumanArbitrationCoordinator, ruleHumanArbitration } from './human-arbitration.js';
27
28
  function encodeEffectExecution(execution) {
28
29
  const encoded = structuredClone(execution);
29
30
  const artifact = execution.artifact;
@@ -111,6 +112,19 @@ function validateHostCommand(command) {
111
112
  }
112
113
  return;
113
114
  }
115
+ if (value.type === 'human_input') {
116
+ if (typeof value.agentId !== 'string' || value.agentId.length === 0 || typeof value.inputId !== 'string' || value.inputId.length === 0)
117
+ throw new Error('INVALID_HOST_COMMAND');
118
+ if (value.targetEffectId !== undefined && (typeof value.targetEffectId !== 'string' || value.targetEffectId.length === 0))
119
+ throw new Error('INVALID_HOST_COMMAND');
120
+ try {
121
+ strictJsonValue(value.value);
122
+ }
123
+ catch {
124
+ throw new Error('INVALID_HOST_COMMAND_VALUE');
125
+ }
126
+ return;
127
+ }
114
128
  if (value.type === 'cancel' || value.type === 'cancel_effect') {
115
129
  if (typeof value.agentId !== 'string' || value.agentId.length === 0 || typeof value.reason !== 'string' || value.reason.length === 0)
116
130
  throw new Error('INVALID_HOST_COMMAND');
@@ -125,6 +139,31 @@ function validateHostCommand(command) {
125
139
  }
126
140
  throw new Error('INVALID_HOST_COMMAND');
127
141
  }
142
+ function parseHumanArbitrationDecision(value) {
143
+ if (!value || typeof value !== 'object' || Array.isArray(value))
144
+ return undefined;
145
+ const candidate = value;
146
+ const actions = new Set(['respond', 'steer', 'spawn', 'defer', 'cancel']);
147
+ if (typeof candidate.decisionId !== 'string' || typeof candidate.inputId !== 'string' || typeof candidate.agentId !== 'string' || typeof candidate.modelId !== 'string' || typeof candidate.action !== 'string' || !actions.has(candidate.action))
148
+ return undefined;
149
+ if (candidate.targetLaneId !== undefined && typeof candidate.targetLaneId !== 'string')
150
+ return undefined;
151
+ if (candidate.targetEffectId !== undefined && typeof candidate.targetEffectId !== 'string')
152
+ return undefined;
153
+ if (candidate.reason !== undefined && typeof candidate.reason !== 'string')
154
+ return undefined;
155
+ return {
156
+ schemaVersion: 1,
157
+ decisionId: candidate.decisionId,
158
+ inputId: candidate.inputId,
159
+ agentId: candidate.agentId,
160
+ action: candidate.action,
161
+ ...(candidate.targetLaneId === undefined ? {} : { targetLaneId: candidate.targetLaneId }),
162
+ ...(candidate.targetEffectId === undefined ? {} : { targetEffectId: candidate.targetEffectId }),
163
+ ...(candidate.reason === undefined ? {} : { reason: candidate.reason }),
164
+ modelId: candidate.modelId,
165
+ };
166
+ }
128
167
  function artifactOutput(value) {
129
168
  if (value instanceof Uint8Array)
130
169
  return { mediaType: 'application/octet-stream', content: new Uint8Array(value) };
@@ -456,6 +495,7 @@ export class PulseRuntime {
456
495
  factInboxDedupeArchive;
457
496
  customExecutor;
458
497
  builtinHumanEffects;
498
+ humanInputProgram;
459
499
  enqueueSeq = 1;
460
500
  maxSteps;
461
501
  maxTickMs;
@@ -468,6 +508,10 @@ export class PulseRuntime {
468
508
  maxPreparedLLMs;
469
509
  effectSubmissionPreparer;
470
510
  schedulerDecisionCoordinator;
511
+ humanArbitrationCoordinator;
512
+ humanArbitrationModelId;
513
+ humanArbitrationRequestSeq = 1;
514
+ humanArbitrationRequests = new Map();
471
515
  schedulerDecisionConfig;
472
516
  schedulerDecisionModelId;
473
517
  schedulerDecisionMaxReorderDistance;
@@ -483,6 +527,13 @@ export class PulseRuntime {
483
527
  hostCommandSeq = 1;
484
528
  factWaiters = [];
485
529
  wakeScheduled = false;
530
+ /**
531
+ * A lock grant can happen synchronously while an effect completion is being
532
+ * applied inside tick(). scheduleWake() intentionally avoids re-entering the
533
+ * drain in that case, so remember forced wake requests and service them once
534
+ * the current drain has finished.
535
+ */
536
+ wakeAfterDrain = false;
486
537
  inDrain = false;
487
538
  wakeError;
488
539
  tickBudget;
@@ -616,6 +667,8 @@ export class PulseRuntime {
616
667
  timeoutMs: this.schedulerDecisionConfig.decisionTimeoutMs,
617
668
  maxOutstanding: this.schedulerDecisionConfig.maxOutstandingDecisions,
618
669
  });
670
+ this.humanArbitrationModelId = config.humanArbitration?.model?.id;
671
+ this.humanArbitrationCoordinator = config.humanArbitration?.model === undefined ? undefined : new HumanArbitrationCoordinator(config.humanArbitration.model, config.humanArbitration.timeoutMs ?? 250);
619
672
  this.telemetryExporter = config.telemetryExporter;
620
673
  this.auditLogSink = config.auditLogSink;
621
674
  this.auditLogPrivacy = config.auditLogPrivacy;
@@ -639,6 +692,11 @@ export class PulseRuntime {
639
692
  this.syncStoragePolicy();
640
693
  }
641
694
  register(program) { this.programs.register(program); }
695
+ /** Register the program used for urgent, concurrent human interactions. */
696
+ setHumanInputProgram(program) {
697
+ this.register(program);
698
+ this.humanInputProgram = program;
699
+ }
642
700
  createAgent(goalOrRequest, program, agentId) {
643
701
  if (this.shuttingDown)
644
702
  throw new Error('RUNTIME_SHUTTING_DOWN');
@@ -750,6 +808,187 @@ export class PulseRuntime {
750
808
  }
751
809
  start(agentId) { if (!this.state.agents.has(agentId))
752
810
  throw new Error(`UNKNOWN_AGENT:${agentId}`); return new PulseSession(this, agentId); }
811
+ /** Accept external human input without waiting for the current Effect to settle. */
812
+ submitHumanInput(agentId, inputId, value, targetEffectId) {
813
+ if (!agentId)
814
+ throw new Error('INVALID_AGENT_ID');
815
+ if (!inputId)
816
+ throw new Error('INVALID_HUMAN_INPUT_ID');
817
+ strictJsonValue(value);
818
+ this.enqueueHostCommand({ type: 'human_input', agentId, inputId, value, ...(targetEffectId === undefined ? {} : { targetEffectId }) });
819
+ }
820
+ humanInputsFor(agentId) {
821
+ return [...this.state.humanInputs.values()].filter((input) => input.agentId === agentId).map((input) => structuredClone(input));
822
+ }
823
+ humanArbitrationRequest(agent, input) {
824
+ const lanes = [...this.state.lanes.values()].filter((lane) => lane.agentId === agent.id || this.ownsAgentForRuntime(lane.agentId, agent.id)).map((lane) => ({ laneId: lane.id, agentId: lane.agentId, status: lane.status, priority: lane.priority, ...(lane.goal ? { goal: lane.goal } : {}), ...(lane.activeWaitId === undefined ? {} : { activeWaitId: lane.activeWaitId }) }));
825
+ const effects = [...this.state.effects.values()].filter((effect) => effect.agentId === agent.id || this.ownsAgentForRuntime(effect.agentId, agent.id)).map((effect) => ({ effectId: effect.id, agentId: effect.agentId, laneId: effect.ownerLaneId, kind: effect.kind, state: effect.state, ...(effect.sideEffectPolicy === undefined ? {} : { sideEffectPolicy: effect.sideEffectPolicy }), sideEffectState: effect.sideEffectState }));
826
+ const llmLimit = this.state.maxRunning.llm;
827
+ const availableLLMSlots = Number.isFinite(llmLimit) ? Math.max(0, llmLimit - this.runningCount('llm') - 1) : Number.POSITIVE_INFINITY;
828
+ return { schemaVersion: 1, decisionId: `human-decision-${this.sessionId}-${this.humanArbitrationRequestSeq}`, agentId: agent.id, input: structuredClone(input), lanes, effects, availableLLMSlots };
829
+ }
830
+ ownsAgentForRuntime(candidateAgentId, ancestorAgentId) {
831
+ let current = this.state.agents.get(candidateAgentId);
832
+ const seen = new Set();
833
+ while (current && !seen.has(current.id)) {
834
+ if (current.id === ancestorAgentId)
835
+ return true;
836
+ seen.add(current.id);
837
+ current = current.parentAgentId === undefined ? undefined : this.state.agents.get(current.parentAgentId);
838
+ }
839
+ return false;
840
+ }
841
+ commitHumanInputDecision(record, decision, eventType = 'human.input.decided') {
842
+ const next = structuredClone(record);
843
+ next.decision = decision.action;
844
+ if (decision.reason === undefined)
845
+ delete next.decisionReason;
846
+ else
847
+ next.decisionReason = decision.reason;
848
+ next.decisionModelId = decision.modelId;
849
+ next.decidedAt = this.state.now;
850
+ const event = { type: eventType, agentId: record.agentId, data: { inputId: record.id, action: decision.action, modelId: decision.modelId, ...(decision.reason === undefined ? {} : { reason: decision.reason }), ...(decision.targetLaneId === undefined ? {} : { targetLaneId: decision.targetLaneId }), ...(decision.targetEffectId === undefined ? {} : { targetEffectId: decision.targetEffectId }) } };
851
+ const mutations = [{ op: 'setHumanInput', inputId: record.id, record: next }, { op: 'appendEvent', event }];
852
+ this.assertStorageAdmission(mutations);
853
+ commitMutationTransaction(this.state, this.mutationLog, `human-input:${record.id}:decision:${decision.action}`, mutations, this.state.now, this.sessionId);
854
+ Object.assign(record, next);
855
+ this.state.humanInputs.set(record.id, record);
856
+ }
857
+ applyHumanArbitrationDecision(decision) {
858
+ const record = this.state.humanInputs.get(decision.inputId);
859
+ if (!record || record.agentId !== decision.agentId || record.status !== 'pending')
860
+ return false;
861
+ const targetEffectId = decision.targetEffectId ?? record.targetEffectId;
862
+ if (decision.action === 'respond') {
863
+ if (targetEffectId !== undefined) {
864
+ const effect = this.state.effects.get(targetEffectId);
865
+ if (!effect || effect.agentId !== record.agentId || effect.kind !== 'human' || effect.outcome)
866
+ return false;
867
+ this.commitHumanInputDecision(record, decision);
868
+ record.status = 'consumed';
869
+ const consumed = structuredClone(record);
870
+ const mutations = [{ op: 'setHumanInput', inputId: record.id, record: consumed }];
871
+ this.assertStorageAdmission(mutations);
872
+ commitMutationTransaction(this.state, this.mutationLog, `human-input:${record.id}:respond`, mutations, this.state.now, this.sessionId);
873
+ Object.assign(record, consumed);
874
+ this.completeEffect(effect.id, { value: record.value }, 'succeeded');
875
+ return true;
876
+ }
877
+ const next = structuredClone(record);
878
+ next.status = 'consumed';
879
+ this.commitHumanInputDecision(record, decision, 'human.input.responded');
880
+ const mutations = [{ op: 'setHumanInput', inputId: record.id, record: next }];
881
+ this.assertStorageAdmission(mutations);
882
+ commitMutationTransaction(this.state, this.mutationLog, `human-input:${record.id}:responded`, mutations, this.state.now, this.sessionId);
883
+ Object.assign(record, next);
884
+ this.state.humanInputs.set(record.id, record);
885
+ return true;
886
+ }
887
+ if (decision.action === 'steer') {
888
+ const lane = this.state.lanes.get(decision.targetLaneId ?? this.state.agents.get(record.agentId)?.rootLaneId ?? '');
889
+ if (!lane || lane.agentId !== record.agentId || ['succeeded', 'failed', 'cancelled'].includes(lane.status)) {
890
+ const deferred = structuredClone(record);
891
+ deferred.status = 'deferred';
892
+ this.commitHumanInputDecision(record, { ...decision, action: 'defer', reason: decision.reason ?? 'Target lane is no longer active.' }, 'human.input.deferred');
893
+ Object.assign(record, deferred);
894
+ return true;
895
+ }
896
+ const nextLane = structuredClone(lane);
897
+ nextLane.pendingHumanInputs = [...(nextLane.pendingHumanInputs ?? []), structuredClone(record)];
898
+ nextLane.priority = Math.max(nextLane.priority, 2);
899
+ nextLane.version++;
900
+ const next = structuredClone(record);
901
+ next.status = 'consumed';
902
+ next.handledByLaneId = lane.id;
903
+ this.commitHumanInputDecision(record, decision);
904
+ const mutations = [{ op: 'setHumanInput', inputId: record.id, record: next }, { op: 'setLane', laneId: lane.id, record: nextLane }, { op: 'appendEvent', event: { type: 'human.input.steered', agentId: record.agentId, laneId: lane.id, data: { inputId: record.id, priority: 'human' } } }];
905
+ this.assertStorageAdmission(mutations);
906
+ commitMutationTransaction(this.state, this.mutationLog, `human-input:${record.id}:steer`, mutations, this.state.now, this.sessionId);
907
+ Object.assign(record, next);
908
+ this.state.humanInputs.set(record.id, record);
909
+ Object.assign(lane, nextLane);
910
+ this.state.lanes.set(lane.id, lane);
911
+ if (lane.status === 'ready')
912
+ this.enqueueReadyItem(readyItemFromLane(lane));
913
+ return true;
914
+ }
915
+ if (decision.action === 'spawn') {
916
+ if (this.humanInputProgram === undefined) {
917
+ const deferred = { ...decision, action: 'defer', reason: decision.reason ?? 'No Human interaction program is registered.' };
918
+ return this.applyHumanArbitrationDecision(deferred);
919
+ }
920
+ try {
921
+ const child = this.createAgent({ goal: `Human input: ${typeof record.value === 'string' ? record.value : JSON.stringify(record.value)}`, program: this.humanInputProgram, priority: 'urgent', parentAgentId: record.agentId, warmStart: { agentId: record.agentId, globalVersion: 'latest', include: 'facts_and_findings' } });
922
+ const next = structuredClone(record);
923
+ next.status = 'consumed';
924
+ next.handledByLaneId = child.laneId;
925
+ this.commitHumanInputDecision(record, decision);
926
+ const mutations = [{ op: 'setHumanInput', inputId: record.id, record: next }, { op: 'appendEvent', event: { type: 'human.input.dispatched', agentId: record.agentId, laneId: child.laneId, data: { inputId: record.id, decision: 'spawn', childAgentId: child.agentId } } }];
927
+ this.assertStorageAdmission(mutations);
928
+ commitMutationTransaction(this.state, this.mutationLog, `human-input:${record.id}:spawn`, mutations, this.state.now, this.sessionId);
929
+ Object.assign(record, next);
930
+ this.state.humanInputs.set(record.id, record);
931
+ }
932
+ catch (cause) {
933
+ return this.applyHumanArbitrationDecision({ ...decision, action: 'defer', reason: cause instanceof Error ? cause.message : String(cause) });
934
+ }
935
+ return true;
936
+ }
937
+ if (decision.action === 'cancel') {
938
+ const targetEffect = targetEffectId === undefined ? undefined : this.state.effects.get(targetEffectId);
939
+ if (targetEffect && (targetEffect.agentId !== record.agentId || targetEffect.outcome))
940
+ return false;
941
+ this.commitHumanInputDecision(record, decision);
942
+ const next = structuredClone(record);
943
+ next.status = 'consumed';
944
+ const mutations = [{ op: 'setHumanInput', inputId: record.id, record: next }];
945
+ this.assertStorageAdmission(mutations);
946
+ commitMutationTransaction(this.state, this.mutationLog, `human-input:${record.id}:cancel`, mutations, this.state.now, this.sessionId);
947
+ Object.assign(record, next);
948
+ this.state.humanInputs.set(record.id, record);
949
+ if (targetEffect)
950
+ this.cancelEffect(targetEffect.id, targetEffect.cancelGraceMs ?? 0, decision.reason ?? 'HUMAN_CANCELLED');
951
+ else
952
+ this.cancelAgent(record.agentId, decision.reason ?? 'HUMAN_CANCELLED');
953
+ return true;
954
+ }
955
+ const deferred = structuredClone(record);
956
+ deferred.status = 'deferred';
957
+ this.commitHumanInputDecision(record, decision, 'human.input.deferred');
958
+ const mutations = [{ op: 'setHumanInput', inputId: record.id, record: deferred }];
959
+ this.assertStorageAdmission(mutations);
960
+ commitMutationTransaction(this.state, this.mutationLog, `human-input:${record.id}:defer`, mutations, this.state.now, this.sessionId);
961
+ Object.assign(record, deferred);
962
+ this.state.humanInputs.set(record.id, record);
963
+ return true;
964
+ }
965
+ requestHumanArbitration(agent, input) {
966
+ const coordinator = this.humanArbitrationCoordinator;
967
+ if (!coordinator)
968
+ return false;
969
+ const request = this.humanArbitrationRequest(agent, input);
970
+ const requestId = request.decisionId;
971
+ this.humanArbitrationRequestSeq++;
972
+ this.humanArbitrationRequests.set(requestId, { agentId: agent.id, inputId: input.id });
973
+ const accepted = coordinator.request(request, (decision) => {
974
+ this.humanArbitrationRequests.delete(requestId);
975
+ if (decision.decisionId !== requestId || decision.inputId !== input.id || decision.agentId !== agent.id || decision.modelId !== this.humanArbitrationModelId)
976
+ return;
977
+ try {
978
+ this.enqueueFact({ type: 'human_arbitration', decision: strictJsonValue(decision) }, `human-arbitration:${requestId}`, true);
979
+ }
980
+ catch { /* malformed model output is handled as a deferred input */ }
981
+ }, () => {
982
+ this.humanArbitrationRequests.delete(requestId);
983
+ try {
984
+ this.enqueueFact({ type: 'human_arbitration', decision: strictJsonValue({ schemaVersion: 1, decisionId: requestId, inputId: input.id, agentId: agent.id, action: 'defer', reason: 'Human arbitration timed out.', modelId: this.humanArbitrationModelId ?? 'model' }) }, `human-arbitration:${requestId}:timeout`, true);
985
+ }
986
+ catch { /* runtime shutdown */ }
987
+ });
988
+ if (!accepted)
989
+ this.humanArbitrationRequests.delete(requestId);
990
+ return accepted;
991
+ }
753
992
  requestCancel(agentId, reason = 'USER_REQUESTED') {
754
993
  if (!agentId)
755
994
  throw new Error('INVALID_AGENT_ID');
@@ -1156,13 +1395,13 @@ export class PulseRuntime {
1156
1395
  }
1157
1396
  this.mutationLog.append(transactionId, mutations, this.state.now);
1158
1397
  }
1159
- enqueueFact(fact, eventId) {
1398
+ enqueueFact(fact, eventId, urgent = false) {
1160
1399
  const candidateInbox = FactInbox.fromSnapshot(this.factInbox.snapshot());
1161
- if (!candidateInbox.enqueue(fact, eventId))
1400
+ if (!(urgent ? candidateInbox.enqueueUrgent(fact, eventId) : candidateInbox.enqueue(fact, eventId)))
1162
1401
  return false;
1163
1402
  const candidatePolicy = this.storagePolicy.clone();
1164
1403
  this.syncStoragePolicy(candidatePolicy, this.state, candidateInbox);
1165
- const envelope = this.factInbox.enqueue(fact, eventId);
1404
+ const envelope = urgent ? this.factInbox.enqueueUrgent(fact, eventId) : this.factInbox.enqueue(fact, eventId);
1166
1405
  if (!envelope)
1167
1406
  return false;
1168
1407
  this.syncStoragePolicy();
@@ -1175,7 +1414,8 @@ export class PulseRuntime {
1175
1414
  enqueueHostCommand(command) {
1176
1415
  validateHostCommand(command);
1177
1416
  const eventId = `host-command-${this.hostCommandSeq}`;
1178
- if (!this.enqueueFact(command, eventId))
1417
+ const urgent = command.type === 'human_input' || command.type === 'reply' || command.type === 'cancel' || command.type === 'cancel_effect';
1418
+ if (!this.enqueueFact(command, eventId, urgent))
1179
1419
  return;
1180
1420
  this.hostCommandSeq++;
1181
1421
  }
@@ -1193,7 +1433,12 @@ export class PulseRuntime {
1193
1433
  this.enqueueFact(fact, `effect-reconcile:${effect.id}:${effect.attemptId}:${status}`);
1194
1434
  }
1195
1435
  scheduleWake(force = false) {
1196
- if (this.wakeScheduled || this.inDrain)
1436
+ if (this.inDrain) {
1437
+ if (force)
1438
+ this.wakeAfterDrain = true;
1439
+ return;
1440
+ }
1441
+ if (this.wakeScheduled)
1197
1442
  return;
1198
1443
  if (!force && this.factInbox.size === 0)
1199
1444
  return;
@@ -1211,7 +1456,9 @@ export class PulseRuntime {
1211
1456
  }
1212
1457
  finally {
1213
1458
  this.inDrain = false;
1214
- if (this.wakeError === undefined && (this.factInbox.size > 0 || this.hasPendingTickCleanup()))
1459
+ const wakeAfterDrain = this.wakeAfterDrain;
1460
+ this.wakeAfterDrain = false;
1461
+ if (this.wakeError === undefined && (wakeAfterDrain || this.factInbox.size > 0 || this.hasPendingTickCleanup()))
1215
1462
  this.scheduleWake(true);
1216
1463
  }
1217
1464
  });
@@ -1409,6 +1656,17 @@ export class PulseRuntime {
1409
1656
  else if (envelope.fact.type === 'scheduler_decision') {
1410
1657
  commandApplied = this.applySchedulerDecision(envelope.fact);
1411
1658
  }
1659
+ else if (envelope.fact.type === 'human_arbitration') {
1660
+ const decision = parseHumanArbitrationDecision(envelope.fact.decision);
1661
+ if (decision !== undefined)
1662
+ commandApplied = this.applyHumanArbitrationDecision(decision);
1663
+ else {
1664
+ const raw = envelope.fact.decision && typeof envelope.fact.decision === 'object' && !Array.isArray(envelope.fact.decision) ? envelope.fact.decision : {};
1665
+ const record = typeof raw.inputId === 'string' ? this.state.humanInputs.get(raw.inputId) : undefined;
1666
+ if (record && record.status === 'pending')
1667
+ commandApplied = this.applyHumanArbitrationDecision({ schemaVersion: 1, decisionId: typeof raw.decisionId === 'string' ? raw.decisionId : `invalid:${record.id}`, inputId: record.id, agentId: record.agentId, action: 'defer', reason: 'Human arbitration returned an invalid decision.', modelId: 'runtime-safety' });
1668
+ }
1669
+ }
1412
1670
  else if (envelope.fact.type === 'effect_completion') {
1413
1671
  if (envelope.fact.dispatchError !== undefined)
1414
1672
  this.tryEmit({ type: 'effect.dispatch_failed', effectId: envelope.fact.effectId, data: envelope.fact.dispatchError });
@@ -1434,6 +1692,43 @@ export class PulseRuntime {
1434
1692
  commandApplied = true;
1435
1693
  }
1436
1694
  }
1695
+ else if (envelope.fact.type === 'human_input') {
1696
+ const agent = this.state.agents.get(envelope.fact.agentId);
1697
+ if (!agent) {
1698
+ this.rejectHostCommand(envelope.eventId, 'AGENT_NOT_FOUND');
1699
+ commandApplied = true;
1700
+ }
1701
+ else if (this.state.humanInputs.has(envelope.fact.inputId)) {
1702
+ this.tryEmit({ type: 'human.input.duplicate', agentId: agent.id, data: { inputId: envelope.fact.inputId } });
1703
+ this.emit({ type: 'command.applied', data: { eventId: envelope.eventId, duplicate: true } });
1704
+ commandApplied = true;
1705
+ }
1706
+ else {
1707
+ const target = envelope.fact.targetEffectId === undefined ? undefined : this.state.effects.get(envelope.fact.targetEffectId);
1708
+ if (target !== undefined && (target.agentId !== agent.id || target.kind !== 'human' || target.outcome !== undefined)) {
1709
+ this.rejectHostCommand(envelope.eventId, target.agentId !== agent.id ? 'EFFECT_NOT_OWNED' : 'EFFECT_NOT_REPLYABLE');
1710
+ commandApplied = true;
1711
+ }
1712
+ else {
1713
+ const record = { id: envelope.fact.inputId, agentId: agent.id, value: structuredClone(envelope.fact.value), receivedAt: this.state.now, status: 'pending', ...(target === undefined ? {} : { targetEffectId: target.id }) };
1714
+ const event = { type: 'human.input.received', agentId: agent.id, ...(target === undefined ? {} : { effectId: target.id }), data: { inputId: record.id, value: record.value, priority: 'human', ...(target === undefined ? {} : { targetEffectId: target.id }) } };
1715
+ const mutations = [{ op: 'setHumanInput', inputId: record.id, record }, { op: 'appendEvent', event }, { op: 'appendEvent', event: { type: 'command.applied', data: { eventId: envelope.eventId } } }];
1716
+ this.assertStorageAdmission(mutations);
1717
+ commitMutationTransaction(this.state, this.mutationLog, `human-input:${record.id}`, mutations, this.state.now, this.sessionId);
1718
+ commandApplied = true;
1719
+ if (target !== undefined) {
1720
+ this.applyHumanArbitrationDecision({ schemaVersion: 1, decisionId: `target:${record.id}`, inputId: record.id, agentId: agent.id, action: 'respond', targetEffectId: target.id, modelId: 'target-effect' });
1721
+ }
1722
+ else {
1723
+ const rule = ruleHumanArbitration(record.value, agent.id, record.id);
1724
+ if (rule !== undefined)
1725
+ this.applyHumanArbitrationDecision(rule);
1726
+ else if (!this.requestHumanArbitration(agent, record) && this.humanInputProgram !== undefined)
1727
+ this.applyHumanArbitrationDecision({ schemaVersion: 1, decisionId: `default:${record.id}`, inputId: record.id, agentId: agent.id, action: 'spawn', modelId: 'runtime-default' });
1728
+ }
1729
+ }
1730
+ }
1731
+ }
1437
1732
  else if (envelope.fact.type === 'reply') {
1438
1733
  const effect = this.state.effects.get(envelope.fact.effectId);
1439
1734
  if (effect?.agentId === envelope.fact.agentId && effect.kind === 'human' && !effect.outcome)
@@ -1458,7 +1753,7 @@ export class PulseRuntime {
1458
1753
  else
1459
1754
  commandApplied = this.cancelEffect(envelope.fact.effectId, 0, envelope.fact.reason, [{ op: 'appendEvent', event: { type: 'command.applied', data: { eventId: envelope.eventId } } }]);
1460
1755
  }
1461
- else {
1756
+ else if (envelope.fact.type === 'set_lane_priority') {
1462
1757
  const lane = this.state.lanes.get(envelope.fact.laneId);
1463
1758
  if (!lane) {
1464
1759
  this.rejectHostCommand(envelope.eventId, 'LANE_NOT_FOUND');
@@ -1484,6 +1779,9 @@ export class PulseRuntime {
1484
1779
  commandApplied = true;
1485
1780
  }
1486
1781
  }
1782
+ else {
1783
+ commandApplied = false;
1784
+ }
1487
1785
  if (!commandApplied && envelope.fact.type !== 'effect_completion' && envelope.fact.type !== 'scheduler_decision')
1488
1786
  this.emit({ type: 'command.applied', data: { eventId: envelope.eventId } });
1489
1787
  }
@@ -1528,7 +1826,7 @@ export class PulseRuntime {
1528
1826
  continue;
1529
1827
  }
1530
1828
  let output;
1531
- const stepContext = { lane: stepLane, state: structuredClone(this.state), ...(lane.pendingResumeInput ? { resumeInput: structuredClone(lane.pendingResumeInput) } : {}), now: this.state.now, observe: (event) => { this.observationInbox.enqueue({ ...event, agentId: lane.agentId, laneId: lane.id, timestamp: this.state.now }); } };
1829
+ const stepContext = { lane: stepLane, state: structuredClone(this.state), ...(lane.pendingResumeInput ? { resumeInput: structuredClone(lane.pendingResumeInput) } : {}), ...(lane.pendingHumanInputs?.length ? { humanInputs: structuredClone(lane.pendingHumanInputs) } : {}), now: this.state.now, observe: (event) => { this.observationInbox.enqueue({ ...event, agentId: lane.agentId, laneId: lane.id, timestamp: this.state.now }); } };
1532
1830
  try {
1533
1831
  output = withPureStepGuard(() => lane.series || program.seriesMember ? this.seriesStep(program, stepContext, lane.series) : program.step(stepContext));
1534
1832
  }
@@ -1602,6 +1900,7 @@ export class PulseRuntime {
1602
1900
  return mutation;
1603
1901
  const nextLane = structuredClone(mutation.record);
1604
1902
  delete nextLane.consecutiveControlErrors;
1903
+ delete nextLane.pendingHumanInputs;
1605
1904
  replaceResumeInput(nextLane, undefined);
1606
1905
  nextLane.progressWatchdog = watchdog.state;
1607
1906
  return { ...mutation, record: nextLane };
@@ -1791,6 +2090,8 @@ export class PulseRuntime {
1791
2090
  async shutdown(timeoutMs = 5_000) {
1792
2091
  this.shuttingDown = true;
1793
2092
  this.schedulerDecisionCoordinator?.cancel();
2093
+ this.humanArbitrationCoordinator?.cancel();
2094
+ this.humanArbitrationRequests.clear();
1794
2095
  this.schedulerDecisionRequests.clear();
1795
2096
  this.schedulerDecisionCache = undefined;
1796
2097
  for (const agent of this.state.agents.values())
@@ -2026,6 +2327,18 @@ export class PulseRuntime {
2026
2327
  hasPendingHostInteraction(agentId) { return [...this.state.effects.values()].some((effect) => effect.kind === 'human' && !effect.outcome && (agentId === undefined || effect.agentId === agentId)); }
2027
2328
  waitForFact() { return new Promise((resolve) => this.factWaiters.push(resolve)); }
2028
2329
  completeFinishedChildAgents() {
2330
+ // Human interaction Agents are detached from the main Lane rather than
2331
+ // represented by an agent Effect. Settle their Agent record when the root
2332
+ // Lane reaches a terminal state so session streams can close correctly.
2333
+ for (const child of this.state.agents.values()) {
2334
+ if (child.parentAgentId === undefined || ['succeeded', 'failed', 'cancelled'].includes(child.state ?? ''))
2335
+ continue;
2336
+ const root = this.state.lanes.get(child.rootLaneId);
2337
+ if (!root || !['succeeded', 'failed', 'cancelled'].includes(root.status))
2338
+ continue;
2339
+ const status = root.status === 'succeeded' ? 'succeeded' : root.status === 'cancelled' ? 'cancelled' : 'failed';
2340
+ this.commitAgentState(child.id, status, `agent:${child.id}:interaction-settled:${root.version}`);
2341
+ }
2029
2342
  for (const effect of this.state.effects.values()) {
2030
2343
  if (effect.kind !== 'agent' || !effect.childAgentId || effect.outcome)
2031
2344
  continue;
@@ -2620,7 +2933,8 @@ export class PulseRuntime {
2620
2933
  return { id: lane.id, agentId: lane.agentId, status: lane.status, cancelReason: lane.cancelReason ?? null, failure: lane.failure?.error ?? null, goal: lane.goal, basePriority: lane.priority, effectivePriority: ready?.effectivePriority ?? lane.priority, queueWaitMs: ready ? Math.max(0, this.state.now - lane.readySince) : 0, blockedBy, activeWaitId: lane.activeWaitId ?? null, lastEventSeq: lastEvent('lane', lane.id), watchdog: lane.progressWatchdog ?? null, consecutiveControlErrors: lane.consecutiveControlErrors ?? 0, lastInterventionReason: lane.progressWatchdog?.lastReason ?? null, unresolvedEffectIds: lane.unresolvedEffectIds ?? [] };
2621
2934
  });
2622
2935
  const effects = [...this.state.effects.values()].filter((effect) => laneId === undefined || effect.ownerLaneId === laneId).map((effect) => ({ id: effect.id, state: effect.state, executionState: effect.executionState, sideEffectState: effect.sideEffectState, attemptId: effect.attemptId, inheritedFloor: effect.inheritedFloor ?? null, deadlineAt: effect.deadlineAt ?? null, lastEventSeq: lastEvent('effect', effect.id), preparation: effect.preparation ?? null, metadata: latestEffectMetadata(effect.id) }));
2623
- return { now: this.state.now, lanes, effects, preparation: { preparing: this.preparingLLMs.size, prepared: [...this.state.effects.values()].filter((effect) => effect.state === 'queued' && effect.preparation?.state === 'prepared').length, maxPreparing: this.maxPreparingLLMs, maxPrepared: this.maxPreparedLLMs }, quarantine: this.quarantine.unresolvedEffectIds };
2936
+ const humanInputs = [...this.state.humanInputs.values()].filter((input) => laneId === undefined || input.agentId === this.state.lanes.get(laneId)?.agentId).map((input) => structuredClone(input));
2937
+ return { now: this.state.now, lanes, effects, humanInputs, preparation: { preparing: this.preparingLLMs.size, prepared: [...this.state.effects.values()].filter((effect) => effect.state === 'queued' && effect.preparation?.state === 'prepared').length, maxPreparing: this.maxPreparingLLMs, maxPrepared: this.maxPreparedLLMs }, quarantine: this.quarantine.unresolvedEffectIds };
2624
2938
  }
2625
2939
  retryEffect(effectId, delayMs) {
2626
2940
  const effect = this.state.effects.get(effectId);
@@ -2709,6 +3023,13 @@ export class PulseRuntime {
2709
3023
  // Higher effective priority (max of own priority and inherited floor) dispatches first, matching ReadyQueue semantics.
2710
3024
  const effectivePriority = (effect) => Math.max(effect.schedulePriority ?? 0, effect.inheritedFloor ?? Number.NEGATIVE_INFINITY);
2711
3025
  const queued = [...this.state.effects.values()].filter((effect) => effect.state === 'queued' && !this.executions.has(effect.id)).sort((a, b) => (effectivePriority(b) - effectivePriority(a)) || a.id.localeCompare(b.id));
3026
+ const llmLimit = () => {
3027
+ const configured = this.state.maxRunning.llm;
3028
+ if ((this.humanInputProgram === undefined && this.humanArbitrationCoordinator === undefined) || !Number.isFinite(configured))
3029
+ return configured;
3030
+ const hasUrgentHumanWork = queued.some((candidate) => candidate.kind === 'llm' && effectivePriority(candidate) >= 2);
3031
+ return hasUrgentHumanWork ? configured : Math.max(0, configured - 1);
3032
+ };
2712
3033
  for (const effect of queued) {
2713
3034
  if (this.tickBudget && !this.tickBudget.canStart())
2714
3035
  break;
@@ -2716,7 +3037,7 @@ export class PulseRuntime {
2716
3037
  continue;
2717
3038
  if (effect.kind === 'llm' && !this.prepareLLMEffect(effect))
2718
3039
  continue;
2719
- if (effect.concurrencyClass !== 'none' && this.runningCount(effect.concurrencyClass) >= this.state.maxRunning[effect.concurrencyClass])
3040
+ if (effect.concurrencyClass !== 'none' && this.runningCount(effect.concurrencyClass) >= (effect.concurrencyClass === 'llm' ? llmLimit() : this.state.maxRunning[effect.concurrencyClass]))
2720
3041
  continue;
2721
3042
  const budgetError = this.budgetRejection(effect);
2722
3043
  if (budgetError) {
@@ -1,4 +1,4 @@
1
- import type { AgentRecord, ArtifactRecord, EffectRecord, JsonValue, LaneRecord, MergeProposal, PrivacyLabel, PrivacyMetadata, ResultRecord, RuntimeEvent, RuntimeState, WaitRecord, ToolCallCorrelation } from '../core/types.js';
1
+ import type { AgentRecord, ArtifactRecord, EffectRecord, JsonValue, LaneRecord, MergeProposal, PrivacyLabel, PrivacyMetadata, ResultRecord, RuntimeEvent, RuntimeState, WaitRecord, ToolCallCorrelation, HumanInputRecord } from '../core/types.js';
2
2
  export interface SessionSnapshot {
3
3
  schemaVersion: 1;
4
4
  state: {
@@ -18,6 +18,7 @@ export interface SessionSnapshot {
18
18
  artifacts?: Array<[string, ArtifactRecord]>;
19
19
  toolCallCorrelations?: Array<[string, ToolCallCorrelation]>;
20
20
  mergeProposals: Array<[string, MergeProposal]>;
21
+ humanInputs?: Array<[string, HumanInputRecord]>;
21
22
  events: RuntimeEvent[];
22
23
  eventsCompactedThrough?: number;
23
24
  nextIds: RuntimeState['nextIds'];
@@ -304,6 +304,17 @@ function validateStateConfiguration(value) {
304
304
  throw new Error('INVALID_SESSION_SNAPSHOT');
305
305
  if (value.trustedSanitizerIds !== undefined && (!Array.isArray(value.trustedSanitizerIds) || new Set(value.trustedSanitizerIds).size !== value.trustedSanitizerIds.length || value.trustedSanitizerIds.some((id) => typeof id !== 'string' || id.length === 0)))
306
306
  throw new Error('INVALID_SESSION_SNAPSHOT');
307
+ if (value.humanInputs !== undefined) {
308
+ const ids = new Set();
309
+ for (const entry of value.humanInputs) {
310
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || entry[0].length === 0 || ids.has(entry[0]))
311
+ throw new Error('INVALID_SESSION_SNAPSHOT');
312
+ const input = entry[1];
313
+ if (!input || input.id !== entry[0] || typeof input.agentId !== 'string' || input.agentId.length === 0 || !['pending', 'consumed', 'deferred'].includes(input.status) || typeof input.receivedAt !== 'number' || !Number.isFinite(input.receivedAt))
314
+ throw new Error('INVALID_SESSION_SNAPSHOT');
315
+ ids.add(entry[0]);
316
+ }
317
+ }
307
318
  }
308
319
  export function exportRuntimeState(state) {
309
320
  return {
@@ -318,6 +329,7 @@ export function exportRuntimeState(state) {
318
329
  artifacts: [...state.artifacts.entries()].map(([ref, artifact]) => [ref, structuredClone(artifact)]),
319
330
  toolCallCorrelations: [...state.toolCallCorrelations.entries()].map(([id, correlation]) => [id, structuredClone(correlation)]),
320
331
  mergeProposals: [...state.mergeProposals.entries()].map(([id, proposal]) => [id, structuredClone(proposal)]),
332
+ humanInputs: [...state.humanInputs.entries()].map(([id, input]) => [id, structuredClone(input)]),
321
333
  events: state.events.map((event) => normalizeRuntimeEvent(event, event.seq, { sessionId: event.sessionId, timestamp: event.timestamp })),
322
334
  ...(state.eventsCompactedThrough === undefined ? {} : { eventsCompactedThrough: state.eventsCompactedThrough }),
323
335
  nextIds: { ...state.nextIds },
@@ -440,6 +452,8 @@ export function importRuntimeState(snapshot) {
440
452
  state.toolCallCorrelations.set(id, structuredClone(correlation));
441
453
  for (const [id, proposal] of value.state.mergeProposals ?? [])
442
454
  state.mergeProposals.set(id, structuredClone(proposal));
455
+ for (const [id, input] of value.state.humanInputs ?? [])
456
+ state.humanInputs.set(id, structuredClone(input));
443
457
  state.events = value.state.events.map((event) => normalizeRuntimeEvent(event, event.seq, { sessionId: event.sessionId, timestamp: event.timestamp }));
444
458
  if (value.state.eventsCompactedThrough !== undefined)
445
459
  state.eventsCompactedThrough = value.state.eventsCompactedThrough;
@@ -7,7 +7,7 @@ import { ContextBuilder, contentHash, estimateHistoryTokens, hasUnsafePathSegmen
7
7
  const isLocal = (value) => 'local' in value;
8
8
  const clone = (value) => structuredClone(value);
9
9
  const resultMetadata = (value) => ({ sizeBytes: Buffer.byteLength(stableSerialize(value), 'utf8'), contentHash: contentHash(value) });
10
- const laneCopy = (lane) => ({ ...lane, resume: clone(lane.resume), context: clone(lane.context), ...(lane.visibleResultRefs === undefined ? {} : { visibleResultRefs: new Set(lane.visibleResultRefs) }), children: new Set(lane.children), ownedEffectIds: new Set(lane.ownedEffectIds), ...(lane.pendingResumeInput === undefined ? {} : { pendingResumeInput: clone(lane.pendingResumeInput) }), ...(lane.pendingControlProposals === undefined ? {} : { pendingControlProposals: clone(lane.pendingControlProposals) }), ...(lane.pendingOutcome === undefined ? {} : { pendingOutcome: clone(lane.pendingOutcome) }) });
10
+ const laneCopy = (lane) => ({ ...lane, resume: clone(lane.resume), context: clone(lane.context), ...(lane.visibleResultRefs === undefined ? {} : { visibleResultRefs: new Set(lane.visibleResultRefs) }), children: new Set(lane.children), ownedEffectIds: new Set(lane.ownedEffectIds), ...(lane.pendingResumeInput === undefined ? {} : { pendingResumeInput: clone(lane.pendingResumeInput) }), ...(lane.pendingHumanInputs === undefined ? {} : { pendingHumanInputs: clone(lane.pendingHumanInputs) }), ...(lane.pendingControlProposals === undefined ? {} : { pendingControlProposals: clone(lane.pendingControlProposals) }), ...(lane.pendingOutcome === undefined ? {} : { pendingOutcome: clone(lane.pendingOutcome) }) });
11
11
  function isRuntimeJsonValue(value, seen = new Set()) {
12
12
  if (value === null || typeof value === 'string' || typeof value === 'boolean')
13
13
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunterzhu/pulse-runtime",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/zhuhengtan/Pulse"