@fradser/pi-agent-teams 0.8.3 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.memory/MEMORY.md +1 -1
- package/.memory/follow-up-queue.md +8 -11
- package/.memory/team-centric-design.md +18 -14
- package/LICENSE +21 -0
- package/README.md +119 -157
- package/package.json +2 -2
- package/references/agent-roles.md +57 -12
- package/src/activity.ts +12 -33
- package/src/agent-actions.ts +101 -0
- package/src/agents.ts +1 -6
- package/src/guidance.ts +114 -199
- package/src/index.ts +117 -101
- package/src/leader-reports.ts +92 -0
- package/src/recipient.ts +44 -0
- package/src/spawner.ts +293 -38
- package/src/state.ts +250 -69
- package/src/statefile.ts +74 -31
- package/src/team-machine.ts +984 -424
- package/src/tool-copy.ts +374 -0
- package/src/tool-render.ts +47 -22
- package/src/tools.ts +182 -161
- package/src/types.ts +104 -65
- package/src/ui.ts +32 -56
- package/src/work-context.ts +88 -0
- package/src/worker-tools.ts +4 -0
- package/src/worker.ts +251 -184
- package/src/follow-up-queue.ts +0 -301
package/src/index.ts
CHANGED
|
@@ -5,44 +5,60 @@
|
|
|
5
5
|
* passive widget and console live in ui.ts.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { getMarkdownTheme, keyHint, ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import {
|
|
10
|
+
import { buildIdleLeaderGuidance, buildTeamLeaderGuidance, WORKER_GUIDANCE } from "./guidance.ts";
|
|
11
11
|
import { clearSessionAgents } from "./agents.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { getConfirmedStopTime, initTeamMachine, markTeammateFinished, removeRuntimeDir, shutdownTeamMachine, syncLeaderContext, teardownTeammates } from "./team-machine.ts";
|
|
13
13
|
import { cleanupExpiredStateDirs } from "./statefile.ts";
|
|
14
14
|
import { livingTeammates, listTasks, resetState } from "./state.ts";
|
|
15
15
|
import { ensureTeamWidget, refreshTeamUI, stopUiTimers } from "./ui.ts";
|
|
16
|
-
import {
|
|
16
|
+
import { registerLeaderTools, registerTeamCommand } from "./tools.ts";
|
|
17
17
|
import { registerWorkerCapabilities, workerBinding } from "./worker.ts";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
18
|
+
import { plainText } from "./tool-copy.ts";
|
|
19
|
+
import { agentColor, bindLifecycleRenderers, clearPiStatus, createToolExecutionWrapper, eventToolLifecycle, formatAgentMessagePrefix, notifyPi, renderAgentMessageBand } from "@fradser/pi-kit";
|
|
20
|
+
import { annotateReportDelivery, formatReports, groupReportsByTeammate, TEAMMATE_HARNESS_MESSAGE_TYPE, TEAMMATE_REPORT_MESSAGE_TYPE, type LeaderReport } from "./leader-reports.ts";
|
|
21
|
+
import { Box, Markdown, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
22
|
+
|
|
23
|
+
/** Geometry bound once: harness event rows share hint and wrapping. */
|
|
24
|
+
const harnessRows = bindLifecycleRenderers({
|
|
25
|
+
fit: truncateToWidth,
|
|
26
|
+
visibleWidth,
|
|
27
|
+
wrapDetail: (line, width) => wrapTextWithAnsi(line, Math.max(1, width)),
|
|
28
|
+
expandHint: () => keyHint("app.tools.expand", "to expand"),
|
|
29
|
+
});
|
|
21
30
|
|
|
22
31
|
const STATE_DIR_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
32
|
|
|
24
33
|
let leaderPi: ExtensionAPI | undefined;
|
|
25
34
|
let leaderCtx: ExtensionContext | undefined;
|
|
26
|
-
let followUpQueue: FollowUpQueue | undefined;
|
|
27
35
|
|
|
28
36
|
export const TEAMMATE_FINISHED_ENTRY_TYPE = "agent-teams-teammate-finished";
|
|
29
|
-
export const TEAMMATE_HEALTH_MESSAGE_TYPE = "agent-teams-health";
|
|
30
37
|
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
function sendLeaderReport(report: LeaderReport): void {
|
|
39
|
+
try {
|
|
40
|
+
leaderPi?.sendMessage({
|
|
41
|
+
customType: report.origin === "harness" || report.harnessEvent
|
|
42
|
+
? TEAMMATE_HARNESS_MESSAGE_TYPE
|
|
43
|
+
: TEAMMATE_REPORT_MESSAGE_TYPE,
|
|
44
|
+
content: formatReports([report]),
|
|
45
|
+
display: true,
|
|
46
|
+
details: report,
|
|
47
|
+
}, { triggerTurn: true, deliverAs: "steer" });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (leaderCtx) {
|
|
50
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
51
|
+
notifyPi(leaderCtx.ui, `Agent Teams report delivery failed: ${detail}. The report remains in /agent-teams.`, "warning");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
38
54
|
}
|
|
39
55
|
|
|
40
56
|
export function hasActiveTeamState(): boolean {
|
|
41
57
|
return livingTeammates().length > 0 || listTasks().length > 0;
|
|
42
58
|
}
|
|
43
59
|
|
|
44
|
-
function extractHarnessReport(details: unknown):
|
|
45
|
-
const report = details as
|
|
60
|
+
function extractHarnessReport(details: unknown): LeaderReport | undefined {
|
|
61
|
+
const report = details as LeaderReport | undefined;
|
|
46
62
|
return report?.origin === "harness" || report?.harnessEvent ? report : undefined;
|
|
47
63
|
}
|
|
48
64
|
|
|
@@ -63,135 +79,137 @@ export default function (pi: ExtensionAPI) {
|
|
|
63
79
|
}
|
|
64
80
|
|
|
65
81
|
leaderPi = pi;
|
|
82
|
+
const deliveries = new WeakMap<object, LeaderReport[]>();
|
|
66
83
|
pi.registerEntryRenderer(TEAMMATE_FINISHED_ENTRY_TYPE, (entry, _options, theme) => {
|
|
67
84
|
const data = entry.data as { teammate?: string; agent?: string } | undefined;
|
|
68
85
|
const name = data?.teammate ?? data?.agent ?? "teammate";
|
|
69
|
-
return new Text(theme.fg("success", `
|
|
70
|
-
});
|
|
71
|
-
pi.registerMessageRenderer(TEAMMATE_HEALTH_MESSAGE_TYPE, (message, { expanded }, theme) => {
|
|
72
|
-
const healthReport = extractHealthReport(message.details);
|
|
73
|
-
const health = healthReport?.health;
|
|
74
|
-
if (!health || !healthReport) return new Text(String(message.content), 0, 0);
|
|
75
|
-
const subject = `@${healthReport.teammate} ${health.state} · silent ${formatSilenceDuration(health.silenceMs)}`;
|
|
76
|
-
return createStaticToolLifecycleMessageRenderer({
|
|
77
|
-
createSpec: () => eventToolLifecycle("agent", subject, {
|
|
78
|
-
details: healthReport.body.split("\n").filter((line) => line.trim()),
|
|
79
|
-
}),
|
|
80
|
-
expandHint: keyHint("app.tools.expand", "to expand"),
|
|
81
|
-
fit: truncateToWidth,
|
|
82
|
-
visibleWidth,
|
|
83
|
-
})(message, { expanded }, theme);
|
|
86
|
+
return new Text(theme.fg("success", `Assignment for @${name} finished.`), 0, 0);
|
|
84
87
|
});
|
|
85
88
|
pi.registerMessageRenderer(TEAMMATE_HARNESS_MESSAGE_TYPE, (message, { expanded }, theme) => {
|
|
86
89
|
const report = extractHarnessReport(message.details);
|
|
87
90
|
if (!report) return new Text(String(message.content), 0, 0);
|
|
88
91
|
const event = report.harnessEvent;
|
|
89
|
-
return
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
visibleWidth,
|
|
94
|
-
})(message, { expanded }, theme);
|
|
92
|
+
return harnessRows.message(
|
|
93
|
+
() => eventToolLifecycle("agent", plainText(event?.subject ?? "Agent Teams event")),
|
|
94
|
+
{ hostComponent: ToolExecutionComponent, ui: leaderCtx?.ui, cwd: leaderCtx?.cwd },
|
|
95
|
+
)(message, { expanded }, theme);
|
|
95
96
|
});
|
|
96
97
|
pi.registerMessageRenderer(TEAMMATE_REPORT_MESSAGE_TYPE, (message, { expanded, outputPad }, theme) => {
|
|
97
|
-
const reports = extractReports(message.details);
|
|
98
|
-
const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
|
|
98
|
+
const reports = deliveries.get(message) ?? extractReports(message.details);
|
|
99
99
|
if (reports.length === 0) {
|
|
100
|
+
const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
|
|
100
101
|
box.addChild(new Markdown(String(message.content), 0, 0, getMarkdownTheme()));
|
|
101
102
|
return box;
|
|
102
103
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
104
|
+
|
|
105
|
+
return createToolExecutionWrapper(
|
|
106
|
+
(opt, currentTheme) => {
|
|
107
|
+
const effTheme = currentTheme ?? theme;
|
|
108
|
+
if (!opt.expanded) {
|
|
109
|
+
const groups = groupReportsByTeammate(reports);
|
|
110
|
+
const band = renderAgentMessageBand(
|
|
111
|
+
groups.map((group) => ({ direction: "from", teammate: group.teammate, count: group.reports.length })),
|
|
112
|
+
{ theme: effTheme, fit: truncateToWidth, visibleWidth, wrapDetail: wrapTextWithAnsi, expandHint: keyHint("app.tools.expand", "to expand") },
|
|
113
|
+
);
|
|
114
|
+
if (!reports.some((report) => report.deliveredAfterStop)) return band;
|
|
115
|
+
const box = new Box(0, 0);
|
|
116
|
+
box.addChild(band);
|
|
117
|
+
box.addChild(new Text(effTheme.fg("warning", "Late delivery: process already stopped."), 0, 0));
|
|
118
|
+
return box;
|
|
119
|
+
}
|
|
120
|
+
const box = new Box(outputPad, 1, (text) => effTheme.bg("customMessageBg", text));
|
|
121
|
+
for (const [index, report] of reports.entries()) {
|
|
122
|
+
const teammate = report.teammate ?? report.agent ?? "teammate";
|
|
123
|
+
const prefix = effTheme.fg("customMessageLabel", effTheme.bold(formatAgentMessagePrefix("from")));
|
|
124
|
+
const name = effTheme.fg(agentColor(teammate), `@${teammate}`);
|
|
125
|
+
const delivery = report.deliveredAfterStop ? effTheme.fg("warning", " (late delivery; process stopped)") : "";
|
|
126
|
+
box.addChild(new Text(`${prefix}${name}${delivery}`, 0, 0));
|
|
127
|
+
box.addChild(new Markdown(report.body, 0, 0, getMarkdownTheme(), {
|
|
128
|
+
color: (text) => effTheme.fg("customMessageText", text),
|
|
129
|
+
}));
|
|
130
|
+
if (index < reports.length - 1) box.addChild(new Text("", 0, 0));
|
|
131
|
+
}
|
|
132
|
+
return box;
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
hostComponent: ToolExecutionComponent,
|
|
136
|
+
toolName: "message",
|
|
137
|
+
expanded,
|
|
138
|
+
ui: leaderCtx?.ui,
|
|
139
|
+
cwd: leaderCtx?.cwd,
|
|
140
|
+
},
|
|
141
|
+
theme,
|
|
142
|
+
);
|
|
121
143
|
});
|
|
122
144
|
registerLeaderTools(pi);
|
|
123
145
|
registerTeamCommand(pi);
|
|
124
146
|
|
|
147
|
+
pi.on("message_start", (event) => {
|
|
148
|
+
if (event.message.role !== "custom" || event.message.customType !== TEAMMATE_REPORT_MESSAGE_TYPE) return;
|
|
149
|
+
const deliveredAt = Date.now();
|
|
150
|
+
deliveries.set(event.message, extractReports(event.message.details).map((report) => annotateReportDelivery(
|
|
151
|
+
report, deliveredAt, report.spawnId ? getConfirmedStopTime(report.spawnId) : undefined,
|
|
152
|
+
)));
|
|
153
|
+
});
|
|
154
|
+
|
|
125
155
|
pi.on("message_end", async (event) => {
|
|
126
156
|
if (event.message.role !== "custom" || event.message.customType !== TEAMMATE_REPORT_MESSAGE_TYPE) return;
|
|
127
|
-
const
|
|
157
|
+
const deliveredAt = Date.now();
|
|
158
|
+
const reports = deliveries.get(event.message) ?? extractReports(event.message.details).map((report) => annotateReportDelivery(
|
|
159
|
+
report, deliveredAt, report.spawnId ? getConfirmedStopTime(report.spawnId) : undefined,
|
|
160
|
+
));
|
|
161
|
+
if (reports.length === 0) return;
|
|
128
162
|
for (const report of reports) {
|
|
129
163
|
if (!markTeammateFinished(report)) continue;
|
|
130
164
|
pi.appendEntry(TEAMMATE_FINISHED_ENTRY_TYPE, {
|
|
131
165
|
teammate: report.teammate ?? report.agent,
|
|
132
166
|
agent: report.agent,
|
|
167
|
+
assignmentId: report.assignmentId,
|
|
168
|
+
workId: report.workId,
|
|
169
|
+
spawnId: report.spawnId,
|
|
133
170
|
});
|
|
134
171
|
}
|
|
172
|
+
return {
|
|
173
|
+
message: { ...event.message, content: formatReports(reports), details: reports.length === 1 ? reports[0] : { reports } },
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
178
|
+
leaderCtx = ctx;
|
|
179
|
+
syncLeaderContext(ctx);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
pi.on("thinking_level_select", async (_event, ctx) => {
|
|
183
|
+
leaderCtx = ctx;
|
|
184
|
+
syncLeaderContext(ctx);
|
|
135
185
|
});
|
|
136
186
|
|
|
137
187
|
pi.on("session_start", async (_event, ctx) => {
|
|
138
188
|
clearSessionAgents();
|
|
139
189
|
resetState();
|
|
140
|
-
refreshLeaderToolDisclosure();
|
|
141
|
-
followUpQueue?.reset();
|
|
142
190
|
leaderCtx = ctx;
|
|
143
|
-
followUpQueue = new FollowUpQueue({
|
|
144
|
-
isIdle: () => Boolean(leaderCtx?.isIdle()),
|
|
145
|
-
prepareOnDispatch: true,
|
|
146
|
-
dispatch: (reports, content) => leaderPi?.sendMessage({
|
|
147
|
-
customType: reports.length === 1 && reports[0]?.health
|
|
148
|
-
? TEAMMATE_HEALTH_MESSAGE_TYPE
|
|
149
|
-
: reports.length === 1 && (reports[0]?.origin === "harness" || reports[0]?.harnessEvent)
|
|
150
|
-
? TEAMMATE_HARNESS_MESSAGE_TYPE
|
|
151
|
-
: TEAMMATE_REPORT_MESSAGE_TYPE,
|
|
152
|
-
// Health remains a compact diagnostic for its dedicated renderer;
|
|
153
|
-
// other harness events retain their explicit envelope in model context.
|
|
154
|
-
content: reports.length === 1 && reports[0]?.health ? reports[0].body : content,
|
|
155
|
-
display: true,
|
|
156
|
-
details: reports.length === 1 ? reports[0] : { reports },
|
|
157
|
-
}, { triggerTurn: true, deliverAs: "followUp" }),
|
|
158
|
-
onFailure: (message) => {
|
|
159
|
-
if (leaderCtx) notifyPi(leaderCtx.ui, message, "warning");
|
|
160
|
-
},
|
|
161
|
-
});
|
|
162
191
|
ensureTeamWidget(ctx);
|
|
163
192
|
initTeamMachine(ctx, {
|
|
164
|
-
sendUpdate:
|
|
165
|
-
archiveQueuedReports: (spawnId) => followUpQueue?.archiveSpawn(spawnId) ?? [],
|
|
193
|
+
sendUpdate: sendLeaderReport,
|
|
166
194
|
notifyChange: () => {
|
|
167
195
|
refreshTeamUI(leaderCtx);
|
|
168
|
-
refreshLeaderToolDisclosure();
|
|
169
196
|
},
|
|
170
197
|
});
|
|
171
198
|
clearPiStatus(ctx.ui, "teammate");
|
|
172
199
|
refreshTeamUI(ctx);
|
|
173
|
-
refreshLeaderToolDisclosure();
|
|
174
200
|
void cleanupExpiredStateDirs(STATE_DIR_MAX_AGE_MS);
|
|
175
201
|
});
|
|
176
202
|
|
|
177
203
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
178
|
-
|
|
204
|
+
leaderCtx = ctx;
|
|
179
205
|
const teamIsActive = hasActiveTeamState();
|
|
180
206
|
return {
|
|
181
207
|
systemPrompt: event.systemPrompt + (teamIsActive
|
|
182
208
|
? buildTeamLeaderGuidance(ctx?.cwd ?? process.cwd())
|
|
183
|
-
:
|
|
209
|
+
: buildIdleLeaderGuidance(ctx?.cwd ?? process.cwd())),
|
|
184
210
|
};
|
|
185
211
|
});
|
|
186
212
|
|
|
187
|
-
pi.on("agent_start", async () => {
|
|
188
|
-
followUpQueue?.onAgentStart();
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
pi.on("agent_settled", async () => {
|
|
192
|
-
followUpQueue?.onAgentSettled();
|
|
193
|
-
});
|
|
194
|
-
|
|
195
213
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
196
214
|
stopUiTimers();
|
|
197
215
|
const diagnostics = await teardownTeammates();
|
|
@@ -200,18 +218,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
200
218
|
}
|
|
201
219
|
shutdownTeamMachine();
|
|
202
220
|
removeRuntimeDir(ctx);
|
|
203
|
-
followUpQueue?.reset();
|
|
204
|
-
followUpQueue = undefined;
|
|
205
221
|
leaderPi = undefined;
|
|
206
222
|
leaderCtx = undefined;
|
|
207
223
|
resetState();
|
|
208
|
-
refreshLeaderToolDisclosure();
|
|
209
224
|
});
|
|
210
225
|
}
|
|
211
226
|
|
|
212
|
-
function extractReports(details: unknown):
|
|
213
|
-
|
|
227
|
+
function extractReports(details: unknown): LeaderReport[] {
|
|
228
|
+
if (Array.isArray(details)) return details as LeaderReport[];
|
|
229
|
+
const typed = details as LeaderReport | { reports?: LeaderReport[] } | undefined;
|
|
214
230
|
if (typed && "reports" in typed && Array.isArray(typed.reports)) return typed.reports;
|
|
215
|
-
if (typed && "teammate" in typed) return [typed as
|
|
231
|
+
if (typed && "teammate" in typed) return [typed as LeaderReport];
|
|
216
232
|
return [];
|
|
217
233
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export const TEAMMATE_REPORT_MESSAGE_TYPE = "agent-teams-report";
|
|
2
|
+
export const TEAMMATE_HARNESS_MESSAGE_TYPE = "agent-teams-harness";
|
|
3
|
+
|
|
4
|
+
export interface LeaderReport {
|
|
5
|
+
teammate?: string;
|
|
6
|
+
agent?: string;
|
|
7
|
+
spawnId?: string;
|
|
8
|
+
assignmentId?: string;
|
|
9
|
+
workId?: string;
|
|
10
|
+
body: string;
|
|
11
|
+
/** Worker-authored reports are the default; harness events use a separate envelope. */
|
|
12
|
+
origin?: "teammate" | "harness";
|
|
13
|
+
harnessEvent?: {
|
|
14
|
+
type: string;
|
|
15
|
+
subject: string;
|
|
16
|
+
};
|
|
17
|
+
finished?: boolean;
|
|
18
|
+
/** Original append-only outbox event identifier, retained for session forensics. */
|
|
19
|
+
eventId?: string;
|
|
20
|
+
/** Original worker status, including omitted status, retained for session forensics. */
|
|
21
|
+
status?: "in_progress" | "completed" | "failed" | "inform" | "request" | "handoff";
|
|
22
|
+
runId?: string;
|
|
23
|
+
/** Wall-clock time the message was authored, not when Pi consumed it. */
|
|
24
|
+
timestamp?: number;
|
|
25
|
+
deliveredAt?: number;
|
|
26
|
+
processStoppedAt?: number;
|
|
27
|
+
deliveredAfterStop?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LeaderReportGroup {
|
|
31
|
+
teammate: string;
|
|
32
|
+
reports: LeaderReport[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function groupReportsByTeammate(reports: LeaderReport[]): LeaderReportGroup[] {
|
|
36
|
+
const groups = new Map<string, LeaderReportGroup>();
|
|
37
|
+
for (const report of reports) {
|
|
38
|
+
const teammate = report.teammate ?? report.agent ?? "teammate";
|
|
39
|
+
if (report.origin === "harness" || report.harnessEvent) {
|
|
40
|
+
groups.set(`harness:${groups.size}:${teammate}`, { teammate, reports: [report] });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const key = `message:${teammate}`;
|
|
44
|
+
const group = groups.get(key);
|
|
45
|
+
if (group) {
|
|
46
|
+
group.reports.push(report);
|
|
47
|
+
} else {
|
|
48
|
+
groups.set(key, { teammate, reports: [report] });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return [...groups.values()];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function annotateReportDelivery(report: LeaderReport, deliveredAt: number, processStoppedAt?: number): LeaderReport {
|
|
55
|
+
if (processStoppedAt === undefined || deliveredAt < processStoppedAt) return { ...report, deliveredAt };
|
|
56
|
+
return { ...report, deliveredAt, processStoppedAt, deliveredAfterStop: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function timestampAttribute(name: string, timestamp: number | undefined): string {
|
|
60
|
+
return timestamp !== undefined && Number.isFinite(timestamp)
|
|
61
|
+
? ` ${name}="${new Date(timestamp).toISOString()}"`
|
|
62
|
+
: "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function formatReports(reports: LeaderReport[]): string {
|
|
66
|
+
return reports
|
|
67
|
+
.map((report) => {
|
|
68
|
+
const { teammate, body, timestamp, origin, harnessEvent } = report;
|
|
69
|
+
const at = timestampAttribute("at", timestamp);
|
|
70
|
+
if (origin === "harness" || harnessEvent) {
|
|
71
|
+
const type = escapeAttribute(harnessEvent?.type ?? "event");
|
|
72
|
+
const subject = escapeAttribute(harnessEvent?.subject ?? "Agent Teams event");
|
|
73
|
+
return `<harness-event type="${type}" subject="${subject}"${at}>\n${body}\n</harness-event>`;
|
|
74
|
+
}
|
|
75
|
+
const name = teammate ?? "teammate";
|
|
76
|
+
const delivery = timestampAttribute("delivered-at", report.deliveredAt);
|
|
77
|
+
const stopped = timestampAttribute("process-stopped-at", report.processStoppedAt);
|
|
78
|
+
const late = report.deliveredAfterStop ? ' delivery="after-stop"' : "";
|
|
79
|
+
const work = report.workId ? ` work="${escapeAttribute(report.workId)}"` : "";
|
|
80
|
+
const assignment = report.assignmentId ? ` assignment="${escapeAttribute(report.assignmentId)}"` : "";
|
|
81
|
+
const status = report.status ? ` status="${escapeAttribute(report.status)}"` : "";
|
|
82
|
+
const note = report.deliveredAfterStop
|
|
83
|
+
? "Delivery note: this report was queued before shutdown and delivered after the process stopped. It does not reopen the assignment.\n\n"
|
|
84
|
+
: "";
|
|
85
|
+
return `<agent-message from="${escapeAttribute(name)}"${at}${delivery}${stopped}${late}${work}${assignment}${status}>\n${note}${body}\n</agent-message>`;
|
|
86
|
+
})
|
|
87
|
+
.join("\n\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function escapeAttribute(value: string): string {
|
|
91
|
+
return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
92
|
+
}
|
package/src/recipient.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Teammate } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export function sessionRoute(name: string): string {
|
|
4
|
+
return `session:${name}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function exactSessionRoute(name: string, spawnId: string): string {
|
|
8
|
+
return `session:${name}:${spawnId}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseExactSessionRoute(route: string): { name: string; spawnId: string } | undefined {
|
|
12
|
+
const match = /^session:([^:]+):([^:]+)$/.exec(route);
|
|
13
|
+
return match ? { name: match[1], spawnId: match[2] } : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resolveExactSession(route: string, roster: readonly Teammate[]): Teammate | undefined {
|
|
17
|
+
const parsed = parseExactSessionRoute(route);
|
|
18
|
+
if (!parsed) return undefined;
|
|
19
|
+
return roster.find((teammate) => teammate.name === parsed.name && teammate.spawnId === parsed.spawnId && teammate.status !== "stopped");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Resolve a model-facing recipient route to its live roster entry. The entry's
|
|
23
|
+
* spawn id travels with the message so a later replacement resident of the same
|
|
24
|
+
* name cannot consume mail addressed to its predecessor. */
|
|
25
|
+
export function resolveRecipient(
|
|
26
|
+
to: string,
|
|
27
|
+
roster: ReadonlyArray<{ name: string; agent: string; status: string; spawnId?: string }>,
|
|
28
|
+
): { name: string; spawnId?: string } {
|
|
29
|
+
const exact = parseExactSessionRoute(to);
|
|
30
|
+
if (exact) {
|
|
31
|
+
const matched = roster.find((entry) => entry.name === exact.name && entry.spawnId === exact.spawnId && entry.status !== "stopped");
|
|
32
|
+
if (matched) return { name: matched.name, spawnId: matched.spawnId };
|
|
33
|
+
throw new Error(`No living session named "${to}".`);
|
|
34
|
+
}
|
|
35
|
+
const explicit = to.startsWith("session:");
|
|
36
|
+
const name = explicit ? to.slice("session:".length) : to;
|
|
37
|
+
const living = roster.filter((entry) => entry.status !== "stopped");
|
|
38
|
+
const recipients = living.filter((entry) => entry.name === name || (!explicit && entry.agent === to));
|
|
39
|
+
if (recipients.length > 1) {
|
|
40
|
+
throw new Error(`Ambiguous Agent @${to}. Use a precise route: ${recipients.map((entry) => entry.spawnId ? exactSessionRoute(entry.name, entry.spawnId) : sessionRoute(entry.name)).join(", ")}.`);
|
|
41
|
+
}
|
|
42
|
+
if (recipients.length === 1) return { name: recipients[0].name, spawnId: recipients[0].spawnId };
|
|
43
|
+
throw new Error(`No living teammate named "${to}".`);
|
|
44
|
+
}
|