@rosetears/aili-pi 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/THIRD_PARTY_NOTICES.md +12 -12
- package/docs/persistent-agents.md +114 -0
- package/extensions/matrix/index.ts +24 -8
- package/extensions/zentui/config.ts +13 -13
- package/extensions/zentui/format.ts +15 -1
- package/extensions/zentui/gradient.ts +22 -13
- package/extensions/zentui/tool-execution.ts +7 -4
- package/manifests/adapter-evidence.json +53 -20
- package/manifests/capabilities.json +3 -3
- package/manifests/live-verification.json +18 -19
- package/manifests/provenance.json +12 -12
- package/manifests/roles.json +270 -57
- package/manifests/sbom.json +1 -128
- package/manifests/skill-compatibility.json +57 -61
- package/manifests/subagent-provenance.json +8 -15
- package/package.json +3 -3
- package/roles/agent-evaluator.md +8 -3
- package/roles/ai-regression-scout.md +8 -3
- package/roles/browser-qa-runner.md +8 -3
- package/roles/code-reviewer.md +8 -3
- package/roles/code-scout.md +8 -3
- package/roles/convergence-reviewer.md +8 -3
- package/roles/doc-researcher.md +8 -3
- package/roles/e2e-artifact-runner.md +8 -3
- package/roles/general.md +49 -0
- package/roles/implementer.md +8 -3
- package/roles/opensource-sanitizer.md +8 -3
- package/roles/plan-auditor.md +8 -3
- package/roles/pr-test-analyzer.md +8 -3
- package/roles/security-auditor.md +8 -3
- package/roles/silent-failure-reviewer.md +8 -3
- package/roles/spec-miner.md +8 -3
- package/roles/test-coverage-reviewer.md +8 -3
- package/roles/test-engineer.md +8 -3
- package/roles/web-performance-auditor.md +8 -3
- package/roles/web-researcher.md +8 -3
- package/scripts/sync-roles.ts +186 -24
- package/src/runtime/doctor.ts +38 -10
- package/src/runtime/global-resources.ts +5 -1
- package/src/runtime/index.ts +2 -2
- package/src/runtime/persistent-agents/hub.ts +429 -0
- package/src/runtime/persistent-agents/model-selection.ts +363 -0
- package/src/runtime/persistent-agents/output-delivery.ts +356 -0
- package/src/runtime/persistent-agents/permission.ts +223 -0
- package/src/runtime/persistent-agents/policy.ts +311 -0
- package/src/runtime/persistent-agents/production.ts +569 -0
- package/src/runtime/persistent-agents/runtime.ts +164 -0
- package/src/runtime/persistent-agents/sandbox.ts +68 -0
- package/src/runtime/persistent-agents/scheduler.ts +190 -0
- package/src/runtime/persistent-agents/session-factory.ts +184 -0
- package/src/runtime/persistent-agents/storage.ts +775 -0
- package/src/runtime/persistent-agents/task-coordinator.ts +460 -0
- package/src/runtime/persistent-agents/task-schema.ts +141 -0
- package/src/runtime/persistent-agents/types.ts +134 -0
- package/src/runtime/persistent-agents/workspace.ts +335 -0
- package/src/runtime/registry.ts +28 -32
- package/src/runtime/roles.ts +211 -18
- package/src/runtime/rose-context.ts +1 -1
- package/templates/APPEND_SYSTEM.md +1 -1
- package/themes/rose-cyberdeck.json +5 -9
- package/upstream/opencode-global-agents.lock.json +1 -1
- package/src/runtime/subagents.ts +0 -249
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { CoordinatorJournal } from "./storage.js";
|
|
4
|
+
import type { AgentRecord, CoordinatorState, TurnRecord } from "./types.js";
|
|
5
|
+
import { assertNoCredentialMaterial } from "./permission.js";
|
|
6
|
+
|
|
7
|
+
export const HUB_TOOL_SCHEMA = Type.Union([
|
|
8
|
+
Type.Object({ action: Type.Literal("list"), includeReleased: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
9
|
+
Type.Object({ action: Type.Literal("send"), agentId: Type.String({ minLength: 1 }), message: Type.String({ minLength: 1 }), messageId: Type.Optional(Type.String({ minLength: 1 })) }, { additionalProperties: false }),
|
|
10
|
+
Type.Object({ action: Type.Literal("wait"), jobIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))), messageIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))), timeoutMs: Type.Optional(Type.Number({ minimum: 0 })), pollIntervalMs: Type.Optional(Type.Number({ minimum: 1 })) }, { additionalProperties: false }),
|
|
11
|
+
Type.Object({ action: Type.Literal("inbox"), agentId: Type.String({ minLength: 1 }), mode: Type.Optional(Type.Union([Type.Literal("peek"), Type.Literal("drain")])) }, { additionalProperties: false }),
|
|
12
|
+
Type.Object({ action: Type.Literal("output"), agentId: Type.String({ minLength: 1 }), offset: Type.Optional(Type.Number({ minimum: 0 })), limit: Type.Optional(Type.Number({ minimum: 1 })) }, { additionalProperties: false }),
|
|
13
|
+
Type.Object({ action: Type.Literal("history"), agentId: Type.String({ minLength: 1 }), offset: Type.Optional(Type.Number({ minimum: 0 })), limit: Type.Optional(Type.Number({ minimum: 1 })) }, { additionalProperties: false }),
|
|
14
|
+
Type.Object({ action: Type.Literal("jobs"), jobId: Type.Optional(Type.String({ minLength: 1 })) }, { additionalProperties: false }),
|
|
15
|
+
Type.Object({ action: Type.Literal("cancel"), id: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
|
|
16
|
+
Type.Object({ action: Type.Literal("model"), operation: Type.Union([Type.Literal("query"), Type.Literal("request"), Type.Literal("clear")]), agentId: Type.Optional(Type.String({ minLength: 1 })), selector: Type.Optional(Type.String({ minLength: 1 })), model: Type.Optional(Type.String({ minLength: 1 })) }, { additionalProperties: false }),
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export interface HubCaller {
|
|
20
|
+
agentId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LiveAgentAdapter {
|
|
24
|
+
steer(message: string): void | Promise<void>;
|
|
25
|
+
sendUserMessage(message: string): void | Promise<void>;
|
|
26
|
+
abort?(reason: string): void | Promise<void>;
|
|
27
|
+
dispose(): void | Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class PermanentReviveError extends Error {
|
|
31
|
+
constructor(message: string) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "PermanentReviveError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface HubServiceOptions {
|
|
38
|
+
journal: CoordinatorJournal;
|
|
39
|
+
revive: (agent: AgentRecord) => Promise<LiveAgentAdapter>;
|
|
40
|
+
cancelJob?: (jobId: string) => Promise<"queued" | "running" | "not-found">;
|
|
41
|
+
output?: (agent: AgentRecord, offset: number, limit: number) => Promise<unknown>;
|
|
42
|
+
history?: (agent: AgentRecord, offset: number, limit: number) => Promise<unknown>;
|
|
43
|
+
model?: (request: Record<string, unknown>, caller: HubCaller) => Promise<unknown>;
|
|
44
|
+
onRelease?: (agent: AgentRecord) => void | Promise<void>;
|
|
45
|
+
clock?: () => Date;
|
|
46
|
+
sleep?: (ms: number) => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface DurableMessage {
|
|
50
|
+
id: string;
|
|
51
|
+
agentId: string;
|
|
52
|
+
senderAgentId?: string;
|
|
53
|
+
content: string;
|
|
54
|
+
createdAt: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function nextId(prefix: string, existing: Iterable<string>): string {
|
|
58
|
+
const pattern = new RegExp(`^${prefix}-(\\d+)$`);
|
|
59
|
+
let max = 0;
|
|
60
|
+
for (const id of existing) {
|
|
61
|
+
const match = id.match(pattern);
|
|
62
|
+
if (match) max = Math.max(max, Number(match[1]));
|
|
63
|
+
}
|
|
64
|
+
return `${prefix}-${max + 1}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
68
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
69
|
+
return value as Record<string, unknown>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function strictKeys(value: Record<string, unknown>, allowed: string[]): void {
|
|
73
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
74
|
+
if (unknown.length > 0) throw new Error(`hub input contains unknown fields: ${unknown.join(", ")}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function requiredString(value: unknown, label: string): string {
|
|
78
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`${label} must be a non-empty string`);
|
|
79
|
+
return value.trim();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function nonNegativeInteger(value: unknown, fallback: number, label: string, minimum = 0): number {
|
|
83
|
+
if (value === undefined) return fallback;
|
|
84
|
+
if (!Number.isSafeInteger(value) || (value as number) < minimum) throw new Error(`${label} must be an integer >= ${minimum}`);
|
|
85
|
+
return value as number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isDescendant(state: CoordinatorState, ancestorId: string, targetId: string): boolean {
|
|
89
|
+
if (ancestorId === targetId) return true;
|
|
90
|
+
let cursor = state.agents[targetId] ?? state.releasedAgents[targetId];
|
|
91
|
+
const seen = new Set<string>();
|
|
92
|
+
while (cursor?.parentAgentId && !seen.has(cursor.id)) {
|
|
93
|
+
if (cursor.parentAgentId === ancestorId) return true;
|
|
94
|
+
seen.add(cursor.id);
|
|
95
|
+
cursor = state.agents[cursor.parentAgentId] ?? state.releasedAgents[cursor.parentAgentId];
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class HubService {
|
|
101
|
+
private readonly live = new Map<string, LiveAgentAdapter>();
|
|
102
|
+
private readonly agentTails = new Map<string, Promise<void>>();
|
|
103
|
+
private readonly clock: () => Date;
|
|
104
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
105
|
+
|
|
106
|
+
constructor(private readonly options: HubServiceOptions) {
|
|
107
|
+
this.clock = options.clock ?? (() => new Date());
|
|
108
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
registerLive(agentId: string, live: LiveAgentAdapter): void {
|
|
112
|
+
const state = this.options.journal.getState();
|
|
113
|
+
if (!state.agents[agentId]) throw new Error(`${agentId}: cannot register live adapter for unknown Agent`);
|
|
114
|
+
if (this.live.has(agentId)) throw new Error(`${agentId}: live adapter already registered`);
|
|
115
|
+
this.live.set(agentId, live);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
unregisterLive(agentId: string): void {
|
|
119
|
+
this.live.delete(agentId);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async park(agentId: string): Promise<boolean> {
|
|
123
|
+
return await this.withAgentOperation(agentId, async () => {
|
|
124
|
+
const agent = this.options.journal.getState().agents[agentId];
|
|
125
|
+
if (!agent || agent.state !== "idle") return false;
|
|
126
|
+
const live = this.live.get(agentId);
|
|
127
|
+
if (live) await live.dispose();
|
|
128
|
+
this.live.delete(agentId);
|
|
129
|
+
await this.options.journal.append({ kind: "agent.state", agentId, payload: { from: "idle", to: "parked", currentJobId: null, currentTurnId: null } });
|
|
130
|
+
return true;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async execute(raw: unknown, caller: HubCaller = {}): Promise<unknown> {
|
|
135
|
+
const input = record(raw, "hub input");
|
|
136
|
+
const action = requiredString(input.action, "hub.action");
|
|
137
|
+
switch (action) {
|
|
138
|
+
case "list":
|
|
139
|
+
strictKeys(input, ["action", "includeReleased"]);
|
|
140
|
+
return this.list(caller, input.includeReleased === true);
|
|
141
|
+
case "send":
|
|
142
|
+
strictKeys(input, ["action", "agentId", "message", "messageId"]);
|
|
143
|
+
{
|
|
144
|
+
const agentId = requiredString(input.agentId, "hub.agentId");
|
|
145
|
+
return await this.withAgentOperation(agentId, async () => await this.send(agentId, requiredString(input.message, "hub.message"), caller, typeof input.messageId === "string" ? input.messageId : undefined));
|
|
146
|
+
}
|
|
147
|
+
case "inbox":
|
|
148
|
+
strictKeys(input, ["action", "agentId", "mode"]);
|
|
149
|
+
{
|
|
150
|
+
const agentId = requiredString(input.agentId, "hub.agentId");
|
|
151
|
+
return await this.withAgentOperation(agentId, async () => await this.inbox(agentId, input.mode === "drain" ? "drain" : "peek", caller));
|
|
152
|
+
}
|
|
153
|
+
case "jobs":
|
|
154
|
+
strictKeys(input, ["action", "jobId"]);
|
|
155
|
+
return this.jobs(caller, typeof input.jobId === "string" ? input.jobId : undefined);
|
|
156
|
+
case "cancel":
|
|
157
|
+
strictKeys(input, ["action", "id"]);
|
|
158
|
+
{
|
|
159
|
+
const id = requiredString(input.id, "hub.id");
|
|
160
|
+
const key = this.options.journal.getState().jobs[id]?.agentId ?? id;
|
|
161
|
+
return await this.withAgentOperation(key, async () => await this.cancel(id, caller));
|
|
162
|
+
}
|
|
163
|
+
case "wait":
|
|
164
|
+
strictKeys(input, ["action", "jobIds", "messageIds", "timeoutMs", "pollIntervalMs"]);
|
|
165
|
+
return await this.wait(input, caller);
|
|
166
|
+
case "output":
|
|
167
|
+
case "history": {
|
|
168
|
+
strictKeys(input, ["action", "agentId", "offset", "limit"]);
|
|
169
|
+
const agent = this.ownedAgent(requiredString(input.agentId, "hub.agentId"), caller, true);
|
|
170
|
+
const offset = nonNegativeInteger(input.offset, 0, "hub.offset");
|
|
171
|
+
const limit = nonNegativeInteger(input.limit, 500, "hub.limit", 1);
|
|
172
|
+
const resolver = action === "output" ? this.options.output : this.options.history;
|
|
173
|
+
if (!resolver) throw new Error(`hub ${action} resolver is unavailable`);
|
|
174
|
+
return await resolver(agent, offset, limit);
|
|
175
|
+
}
|
|
176
|
+
case "model":
|
|
177
|
+
strictKeys(input, ["action", "operation", "agentId", "selector", "model"]);
|
|
178
|
+
if (!this.options.model) throw new Error("hub model operations are unavailable");
|
|
179
|
+
return await this.options.model(input, caller);
|
|
180
|
+
default:
|
|
181
|
+
throw new Error(`unknown hub action: ${action}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async settleMessageTurn(agentId: string, turnId: string, status: "completed" | "failed" | "aborted", error?: string): Promise<void> {
|
|
186
|
+
await this.withAgentOperation(agentId, async () => await this.settleMessageTurnUnlocked(agentId, turnId, status, error));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private async settleMessageTurnUnlocked(agentId: string, turnId: string, status: "completed" | "failed" | "aborted", error?: string): Promise<void> {
|
|
190
|
+
const state = this.options.journal.getState();
|
|
191
|
+
const agent = state.agents[agentId];
|
|
192
|
+
const turn = state.turns[turnId];
|
|
193
|
+
if (!agent || !turn || turn.agentId !== agentId || turn.state !== "running" || agent.state !== "running") {
|
|
194
|
+
throw new Error(`${agentId}/${turnId}: no matching running message turn`);
|
|
195
|
+
}
|
|
196
|
+
await this.options.journal.append({
|
|
197
|
+
kind: "turn.state",
|
|
198
|
+
agentId,
|
|
199
|
+
turnId,
|
|
200
|
+
payload: { from: "running", to: status, outcome: error ?? status },
|
|
201
|
+
});
|
|
202
|
+
await this.options.journal.append({
|
|
203
|
+
kind: "agent.state",
|
|
204
|
+
agentId,
|
|
205
|
+
payload: { from: "running", to: status === "aborted" ? "aborted" : "idle", currentTurnId: null },
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async withAgentOperation<T>(agentId: string, operation: () => Promise<T>): Promise<T> {
|
|
210
|
+
const previous = this.agentTails.get(agentId) ?? Promise.resolve();
|
|
211
|
+
const current = previous.then(operation);
|
|
212
|
+
const tail = current.then(() => undefined, () => undefined);
|
|
213
|
+
this.agentTails.set(agentId, tail);
|
|
214
|
+
try {
|
|
215
|
+
return await current;
|
|
216
|
+
} finally {
|
|
217
|
+
if (this.agentTails.get(agentId) === tail) this.agentTails.delete(agentId);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private list(caller: HubCaller, includeReleased: boolean) {
|
|
222
|
+
const state = this.options.journal.getState();
|
|
223
|
+
const visible = (agent: AgentRecord) => !caller.agentId || isDescendant(state, caller.agentId, agent.id);
|
|
224
|
+
return {
|
|
225
|
+
agents: Object.values(state.agents).filter(visible),
|
|
226
|
+
released: includeReleased ? Object.values(state.releasedAgents).filter(visible) : [],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private ownedAgent(agentId: string, caller: HubCaller, includeReleased = false): AgentRecord {
|
|
231
|
+
const state = this.options.journal.getState();
|
|
232
|
+
const agent = state.agents[agentId] ?? (includeReleased ? state.releasedAgents[agentId] : undefined);
|
|
233
|
+
if (!agent) throw new Error(`${agentId}: unknown Agent in this parent`);
|
|
234
|
+
if (caller.agentId && !isDescendant(state, caller.agentId, agentId)) throw new Error(`${agentId}: cross-owner Agent access denied`);
|
|
235
|
+
return agent;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private messageReceipt(messageId: string): Record<string, unknown> | undefined {
|
|
239
|
+
return this.options.journal.getState().messages[messageId];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async putMessage(message: DurableMessage, status: string, details: Record<string, unknown> = {}): Promise<void> {
|
|
243
|
+
await this.options.journal.append({
|
|
244
|
+
kind: "message.put",
|
|
245
|
+
agentId: message.agentId,
|
|
246
|
+
messageId: message.id,
|
|
247
|
+
payload: {
|
|
248
|
+
status,
|
|
249
|
+
agentId: message.agentId,
|
|
250
|
+
senderAgentId: message.senderAgentId,
|
|
251
|
+
createdAt: message.createdAt,
|
|
252
|
+
contentHash: createHash("sha256").update(message.content).digest("hex"),
|
|
253
|
+
...details,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async send(agentId: string, content: string, caller: HubCaller, requestedMessageId?: string): Promise<Record<string, unknown>> {
|
|
259
|
+
await assertNoCredentialMaterial(content, "hub message");
|
|
260
|
+
const agent = this.ownedAgent(agentId, caller);
|
|
261
|
+
if (agent.state === "aborted") throw new Error(`${agentId}: Agent is terminal aborted`);
|
|
262
|
+
const state = this.options.journal.getState();
|
|
263
|
+
const messageId = requestedMessageId?.trim() || nextId("message", Object.keys(state.messages));
|
|
264
|
+
if (!/^[A-Za-z0-9._:-]{1,160}$/.test(messageId) || messageId.includes("..")) throw new Error("messageId is unsafe");
|
|
265
|
+
const existing = this.messageReceipt(messageId);
|
|
266
|
+
if (existing) {
|
|
267
|
+
if (existing.agentId !== agentId) throw new Error(`${messageId}: message ID is owned by another Agent`);
|
|
268
|
+
const queued = this.options.journal.getState().mailboxes[agentId]?.messages.some((item) => item.id === messageId);
|
|
269
|
+
return { messageId, deduplicated: true, ...(queued && existing.status === "pending" ? { ...existing, status: "queued" } : existing) };
|
|
270
|
+
}
|
|
271
|
+
const message: DurableMessage = {
|
|
272
|
+
id: messageId,
|
|
273
|
+
agentId,
|
|
274
|
+
senderAgentId: caller.agentId,
|
|
275
|
+
content,
|
|
276
|
+
createdAt: this.clock().toISOString(),
|
|
277
|
+
};
|
|
278
|
+
await this.putMessage(message, "pending");
|
|
279
|
+
|
|
280
|
+
if (agent.state === "running") {
|
|
281
|
+
const live = this.live.get(agentId);
|
|
282
|
+
if (!live) return await this.enqueueAfterLiveFailure(message, "running Agent live adapter is unavailable");
|
|
283
|
+
try {
|
|
284
|
+
await live.steer(content);
|
|
285
|
+
await this.putMessage(message, "delivered", { delivery: "steer-safe-boundary" });
|
|
286
|
+
return { messageId, status: "delivered", delivery: "steer-safe-boundary" };
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return await this.enqueueAfterLiveFailure(message, error instanceof Error ? error.message : String(error));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (agent.state === "parked") {
|
|
293
|
+
let revived: LiveAgentAdapter;
|
|
294
|
+
try {
|
|
295
|
+
revived = await this.options.revive(agent);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
298
|
+
await this.putMessage(message, "failed", { permanent: true, reason });
|
|
299
|
+
return { messageId, status: "failed", permanent: true, reason };
|
|
300
|
+
}
|
|
301
|
+
this.live.set(agentId, revived);
|
|
302
|
+
return await this.startMessageTurn(agent, message, revived, "parked");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (agent.state === "idle") {
|
|
306
|
+
const live = this.live.get(agentId);
|
|
307
|
+
if (!live) return await this.enqueueAfterLiveFailure(message, "idle Agent live adapter is unavailable");
|
|
308
|
+
return await this.startMessageTurn(agent, message, live, "idle");
|
|
309
|
+
}
|
|
310
|
+
throw new Error(`${agentId}: Agent state ${agent.state} cannot receive messages`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async startMessageTurn(agent: AgentRecord, message: DurableMessage, live: LiveAgentAdapter, priorState: "idle" | "parked"): Promise<Record<string, unknown>> {
|
|
314
|
+
const state = this.options.journal.getState();
|
|
315
|
+
const turnId = nextId("turn", Object.keys(state.turns));
|
|
316
|
+
const now = this.clock().toISOString();
|
|
317
|
+
const turn: TurnRecord = {
|
|
318
|
+
id: turnId,
|
|
319
|
+
agentId: agent.id,
|
|
320
|
+
state: "queued",
|
|
321
|
+
createdAt: now,
|
|
322
|
+
updatedAt: now,
|
|
323
|
+
metadata: { source: "hub.send", messageId: message.id },
|
|
324
|
+
};
|
|
325
|
+
await this.options.journal.append({ kind: "turn.created", agentId: agent.id, turnId, payload: { record: turn } });
|
|
326
|
+
await this.options.journal.append({ kind: "agent.state", agentId: agent.id, payload: { from: priorState, to: "running", currentTurnId: turnId, currentJobId: null } });
|
|
327
|
+
await this.options.journal.append({ kind: "turn.state", agentId: agent.id, turnId, payload: { from: "queued", to: "running" } });
|
|
328
|
+
try {
|
|
329
|
+
await live.sendUserMessage(message.content);
|
|
330
|
+
await this.putMessage(message, "delivered", { delivery: priorState === "parked" ? "revive-turn" : "idle-turn", turnId });
|
|
331
|
+
return { messageId: message.id, status: "delivered", delivery: priorState === "parked" ? "revive-turn" : "idle-turn", turnId };
|
|
332
|
+
} catch (error) {
|
|
333
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
334
|
+
await this.options.journal.append({ kind: "turn.state", agentId: agent.id, turnId, payload: { from: "running", to: "failed", outcome: reason } });
|
|
335
|
+
await this.options.journal.append({ kind: "agent.state", agentId: agent.id, payload: { from: "running", to: priorState, currentTurnId: null } });
|
|
336
|
+
if (priorState === "parked") {
|
|
337
|
+
await Promise.resolve(live.dispose()).catch(() => undefined);
|
|
338
|
+
this.live.delete(agent.id);
|
|
339
|
+
}
|
|
340
|
+
return await this.enqueueAfterLiveFailure(message, reason);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private async enqueueAfterLiveFailure(message: DurableMessage, reason: string): Promise<Record<string, unknown>> {
|
|
345
|
+
const state = this.options.journal.getState();
|
|
346
|
+
const current = state.mailboxes[message.agentId]?.messages ?? [];
|
|
347
|
+
if (current.length >= 100) {
|
|
348
|
+
await this.putMessage(message, "overflow", { reason: "mailbox-cap-100", liveFailure: reason });
|
|
349
|
+
return { messageId: message.id, status: "failed", overflow: true, reason: "mailbox-cap-100" };
|
|
350
|
+
}
|
|
351
|
+
const queued = [...current, message as unknown as Record<string, unknown>];
|
|
352
|
+
await this.options.journal.append({ kind: "mailbox.put", agentId: message.agentId, messageId: message.id, payload: { messages: queued } });
|
|
353
|
+
await this.putMessage(message, "queued", { liveFailure: reason });
|
|
354
|
+
return { messageId: message.id, status: "queued", mailboxSize: queued.length, reason };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private async inbox(agentId: string, mode: "peek" | "drain", caller: HubCaller) {
|
|
358
|
+
this.ownedAgent(agentId, caller);
|
|
359
|
+
const messages = this.options.journal.getState().mailboxes[agentId]?.messages ?? [];
|
|
360
|
+
if (mode === "drain" && messages.length > 0) {
|
|
361
|
+
await this.options.journal.append({ kind: "mailbox.put", agentId, payload: { messages: [] } });
|
|
362
|
+
}
|
|
363
|
+
return { agentId, mode, count: messages.length, messages };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private jobs(caller: HubCaller, jobId?: string) {
|
|
367
|
+
const state = this.options.journal.getState();
|
|
368
|
+
const visible = Object.values(state.jobs).filter((job) => {
|
|
369
|
+
if (jobId && job.id !== jobId) return false;
|
|
370
|
+
return !caller.agentId || isDescendant(state, caller.agentId, job.agentId);
|
|
371
|
+
});
|
|
372
|
+
if (jobId && visible.length === 0) throw new Error(`${jobId}: unknown or cross-owner job`);
|
|
373
|
+
return { jobs: visible };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private async cancel(id: string, caller: HubCaller): Promise<Record<string, unknown>> {
|
|
377
|
+
const state = this.options.journal.getState();
|
|
378
|
+
const job = state.jobs[id];
|
|
379
|
+
if (job) {
|
|
380
|
+
this.ownedAgent(job.agentId, caller);
|
|
381
|
+
if (!this.options.cancelJob) throw new Error("job cancellation bridge is unavailable");
|
|
382
|
+
const live = this.live.get(job.agentId);
|
|
383
|
+
await live?.abort?.(`hub cancel ${id}`);
|
|
384
|
+
return { id, kind: "job", result: await this.options.cancelJob(id), transcriptPreserved: true };
|
|
385
|
+
}
|
|
386
|
+
const agent = this.ownedAgent(id, caller);
|
|
387
|
+
if (agent.state === "queued" && agent.currentJobId) return await this.cancel(agent.currentJobId, caller);
|
|
388
|
+
if (agent.state === "running") {
|
|
389
|
+
if (agent.currentJobId) return await this.cancel(agent.currentJobId, caller);
|
|
390
|
+
const live = this.live.get(agent.id);
|
|
391
|
+
await live?.abort?.(`hub cancel ${id}`);
|
|
392
|
+
if (agent.currentTurnId) await this.settleMessageTurnUnlocked(agent.id, agent.currentTurnId, "aborted", "hub cancel");
|
|
393
|
+
return { id, kind: "agent", result: "aborted", transcriptPreserved: true };
|
|
394
|
+
}
|
|
395
|
+
const live = this.live.get(agent.id);
|
|
396
|
+
if (live) await live.dispose();
|
|
397
|
+
this.live.delete(agent.id);
|
|
398
|
+
await this.options.onRelease?.(agent);
|
|
399
|
+
await this.options.journal.append({ kind: "agent.released", agentId: agent.id, payload: { reason: "hub cancel/release" } });
|
|
400
|
+
return { id, kind: "agent", result: "released", transcriptPreserved: true };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private async wait(input: Record<string, unknown>, caller: HubCaller) {
|
|
404
|
+
const jobIds = Array.isArray(input.jobIds) ? input.jobIds.map((id) => requiredString(id, "hub.jobIds[]")) : [];
|
|
405
|
+
const messageIds = Array.isArray(input.messageIds) ? input.messageIds.map((id) => requiredString(id, "hub.messageIds[]")) : [];
|
|
406
|
+
if (jobIds.length + messageIds.length === 0) throw new Error("hub wait requires jobIds or messageIds");
|
|
407
|
+
const timeoutMs = nonNegativeInteger(input.timeoutMs, 30_000, "hub.timeoutMs");
|
|
408
|
+
const pollIntervalMs = nonNegativeInteger(input.pollIntervalMs, 25, "hub.pollIntervalMs", 1);
|
|
409
|
+
const deadline = Date.now() + timeoutMs;
|
|
410
|
+
while (true) {
|
|
411
|
+
const state = this.options.journal.getState();
|
|
412
|
+
const jobs = jobIds.map((id) => state.jobs[id]).filter(Boolean);
|
|
413
|
+
for (const job of jobs) this.ownedAgent(job!.agentId, caller, true);
|
|
414
|
+
const messages = messageIds.map((id) => state.messages[id]);
|
|
415
|
+
for (const message of messages) {
|
|
416
|
+
if (message && typeof message.agentId === "string") this.ownedAgent(message.agentId, caller, true);
|
|
417
|
+
}
|
|
418
|
+
const unknown = [
|
|
419
|
+
...jobIds.filter((id) => !state.jobs[id]),
|
|
420
|
+
...messageIds.filter((id) => !state.messages[id]),
|
|
421
|
+
];
|
|
422
|
+
const jobsDone = jobs.length === jobIds.length && jobs.every((job) => ["completed", "failed", "aborted", "unexecuted"].includes(job!.state));
|
|
423
|
+
const messagesDone = messages.length === messageIds.length && messages.every((message) => ["delivered", "queued", "failed", "overflow"].includes(String(message!.status)));
|
|
424
|
+
if (unknown.length === 0 && jobsDone && messagesDone) return { completed: true, timedOut: false, jobs, messages };
|
|
425
|
+
if (Date.now() >= deadline) return { completed: false, timedOut: true, unknown, jobs, messages };
|
|
426
|
+
await this.sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|