@sgtbeatdown/pi-tasks 0.1.1 → 0.1.3
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 +80 -9
- package/package.json +6 -2
- package/skills/wolfpack-pi-task-delegation/SKILL.md +51 -27
- package/src/extension.ts +236 -137
- package/src/gateway-client.ts +32 -8
- package/src/in-memory-task-relay.ts +114 -0
- package/src/index.ts +35 -0
- package/src/legacy-extension.ts +248 -0
- package/src/legacy-task-inbox.ts +148 -0
- package/src/sqlite-database.ts +68 -0
- package/src/task-core.ts +430 -0
- package/src/task-inbox.ts +68 -90
- package/src/task-protocol.ts +164 -0
- package/src/task-relay-conformance.ts +23 -0
- package/src/task-store.ts +491 -0
- package/src/v1-compat-extension.ts +1 -0
- package/src/wolfpack-task-relay.ts +343 -0
package/src/extension.ts
CHANGED
|
@@ -3,205 +3,308 @@ import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-
|
|
|
3
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
createWolfpackGatewayClient,
|
|
12
|
-
type TaskCompletionInput,
|
|
13
|
-
type TaskStatus,
|
|
14
|
-
type WolfpackGatewayClient,
|
|
15
|
-
} from "./gateway-client";
|
|
16
|
-
import { deliverTaskInbox, restoredInboxCursor } from "./task-inbox";
|
|
6
|
+
import { deliverTaskInbox, incorporatedTaskEvents } from "./task-inbox";
|
|
7
|
+
import { TaskOutboxDeliveryError, TaskProtocolError } from "./task-protocol";
|
|
8
|
+
import { createWolfpackTaskCore } from "./wolfpack-task-relay";
|
|
9
|
+
import type { TaskCore } from "./task-core";
|
|
10
|
+
import type { TaskEndpoint, TaskSnapshot } from "./task-protocol";
|
|
17
11
|
|
|
12
|
+
const DEFAULT_TASK_TIMEOUT_MS = 30 * 60 * 1_000;
|
|
13
|
+
const MIN_TASK_TIMEOUT_MS = 1_000;
|
|
14
|
+
const MAX_TASK_TIMEOUT_MS = 86_400_000;
|
|
18
15
|
const BACKGROUND_POLL_MS = 5_000;
|
|
16
|
+
const TARGET_NOT_REGISTERED_CODE = "TARGET_NOT_REGISTERED";
|
|
19
17
|
const WAIT_POLL_MS = 250;
|
|
20
18
|
const SUMMARY_MAX_CHARS = 1_200;
|
|
19
|
+
const PRE_ASSIGNMENT_TOOLS = new Set(["agent_task_inbox", "agent_task_status", "agent_task_wait"]);
|
|
20
|
+
export const WORKER_GATE_DENIAL_CODE = "PI_TASK_WORKER_ASSIGNMENT_REQUIRED";
|
|
21
21
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
const EndpointParams = Type.Object({
|
|
23
|
+
relay: Type.String({ minLength: 1, description: "relay identifier" }),
|
|
24
|
+
id: Type.String({ minLength: 1, description: "opaque endpoint identifier" }),
|
|
25
25
|
});
|
|
26
|
-
|
|
27
|
-
const ContextRefParams = Type.Object({
|
|
28
|
-
path: Type.String({ minLength: 1 }),
|
|
29
|
-
selector: Type.Optional(Type.String()),
|
|
30
|
-
purpose: Type.Optional(Type.String()),
|
|
31
|
-
});
|
|
32
|
-
|
|
33
26
|
const SendParams = Type.Object({
|
|
34
|
-
to:
|
|
27
|
+
to: EndpointParams,
|
|
35
28
|
task: Type.String({ minLength: 1, maxLength: 16 * 1024 }),
|
|
36
|
-
context: Type.Optional(Type.Object({
|
|
37
|
-
summary: Type.Optional(Type.String({ minLength: 1, maxLength: 16 * 1024 })),
|
|
38
|
-
refs: Type.Optional(Type.Array(ContextRefParams)),
|
|
39
|
-
})),
|
|
40
|
-
role: Type.Optional(Type.String()),
|
|
41
|
-
preflight: Type.Optional(Type.Object({ requiredProject: Type.Optional(Type.String()) })),
|
|
42
|
-
metadata: Type.Optional(Type.Object({
|
|
43
|
-
phaseId: Type.Optional(Type.String()),
|
|
44
|
-
issueId: Type.Optional(Type.String()),
|
|
45
|
-
verificationTier: Type.Optional(Type.String()),
|
|
46
|
-
rootCause: Type.Optional(Type.String()),
|
|
47
|
-
})),
|
|
48
|
-
onCompletePrompt: Type.Optional(Type.String({ maxLength: 4_000 })),
|
|
49
29
|
timeoutMs: Type.Optional(Type.Integer({ minimum: MIN_TASK_TIMEOUT_MS, maximum: MAX_TASK_TIMEOUT_MS })),
|
|
50
|
-
idempotencyKey: Type.Optional(Type.String({ minLength: 1 })),
|
|
51
30
|
});
|
|
52
|
-
|
|
53
31
|
const TaskIdParams = Type.Object({ taskId: Type.String({ minLength: 1 }) });
|
|
54
32
|
const WaitParams = Type.Object({ taskId: Type.String({ minLength: 1 }), timeoutMs: Type.Optional(Type.Integer({ minimum: MIN_TASK_TIMEOUT_MS, maximum: MAX_TASK_TIMEOUT_MS })) });
|
|
55
|
-
const
|
|
56
|
-
const MessageParams = Type.Object({
|
|
57
|
-
taskId: Type.String({ minLength: 1 }),
|
|
58
|
-
type: StringEnum(["question", "answer", "information"] as const),
|
|
59
|
-
message: Type.String({ minLength: 1, maxLength: 16 * 1024 }),
|
|
60
|
-
replyToMessageId: Type.Optional(Type.String({ minLength: 1 })),
|
|
61
|
-
});
|
|
33
|
+
const MessageParams = Type.Object({ taskId: Type.String({ minLength: 1 }), type: StringEnum(["question", "answer", "information"] as const), message: Type.String({ minLength: 1, maxLength: 16 * 1024 }) });
|
|
62
34
|
const DoneParams = Type.Object({
|
|
63
|
-
taskId: Type.String({ minLength: 1 }),
|
|
64
|
-
status: StringEnum(["completed", "failed", "cancelled"] as const),
|
|
65
|
-
summary: Type.String({ minLength: 1, maxLength: SUMMARY_MAX_CHARS }),
|
|
35
|
+
taskId: Type.String({ minLength: 1 }), status: StringEnum(["completed", "failed", "cancelled"] as const), summary: Type.String({ minLength: 1, maxLength: SUMMARY_MAX_CHARS }),
|
|
66
36
|
result: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
67
37
|
error: Type.Optional(Type.Object({ code: Type.String(), message: Type.String(), retryable: Type.Boolean() })),
|
|
68
|
-
artifacts: Type.Optional(Type.Array(Type.Object({ path: Type.String({ minLength: 1 }), mimeType: Type.Optional(Type.String()), description: Type.Optional(Type.String()) }))),
|
|
38
|
+
artifacts: Type.Optional(Type.Array(Type.Object({ path: Type.String({ minLength: 1, description: "artifact paths are receiver-project-relative regular files" }), mimeType: Type.Optional(Type.String()), description: Type.Optional(Type.String()) }))),
|
|
69
39
|
});
|
|
70
40
|
|
|
71
|
-
export
|
|
72
|
-
let backgroundTimer: ReturnType<typeof setInterval> | undefined;
|
|
41
|
+
export type ConfiguredCoreFactory = (signal?: AbortSignal) => Promise<TaskCore>;
|
|
73
42
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
43
|
+
/** Reuses a successful configured core while allowing the next lifecycle operation to retry a failed setup. */
|
|
44
|
+
export function createConfiguredCoreLoader(factory: ConfiguredCoreFactory): ConfiguredCoreFactory {
|
|
45
|
+
let corePromise: Promise<TaskCore> | undefined;
|
|
46
|
+
return async (signal?: AbortSignal): Promise<TaskCore> => {
|
|
47
|
+
if (corePromise) return corePromise;
|
|
48
|
+
const candidate = Promise.resolve().then(() => factory(signal));
|
|
49
|
+
corePromise = candidate;
|
|
50
|
+
try {
|
|
51
|
+
return await candidate;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (corePromise === candidate) corePromise = undefined;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Registers v2 endpoint-owned tools using a configured durable Wolfpack relay by default. */
|
|
60
|
+
export function registerAgentTaskTools(pi: ExtensionAPI, core: TaskCore | undefined = undefined, createCore: ConfiguredCoreFactory = (signal) => createWolfpackTaskCore({}, signal)): void {
|
|
61
|
+
let inboxContext: ExtensionContext | undefined;
|
|
62
|
+
let backgroundTimer: ReturnType<typeof setInterval> | undefined;
|
|
63
|
+
let lifecycleEpoch = 0;
|
|
64
|
+
const pendingInsertions = new Set<string>();
|
|
65
|
+
const closingTaskIds = new Set<string>();
|
|
66
|
+
const workerGateEnabled = process.env.PI_TASK_WORKER === "1";
|
|
67
|
+
const configuredCore: ConfiguredCoreFactory = core === undefined ? createConfiguredCoreLoader(createCore) : async (): Promise<TaskCore> => core;
|
|
68
|
+
const refreshInbox = createSingleFlightInboxRefresh(async (signal) => {
|
|
69
|
+
const context = inboxContext;
|
|
70
|
+
if (!context) return undefined;
|
|
71
|
+
const activeCore = await configuredCore(signal);
|
|
72
|
+
if (inboxContext !== context) return undefined;
|
|
73
|
+
let outboxError: unknown;
|
|
74
|
+
try {
|
|
75
|
+
await activeCore.flushOutbox(signal);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
outboxError = error;
|
|
78
|
+
}
|
|
79
|
+
if (inboxContext !== context) return undefined;
|
|
80
|
+
try {
|
|
81
|
+
await activeCore.evaluateTimeouts(signal);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
outboxError ??= error;
|
|
84
|
+
}
|
|
85
|
+
if (inboxContext !== context) return undefined;
|
|
86
|
+
const isCurrent = (): boolean => inboxContext === context;
|
|
87
|
+
const guardedCore: TaskCore = {
|
|
88
|
+
...activeCore,
|
|
89
|
+
async receive(receiveSignal) {
|
|
90
|
+
const deliveries = await activeCore.receive(receiveSignal);
|
|
91
|
+
return isCurrent() ? deliveries : [];
|
|
92
|
+
},
|
|
93
|
+
async acknowledgeRelayDelivery(cursor, acknowledgementSignal) {
|
|
94
|
+
if (isCurrent()) await activeCore.acknowledgeRelayDelivery(cursor, acknowledgementSignal);
|
|
95
|
+
},
|
|
96
|
+
async recordInsertion(input, insertionSignal) {
|
|
97
|
+
if (isCurrent()) await activeCore.recordInsertion(input, insertionSignal);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
await deliverTaskInbox({
|
|
101
|
+
sendMessage(message, options) { if (isCurrent()) pi.sendMessage(message, options); },
|
|
102
|
+
appendEntry(customType, data) { if (isCurrent()) pi.appendEntry(customType, data); },
|
|
103
|
+
}, guardedCore, {
|
|
104
|
+
hasPendingMessages: (): boolean => !isCurrent() || context.hasPendingMessages(),
|
|
105
|
+
sessionManager: context.sessionManager,
|
|
106
|
+
}, pendingInsertions, signal);
|
|
107
|
+
return outboxError;
|
|
108
|
+
});
|
|
109
|
+
const refreshLifecycle = async (context: ExtensionContext, epoch: number): Promise<void> => {
|
|
110
|
+
const isCurrent = (): boolean => lifecycleEpoch === epoch && inboxContext === context;
|
|
111
|
+
try {
|
|
112
|
+
const activeCore = await configuredCore();
|
|
113
|
+
let connectionError: unknown;
|
|
114
|
+
try {
|
|
115
|
+
await activeCore.connect();
|
|
116
|
+
} catch (error) {
|
|
117
|
+
connectionError = error;
|
|
118
|
+
}
|
|
119
|
+
if (!isCurrent()) return;
|
|
120
|
+
const outboxError = await refreshInbox();
|
|
121
|
+
if (!isCurrent()) return;
|
|
122
|
+
const lifecycleError = outboxError ?? connectionError;
|
|
123
|
+
const status = lifecycleError === undefined ? undefined : outboxFailureStatus(lifecycleError);
|
|
124
|
+
context.ui.setStatus("pi-tasks", status === undefined ? undefined : context.ui.theme.fg("warning", status));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (isCurrent()) context.ui.setStatus("pi-tasks", context.ui.theme.fg("warning", outboxFailureStatus(error)));
|
|
127
|
+
}
|
|
77
128
|
};
|
|
78
129
|
|
|
79
|
-
pi.on("
|
|
80
|
-
|
|
130
|
+
pi.on("tool_call", async (event, context) => {
|
|
131
|
+
if (!workerGateEnabled || PRE_ASSIGNMENT_TOOLS.has(event.toolName)) return undefined;
|
|
132
|
+
try {
|
|
133
|
+
const activeCore = await configuredCore(context.signal);
|
|
134
|
+
const assignments = assignedWorkerTasks(activeCore, context.sessionManager.getEntries());
|
|
135
|
+
if (event.toolName === "agent_task_done") {
|
|
136
|
+
const taskId = isRecord(event.input) && typeof event.input.taskId === "string" ? event.input.taskId : undefined;
|
|
137
|
+
const assigned = taskId === undefined ? undefined : assignments.find((task) => task.taskId === taskId);
|
|
138
|
+
if (assigned === undefined || (!eligibleWorkerTask(assigned, closingTaskIds) && assigned.terminalDelivery.state === "not_submitted" && !closingTaskIds.has(assigned.taskId))) {
|
|
139
|
+
return { block: true, reason: WORKER_GATE_DENIAL_CODE };
|
|
140
|
+
}
|
|
141
|
+
closingTaskIds.add(assigned.taskId);
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
if (assignments.some((task) => eligibleWorkerTask(task, closingTaskIds))) return undefined;
|
|
145
|
+
} catch {
|
|
146
|
+
// Authorization evidence unavailable or malformed: fail closed with the stable public reason.
|
|
147
|
+
}
|
|
148
|
+
return { block: true, reason: WORKER_GATE_DENIAL_CODE };
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
pi.on("session_start", async (_event, context) => {
|
|
152
|
+
pendingInsertions.clear();
|
|
153
|
+
closingTaskIds.clear();
|
|
154
|
+
const epoch = ++lifecycleEpoch;
|
|
155
|
+
inboxContext = context;
|
|
156
|
+
if (backgroundTimer) clearInterval(backgroundTimer);
|
|
81
157
|
backgroundTimer = setInterval(() => {
|
|
82
|
-
|
|
158
|
+
const currentContext = inboxContext;
|
|
159
|
+
if (currentContext) void refreshLifecycle(currentContext, epoch);
|
|
83
160
|
}, BACKGROUND_POLL_MS);
|
|
84
|
-
await
|
|
161
|
+
await refreshLifecycle(context, epoch);
|
|
85
162
|
});
|
|
86
|
-
pi.on("agent_end", async (_event,
|
|
87
|
-
|
|
163
|
+
pi.on("agent_end", async (_event, context) => {
|
|
164
|
+
const epoch = lifecycleEpoch;
|
|
165
|
+
inboxContext = context;
|
|
166
|
+
await refreshLifecycle(context, epoch);
|
|
88
167
|
});
|
|
89
168
|
pi.on("session_shutdown", () => {
|
|
169
|
+
pendingInsertions.clear();
|
|
170
|
+
closingTaskIds.clear();
|
|
171
|
+
lifecycleEpoch += 1;
|
|
90
172
|
if (backgroundTimer) clearInterval(backgroundTimer);
|
|
91
173
|
backgroundTimer = undefined;
|
|
174
|
+
inboxContext = undefined;
|
|
92
175
|
});
|
|
93
176
|
|
|
94
177
|
pi.registerTool({
|
|
95
|
-
name: "agent_task_send", label: "Send Agent Task", description: "
|
|
96
|
-
|
|
97
|
-
promptGuidelines: ["Use agent_task_send for normal nonblocking delegation; do not wait unless the user explicitly asks."],
|
|
98
|
-
parameters: SendParams,
|
|
99
|
-
async execute(_toolCallId, params, signal) {
|
|
178
|
+
name: "agent_task_send", label: "Send Agent Task", description: "Persist an endpoint-owned task and submit its opaque assignment envelope to a relay.", parameters: SendParams,
|
|
179
|
+
async execute(_id, params, signal) {
|
|
100
180
|
try {
|
|
101
|
-
const
|
|
102
|
-
return toolResult(
|
|
103
|
-
} catch (error) { return
|
|
104
|
-
},
|
|
181
|
+
const sent = await (await configuredCore(signal)).createTask({ target: params.to, task: params.task, timeoutMs: params.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS }, signal);
|
|
182
|
+
return toolResult(sent, `## task accepted\n- task: \`${sent.taskId}\`\n- target: \`${params.to.relay}/${params.to.id}\`\n- delivery: relay acceptance only`);
|
|
183
|
+
} catch (error) { return taskError(error); }
|
|
184
|
+
},
|
|
185
|
+
renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
105
186
|
});
|
|
106
|
-
|
|
107
187
|
pi.registerTool({
|
|
108
|
-
name: "agent_task_status", label: "Task Status", description: "Read
|
|
109
|
-
async execute(
|
|
110
|
-
try {
|
|
188
|
+
name: "agent_task_status", label: "Task Status", description: "Read local endpoint-owned task state; status is unavailable while its origin is offline.", parameters: TaskIdParams,
|
|
189
|
+
async execute(_id, params, signal) {
|
|
190
|
+
try {
|
|
191
|
+
const task = (await configuredCore(signal)).getTask(params.taskId);
|
|
192
|
+
return task ? toolResult(task, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}`) : taskError(new Error("unknown local task"));
|
|
193
|
+
} catch (error) { return taskError(error); }
|
|
111
194
|
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
112
195
|
});
|
|
113
|
-
|
|
114
196
|
pi.registerTool({
|
|
115
|
-
name: "agent_task_wait", label: "Wait Agent Task", description: "
|
|
116
|
-
async execute(
|
|
197
|
+
name: "agent_task_wait", label: "Wait Agent Task", description: "Wait for a locally-known endpoint-owned task to reach a terminal state.", parameters: WaitParams,
|
|
198
|
+
async execute(_id, params, signal, onUpdate) {
|
|
117
199
|
const deadline = Date.now() + (params.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS);
|
|
118
200
|
try {
|
|
119
201
|
for (;;) {
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
if (
|
|
202
|
+
await refreshInbox(signal);
|
|
203
|
+
const task = (await configuredCore(signal)).getTask(params.taskId);
|
|
204
|
+
if (!task) return taskError(new Error("unknown local task"));
|
|
205
|
+
if (terminal(task.status)) return toolResult(task, `## task status\n- task: \`${task.taskId}\`\n- status: ${task.status}`);
|
|
206
|
+
if (Date.now() >= deadline) return toolResult({ taskId: params.taskId, status: task.status }, `## task wait\n- task: \`${params.taskId}\`\n- status: ${task.status}\n- wait timed out`);
|
|
124
207
|
onUpdate?.({ content: [{ type: "text", text: `waiting for ${params.taskId}...` }], details: {} });
|
|
125
|
-
await sleep(Math.min(WAIT_POLL_MS,
|
|
208
|
+
await sleep(Math.min(WAIT_POLL_MS, deadline - Date.now()), signal);
|
|
126
209
|
}
|
|
127
|
-
} catch (error) { return
|
|
210
|
+
} catch (error) { return taskError(error); }
|
|
128
211
|
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
129
212
|
});
|
|
130
|
-
|
|
131
213
|
pi.registerTool({
|
|
132
|
-
name: "agent_task_inbox", label: "Task Inbox", description: "Read
|
|
133
|
-
async execute(
|
|
214
|
+
name: "agent_task_inbox", label: "Task Inbox", description: "Read local task records after processing relay deliveries without acknowledging task lifecycle.", parameters: Type.Object({}),
|
|
215
|
+
async execute(_id, _params, signal) {
|
|
134
216
|
try {
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
cursor = page.nextCursor;
|
|
142
|
-
}
|
|
143
|
-
const tasks = await Promise.all([...taskIds].map((taskId) => client.status(taskId, signal)));
|
|
144
|
-
if (params.ack) await Promise.all(tasks.filter((task) => terminal(task.status)).map((task) => client.ack(task.task.taskId, signal)));
|
|
145
|
-
return toolResult({ tasks }, `## task inbox\n${tasks.length === 0 ? "- empty" : tasks.map((task) => `- \`${task.task.taskId}\`: ${task.status}${task.completion ? ` — ${task.completion.summary}` : ""}`).join("\n")}`);
|
|
146
|
-
} catch (error) { return clientError(error); }
|
|
147
|
-
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
217
|
+
await refreshInbox(signal);
|
|
218
|
+
const tasks = (await configuredCore(signal)).listTasks();
|
|
219
|
+
return toolResult({ tasks }, `## task inbox\n${tasks.map((task) => `- \`${task.taskId}\`: ${task.status}`).join("\n") || "- empty"}`);
|
|
220
|
+
} catch (error) { return taskError(error); }
|
|
221
|
+
},
|
|
222
|
+
renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
148
223
|
});
|
|
149
|
-
|
|
150
224
|
pi.registerTool({
|
|
151
|
-
name: "agent_task_message", label: "Message Agent Task", description: "
|
|
152
|
-
async execute(
|
|
153
|
-
|
|
154
|
-
const [identity, status] = await Promise.all([client.sessionStatus(signal), client.status(params.taskId, signal)]);
|
|
155
|
-
const receipt = await client.message(params, signal);
|
|
156
|
-
const receiverQuestion = params.type === "question" && identity.sessionId === status.task.target.sessionId;
|
|
157
|
-
return { ...toolResult(receipt, `## task ${params.type}\n- task: \`${params.taskId}\`\n${params.message}`), ...(receiverQuestion ? { terminate: true } : {}) };
|
|
158
|
-
} catch (error) { return clientError(error); }
|
|
159
|
-
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
225
|
+
name: "agent_task_message", label: "Message Agent Task", description: "Persist a message intent before relay submission.", parameters: MessageParams,
|
|
226
|
+
async execute(_id, params, signal) { try { await (await configuredCore(signal)).submitIntent({ taskId: params.taskId, type: `task.${params.type}`, payload: { message: params.message } }, signal); return toolResult({ taskId: params.taskId }, `## task ${params.type}\n- task: \`${params.taskId}\``); } catch (error) { return taskError(error); } },
|
|
227
|
+
renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
160
228
|
});
|
|
161
|
-
|
|
162
229
|
pi.registerTool({
|
|
163
|
-
name: "agent_task_cancel", label: "Cancel Agent Task", description: "
|
|
164
|
-
async execute(
|
|
165
|
-
|
|
166
|
-
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
230
|
+
name: "agent_task_cancel", label: "Cancel Agent Task", description: "Persist cancellation before relay submission.", parameters: TaskIdParams,
|
|
231
|
+
async execute(_id, params, signal) { try { await (await configuredCore(signal)).submitIntent({ taskId: params.taskId, type: "task.cancelled", payload: {} }, signal); return toolResult({ taskId: params.taskId }, `## task cancellation requested\n- task: \`${params.taskId}\``); } catch (error) { return taskError(error); } },
|
|
232
|
+
renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
167
233
|
});
|
|
168
|
-
|
|
169
234
|
pi.registerTool({
|
|
170
|
-
name: "
|
|
171
|
-
|
|
172
|
-
|
|
235
|
+
name: "agent_task_ack", label: "Acknowledge Agent Task", description: "Record parent acknowledgement as an origin-owned logical event.", parameters: TaskIdParams,
|
|
236
|
+
async execute(_id, params, signal) { try { await (await configuredCore(signal)).acknowledgeParent(params.taskId, signal); return toolResult({ taskId: params.taskId }, `## task acknowledged\n- task: \`${params.taskId}\``); } catch (error) { return taskError(error); } },
|
|
237
|
+
renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
238
|
+
});
|
|
239
|
+
pi.registerTool({
|
|
240
|
+
name: "agent_task_done", label: "Complete Agent Task", description: "Persist a terminal task intent before relay submission.", parameters: DoneParams,
|
|
241
|
+
async execute(_id, params, signal) {
|
|
173
242
|
try {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
} catch (error) { return clientError(error); }
|
|
243
|
+
await (await configuredCore(signal)).submitIntent({ taskId: params.taskId, type: `task.${params.status}`, payload: { summary: params.summary, ...(params.result === undefined ? {} : { result: params.result }), ...(params.error === undefined ? {} : { error: params.error }), ...(params.artifacts === undefined ? {} : { artifacts: params.artifacts }) } }, signal);
|
|
244
|
+
return { ...toolResult({ taskId: params.taskId }, `## task ${params.status}\n- task: \`${params.taskId}\`\n- ${params.summary}`), terminate: true };
|
|
245
|
+
} catch (error) { return taskError(error); }
|
|
178
246
|
}, renderResult(result, _options, theme) { return new Text(theme.fg("accent", text(result))); },
|
|
179
247
|
});
|
|
180
248
|
}
|
|
181
249
|
|
|
182
|
-
export
|
|
183
|
-
|
|
250
|
+
export function createSingleFlightInboxRefresh<TValue>(refresh: (signal?: AbortSignal) => Promise<TValue>): (signal?: AbortSignal) => Promise<TValue> {
|
|
251
|
+
let inFlight: Promise<TValue> | undefined;
|
|
252
|
+
return (signal): Promise<TValue> => {
|
|
253
|
+
if (inFlight) return inFlight;
|
|
254
|
+
const current = refresh(signal).finally(() => {
|
|
255
|
+
if (inFlight === current) inFlight = undefined;
|
|
256
|
+
});
|
|
257
|
+
inFlight = current;
|
|
258
|
+
return current;
|
|
259
|
+
};
|
|
184
260
|
}
|
|
185
261
|
|
|
186
|
-
function
|
|
187
|
-
|
|
262
|
+
export default function piTasks(pi: ExtensionAPI): void {
|
|
263
|
+
registerAgentTaskTools(pi);
|
|
188
264
|
}
|
|
189
265
|
|
|
190
266
|
function toolResult(details: unknown, markdown: string): AgentToolResult<unknown> {
|
|
191
267
|
return { content: [{ type: "text", text: markdown }], details };
|
|
192
268
|
}
|
|
193
269
|
|
|
194
|
-
function
|
|
195
|
-
const
|
|
196
|
-
|
|
270
|
+
function taskError(error: unknown): AgentToolResult<unknown> {
|
|
271
|
+
const message = error instanceof Error ? error.message : "task operation failed";
|
|
272
|
+
if (error instanceof TaskProtocolError) {
|
|
273
|
+
return toolResult({ ...error.details, error: { code: error.code, message, retryable: error.retryable } }, `## task error\n- ${message}`);
|
|
274
|
+
}
|
|
275
|
+
return toolResult({ error: { code: "TASK_ERROR", message, retryable: true } }, `## task error\n- ${message}`);
|
|
197
276
|
}
|
|
198
277
|
|
|
199
|
-
function
|
|
200
|
-
return
|
|
278
|
+
function outboxFailureStatus(error: unknown): "tasks: relay unavailable" | "tasks: outbox degraded" {
|
|
279
|
+
return error instanceof TaskOutboxDeliveryError && error.code === TARGET_NOT_REGISTERED_CODE ? "tasks: outbox degraded" : "tasks: relay unavailable";
|
|
201
280
|
}
|
|
202
281
|
|
|
203
282
|
function terminal(status: string): boolean {
|
|
204
|
-
return
|
|
283
|
+
return ["completed", "failed", "cancelled", "timed_out"].includes(status);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function assignedWorkerTasks(core: TaskCore, entries: readonly unknown[]): readonly TaskSnapshot[] {
|
|
287
|
+
const assignments = new Map<string, TaskSnapshot>();
|
|
288
|
+
for (const evidence of incorporatedTaskEvents(entries)) {
|
|
289
|
+
const task = core.getTask(evidence.taskId);
|
|
290
|
+
if (!task || !sameEndpoint(task.target, core.endpoint)) continue;
|
|
291
|
+
const created = task.events.find((event) => event.eventId === evidence.eventId);
|
|
292
|
+
if (!created || created.type !== "task.created" || !sameEndpoint(created.target, core.endpoint) || !sameEndpoint(created.source, task.origin)) continue;
|
|
293
|
+
assignments.set(task.taskId, task);
|
|
294
|
+
}
|
|
295
|
+
return [...assignments.values()];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function eligibleWorkerTask(task: TaskSnapshot, closingTaskIds: ReadonlySet<string>): boolean {
|
|
299
|
+
return task.status === "active" && task.terminalDelivery.state === "not_submitted" && !closingTaskIds.has(task.taskId);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function sameEndpoint(left: TaskEndpoint, right: TaskEndpoint): boolean {
|
|
303
|
+
return left.relay === right.relay && left.id === right.id;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
307
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
205
308
|
}
|
|
206
309
|
|
|
207
310
|
function text(result: { readonly content?: readonly unknown[] }): string {
|
|
@@ -209,14 +312,10 @@ function text(result: { readonly content?: readonly unknown[] }): string {
|
|
|
209
312
|
return first && typeof first === "object" && "type" in first && first.type === "text" && "text" in first && typeof first.text === "string" ? first.text : "";
|
|
210
313
|
}
|
|
211
314
|
|
|
212
|
-
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
213
|
-
if (signal?.aborted) throw new GatewayClientError("ABORTED", "task wait was cancelled", true);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
315
|
async function sleep(milliseconds: number, signal: AbortSignal | undefined): Promise<void> {
|
|
316
|
+
if (signal?.aborted) throw new Error("task wait was cancelled");
|
|
217
317
|
await new Promise<void>((resolve, reject) => {
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
318
|
+
const timer = setTimeout(resolve, Math.max(1, milliseconds));
|
|
319
|
+
signal?.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("task wait was cancelled")); }, { once: true });
|
|
221
320
|
});
|
|
222
321
|
}
|
package/src/gateway-client.ts
CHANGED
|
@@ -20,12 +20,14 @@ export type GatewayErrorCode =
|
|
|
20
20
|
export class GatewayClientError extends Error {
|
|
21
21
|
readonly code: GatewayErrorCode;
|
|
22
22
|
readonly retryable: boolean;
|
|
23
|
+
readonly path?: string;
|
|
23
24
|
|
|
24
|
-
constructor(code: GatewayErrorCode, message: string, retryable: boolean) {
|
|
25
|
+
constructor(code: GatewayErrorCode, message: string, retryable: boolean, path?: string) {
|
|
25
26
|
super(message);
|
|
26
27
|
this.name = "GatewayClientError";
|
|
27
28
|
this.code = code;
|
|
28
29
|
this.retryable = retryable;
|
|
30
|
+
this.path = path;
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -187,7 +189,7 @@ export function createWolfpackGatewayClient(options: WolfpackGatewayClientOption
|
|
|
187
189
|
signal: controller.signal,
|
|
188
190
|
});
|
|
189
191
|
const parsed = await parseJson(response);
|
|
190
|
-
if (isErrorEnvelope(parsed)) throw new GatewayClientError(parsed.error.code, parsed.error.message, parsed.error.retryable);
|
|
192
|
+
if (isErrorEnvelope(parsed)) throw new GatewayClientError(parsed.error.code, parsed.error.message, parsed.error.retryable, parsed.error.path);
|
|
191
193
|
if (!response.ok) throw new GatewayClientError("MALFORMED_RESPONSE", `wolfpack returned HTTP ${response.status} without a task error envelope`, true);
|
|
192
194
|
if (!isOkEnvelope(parsed)) throw new GatewayClientError("MALFORMED_RESPONSE", "wolfpack returned an invalid task response", true);
|
|
193
195
|
return parsed;
|
|
@@ -278,11 +280,28 @@ function serializeBody(body: unknown): string {
|
|
|
278
280
|
}
|
|
279
281
|
|
|
280
282
|
function validateSend(input: SendTaskInput): void {
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
if (
|
|
283
|
+
const path = sendValidationPath(input);
|
|
284
|
+
if (path !== undefined) throw new GatewayClientError("INVALID_REQUEST", "task and target address are required and bounded", false, path);
|
|
285
|
+
if (input.context?.summary !== undefined && (!nonEmpty(input.context.summary) || bytes(input.context.summary) > MAX_CONTEXT_SUMMARY_BYTES)) throw new GatewayClientError("INVALID_REQUEST", "context summary exceeds the 16KiB initial limit", false, "/context/summary");
|
|
286
|
+
if (bytes(JSON.stringify(input)) > MAX_ASSIGNMENT_ENVELOPE_BYTES) throw new GatewayClientError("INVALID_REQUEST", "assignment envelope exceeds the 48KiB initial limit", false, "");
|
|
284
287
|
const timeoutMs = input.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS;
|
|
285
|
-
if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_TASK_TIMEOUT_MS || timeoutMs > MAX_TASK_TIMEOUT_MS) throw new GatewayClientError("INVALID_REQUEST", "task timeout must be from 1000ms through 24h", false);
|
|
288
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_TASK_TIMEOUT_MS || timeoutMs > MAX_TASK_TIMEOUT_MS) throw new GatewayClientError("INVALID_REQUEST", "task timeout must be from 1000ms through 24h", false, "/timeoutMs");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function sendValidationPath(input: SendTaskInput): string | undefined {
|
|
292
|
+
if (!isRecord(input.to)) return "/to";
|
|
293
|
+
if (!nonEmpty(input.to.machine)) return "/to/machine";
|
|
294
|
+
if (!nonEmpty(input.to.sessionId)) return "/to/sessionId";
|
|
295
|
+
if (!nonEmpty(input.task) || bytes(input.task) > MAX_TASK_BYTES) return "/task";
|
|
296
|
+
if (input.preflight?.requiredProject !== undefined && !nonEmpty(input.preflight.requiredProject)) return "/preflight/requiredProject";
|
|
297
|
+
if (input.context?.refs !== undefined) {
|
|
298
|
+
if (!Array.isArray(input.context.refs)) return "/context/refs";
|
|
299
|
+
for (const [index, ref] of input.context.refs.entries()) {
|
|
300
|
+
if (!isRecord(ref)) return `/context/refs/${index}`;
|
|
301
|
+
if (!nonEmpty(ref.path)) return `/context/refs/${index}/path`;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return undefined;
|
|
286
305
|
}
|
|
287
306
|
|
|
288
307
|
function validateMessage(input: MessageTaskInput): void {
|
|
@@ -328,8 +347,13 @@ function isOkEnvelope(value: unknown): value is Record<string, unknown> & { read
|
|
|
328
347
|
return isRecord(value) && value.ok === true;
|
|
329
348
|
}
|
|
330
349
|
|
|
331
|
-
function isErrorEnvelope(value: unknown): value is { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly retryable: boolean } } {
|
|
332
|
-
return isRecord(value) && value.ok === false && isRecord(value.error) && nonEmpty(value.error.code) && typeof value.error.message === "string" && typeof value.error.retryable === "boolean"
|
|
350
|
+
function isErrorEnvelope(value: unknown): value is { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly retryable: boolean; readonly path?: string } } {
|
|
351
|
+
return isRecord(value) && value.ok === false && isRecord(value.error) && nonEmpty(value.error.code) && typeof value.error.message === "string" && typeof value.error.retryable === "boolean"
|
|
352
|
+
&& (value.error.path === undefined || jsonPointer(value.error.path));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function jsonPointer(value: unknown): value is string {
|
|
356
|
+
return typeof value === "string" && /^(?:|\/(?:[^~/]|~[01])*)*$/.test(value);
|
|
333
357
|
}
|
|
334
358
|
|
|
335
359
|
function assignment(value: unknown): value is TaskAssignment {
|