@astrofoundry/pi-astro 0.22.2 ā 0.22.4
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.
|
@@ -7,6 +7,8 @@ import { chunkMessage, fenceState } from "./chunk.ts";
|
|
|
7
7
|
import { helpText, parseCommand } from "./commands.ts";
|
|
8
8
|
import { allowedSpecialists, canUse, loadConfig, loadToken, validateConfig } from "./config.ts";
|
|
9
9
|
import { INTENTS, initialState, reduce } from "./gateway.ts";
|
|
10
|
+
import type { RunResult } from "../astro-subagents/child.ts";
|
|
11
|
+
import { failureReply } from "./index.ts";
|
|
10
12
|
import { DiscordRest } from "./rest.ts";
|
|
11
13
|
|
|
12
14
|
const ID = "123456789012345678";
|
|
@@ -147,6 +149,17 @@ describe("rest", () => {
|
|
|
147
149
|
});
|
|
148
150
|
});
|
|
149
151
|
|
|
152
|
+
describe("failure reply", () => {
|
|
153
|
+
it("names the reason and keeps stderr as an attachment", () => {
|
|
154
|
+
const base: RunResult = { agent: "astro.dns", agentSource: "bundled", task: "t", exitCode: 1, messages: [], stderr: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 } };
|
|
155
|
+
expect(failureReply({ ...base, errorMessage: "timed out after 30 min" })).toEqual({ text: "timed out after 30 min" });
|
|
156
|
+
expect(failureReply({ ...base, stderr: "noise\n" })).toEqual({ text: "the specialist process exited with code 1", attachment: "noise" });
|
|
157
|
+
expect(failureReply({ ...base, stopReason: "aborted" as RunResult["stopReason"] }).text).toBe("the run was stopped");
|
|
158
|
+
const withWords = failureReply({ ...base, messages: [{ role: "assistant", content: [{ type: "text", text: "I asked for approval." }] } as never] });
|
|
159
|
+
expect(withWords.text).toContain("Last words of the specialist:\nI asked for approval.");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
150
163
|
describe("approval server", () => {
|
|
151
164
|
it("creates tickets, reports decisions, expires, and rejects bad tokens", async () => {
|
|
152
165
|
const tickets: Ticket[] = [];
|
|
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { loadConfig as loadSpecialists } from "../specialist-gate/config.ts";
|
|
3
3
|
import { APPROVAL_TOKEN_ENV, APPROVAL_URL_ENV } from "../specialist-gate/index.ts";
|
|
4
4
|
import { type AgentConfig, defaultDirs, discoverAgents, resolveSkills } from "../astro-subagents/agents.ts";
|
|
5
|
-
import { type DispatchDefaults, childRemaining, currentDepth,
|
|
5
|
+
import { AGENT_ENV, type DispatchDefaults, type RunResult, childRemaining, currentDepth, finalOutput, isFailed, runAgent } from "../astro-subagents/child.ts";
|
|
6
6
|
import { ApprovalServer, type Ticket } from "./approvals.ts";
|
|
7
7
|
import { chunkMessage } from "./chunk.ts";
|
|
8
8
|
import { CHANNEL_RULES, helpText, parseCommand } from "./commands.ts";
|
|
@@ -13,6 +13,40 @@ import { DiscordRest, type MessageComponent } from "./rest.ts";
|
|
|
13
13
|
const REACTION = { running: "ā³", waiting: "š", done: "ā
", failed: "ā" } as const;
|
|
14
14
|
/** Answers longer than this go as a file attachment with a short summary. */
|
|
15
15
|
const ATTACHMENT_THRESHOLD = 6000;
|
|
16
|
+
/** Upper bound for one specialist run started from chat; the wrappers' own timeouts are shorter. */
|
|
17
|
+
const TASK_TIMEOUT_MINUTES = 30;
|
|
18
|
+
|
|
19
|
+
interface ToolCallPart {
|
|
20
|
+
type?: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
arguments?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One log line per new message: tool calls with their arguments, tool results, and assistant text lengths. */
|
|
26
|
+
function describeProgress(result: RunResult, seen: number): { lines: string[]; seen: number } {
|
|
27
|
+
const lines: string[] = [];
|
|
28
|
+
for (const message of result.messages.slice(seen)) {
|
|
29
|
+
if (message.role === "assistant") {
|
|
30
|
+
for (const part of message.content as ToolCallPart[]) {
|
|
31
|
+
if (part.type === "toolCall") lines.push(`tool ${part.name ?? "?"} ${JSON.stringify(part.arguments ?? {}).slice(0, 300)}`);
|
|
32
|
+
else if (part.type === "text") lines.push(`assistant text ${((part as { text?: string }).text ?? "").length} chars`);
|
|
33
|
+
}
|
|
34
|
+
} else if (message.role === "toolResult") {
|
|
35
|
+
const { toolName, isError } = message as { toolName?: string; isError?: boolean };
|
|
36
|
+
lines.push(`result ${toolName ?? "?"}${isError ? " (error)" : ""}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { lines, seen: result.messages.length };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Reply for a failed run: the reason in one line, the specialist's last words if any, raw stderr only as an attachment. */
|
|
43
|
+
export function failureReply(result: RunResult): { text: string; attachment?: string } {
|
|
44
|
+
const reason = result.errorMessage?.trim() || (result.stopReason === "aborted" ? "the run was stopped" : `the specialist process exited with code ${result.exitCode}`);
|
|
45
|
+
const lastWords = finalOutput(result.messages).trim();
|
|
46
|
+
const text = lastWords.length > 0 ? `${reason}\n\nLast words of the specialist:\n${lastWords}` : reason;
|
|
47
|
+
const stderr = result.stderr.trim();
|
|
48
|
+
return stderr.length > 0 ? { text, attachment: stderr } : { text };
|
|
49
|
+
}
|
|
16
50
|
|
|
17
51
|
interface IncomingMessage {
|
|
18
52
|
id: string;
|
|
@@ -162,21 +196,35 @@ class Bridge {
|
|
|
162
196
|
const { depth, remaining } = currentDepth();
|
|
163
197
|
let failed = true;
|
|
164
198
|
let output: string;
|
|
199
|
+
let attachment: string | undefined;
|
|
200
|
+
let seen = 0;
|
|
165
201
|
try {
|
|
166
202
|
const result = await runAgent({
|
|
167
|
-
agent,
|
|
203
|
+
agent: { ...agent, timeoutMinutes: agent.timeoutMinutes ?? TASK_TIMEOUT_MINUTES },
|
|
168
204
|
skills,
|
|
169
205
|
task: taskText,
|
|
170
206
|
cwd: process.cwd(),
|
|
171
207
|
depth,
|
|
172
208
|
remaining: childRemaining(remaining, agent),
|
|
173
209
|
defaults,
|
|
174
|
-
|
|
210
|
+
// The child must not become a second Discord bridge.
|
|
211
|
+
env: { [ACTIVATION_ENV]: "0", [APPROVAL_URL_ENV]: this.approvals.url, [APPROVAL_TOKEN_ENV]: this.approvals.token, ASTRO_CHANNEL: "discord" },
|
|
175
212
|
extraSystemPrompt: CHANNEL_RULES,
|
|
213
|
+
onUpdate: (partial) => {
|
|
214
|
+
const progress = describeProgress(partial, seen);
|
|
215
|
+
seen = progress.seen;
|
|
216
|
+
for (const line of progress.lines) log(`astro.${task.specialist}: ${line}`);
|
|
217
|
+
},
|
|
176
218
|
});
|
|
177
219
|
failed = isFailed(result);
|
|
178
|
-
|
|
179
|
-
|
|
220
|
+
if (failed) {
|
|
221
|
+
const reply = failureReply(result);
|
|
222
|
+
output = reply.text;
|
|
223
|
+
attachment = reply.attachment;
|
|
224
|
+
} else {
|
|
225
|
+
output = finalOutput(result.messages) || "(no output)";
|
|
226
|
+
}
|
|
227
|
+
log(`astro.${task.specialist} ${failed ? "failed" : "done"} in ${Math.round((Date.now() - task.startedAt) / 1000)} s, ${result.usage.turns} turns, model spend ${result.usage.cost.toFixed(4)} USD`);
|
|
180
228
|
} catch (err) {
|
|
181
229
|
output = err instanceof Error ? err.message : String(err);
|
|
182
230
|
} finally {
|
|
@@ -184,10 +232,14 @@ class Bridge {
|
|
|
184
232
|
}
|
|
185
233
|
await this.rest.removeOwnReaction(task.channelId, task.messageId, REACTION.running).catch(() => undefined);
|
|
186
234
|
await this.rest.addReaction(task.channelId, task.messageId, failed ? REACTION.failed : REACTION.done).catch(() => undefined);
|
|
187
|
-
await this.post(task, `${failed ? "ā" : "ā
"} **${task.specialist}**\n${output}
|
|
235
|
+
await this.post(task, `${failed ? "ā" : "ā
"} **${task.specialist}**\n${output}`, attachment);
|
|
188
236
|
}
|
|
189
237
|
|
|
190
|
-
private async post(task: ActiveTask, text: string): Promise<void> {
|
|
238
|
+
private async post(task: ActiveTask, text: string, attachment?: string): Promise<void> {
|
|
239
|
+
if (attachment !== undefined) {
|
|
240
|
+
await this.rest.createMessage(task.channelId, { content: text.slice(0, 1900), replyTo: task.messageId, file: { name: `astro-${task.specialist}-stderr-${Date.now()}.txt`, content: attachment } });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
191
243
|
if (text.length > ATTACHMENT_THRESHOLD) {
|
|
192
244
|
const summary = `${text.slice(0, 1200).trim()}\n⦠full answer attached (${text.length} characters).`;
|
|
193
245
|
await this.rest.createMessage(task.channelId, { content: summary, replyTo: task.messageId, file: { name: `astro-${task.specialist}-${Date.now()}.md`, content: text } });
|
|
@@ -252,7 +304,7 @@ class Bridge {
|
|
|
252
304
|
* the headless LaunchAgent host sets; interactive sessions never connect.
|
|
253
305
|
*/
|
|
254
306
|
export default function astroDiscord(pi: ExtensionAPI): void {
|
|
255
|
-
if (process.env[ACTIVATION_ENV] !== "1") return;
|
|
307
|
+
if (process.env[ACTIVATION_ENV] !== "1" || process.env[AGENT_ENV]) return;
|
|
256
308
|
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
257
309
|
const loaded = loadConfig();
|
|
258
310
|
if (!loaded.config) {
|
|
@@ -30,11 +30,12 @@ class MutableOutput extends Writable {
|
|
|
30
30
|
async function main(): Promise<void> {
|
|
31
31
|
const output = new MutableOutput();
|
|
32
32
|
const rl = createInterface({ input: process.stdin, output, terminal: true });
|
|
33
|
+
// readline redraws the prompt line on every keystroke, so the question must be its prompt, not a bare stdout write.
|
|
33
34
|
const ask = async (question: string, hidden = false): Promise<string> => {
|
|
34
35
|
output.muted = false;
|
|
35
|
-
|
|
36
|
+
const pending = rl.question(question);
|
|
36
37
|
output.muted = hidden;
|
|
37
|
-
const answer = await
|
|
38
|
+
const answer = await pending;
|
|
38
39
|
output.muted = false;
|
|
39
40
|
if (hidden) process.stdout.write("\n");
|
|
40
41
|
return answer.trim();
|