@hunterzhu/pulse-runtime 0.1.4 → 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.
- package/dist/core/inbox.d.ts +6 -0
- package/dist/core/inbox.js +31 -7
- package/dist/core/mutations.d.ts +5 -1
- package/dist/core/mutations.js +5 -1
- package/dist/core/types.d.ts +20 -0
- package/dist/core/types.js +1 -1
- package/dist/dsl/session.d.ts +7 -0
- package/dist/dsl/session.js +28 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/scheduler/human-arbitration.d.ts +59 -0
- package/dist/scheduler/human-arbitration.js +54 -0
- package/dist/scheduler/runtime.d.ts +39 -2
- package/dist/scheduler/runtime.js +331 -10
- package/dist/storage/session.d.ts +2 -1
- package/dist/storage/session.js +14 -0
- package/dist/transitions/validate.js +1 -1
- package/package.json +1 -1
package/dist/core/inbox.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/inbox.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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;
|
package/dist/core/mutations.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/mutations.js
CHANGED
|
@@ -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;
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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>>;
|
package/dist/core/types.js
CHANGED
|
@@ -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'); }
|
package/dist/dsl/session.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/dsl/session.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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'];
|
package/dist/storage/session.js
CHANGED
|
@@ -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;
|