symbio 0.3.0 → 0.5.0
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/CHANGELOG.md +35 -0
- package/README.md +14 -4
- package/dist/server/api.js +47 -1
- package/dist/server/docs.js +49 -2
- package/dist/server/guard.js +21 -0
- package/dist/server/images.js +54 -0
- package/dist/server/index.js +3 -0
- package/dist/server/mcp.js +2 -2
- package/dist/server/opencode/client.js +1 -0
- package/dist/server/prompts.js +93 -7
- package/dist/server/rules.js +191 -0
- package/dist/server/store.js +5 -0
- package/dist/server/team.js +240 -42
- package/dist/server/tools.js +126 -7
- package/dist/server/updates.js +34 -0
- package/dist/server/workspaces.js +43 -5
- package/dist/web/assets/{index-BM4oACfZ.css → index-CtcmbMmw.css} +1 -1
- package/dist/web/assets/index-yZ8d_EGH.js +53 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/index-CrEYaqhD.js +0 -52
package/dist/server/team.js
CHANGED
|
@@ -5,7 +5,7 @@ import { randomBytes } from "node:crypto";
|
|
|
5
5
|
import { promises as fs } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { AssetRegister } from "./assets.js";
|
|
8
|
-
import { AGENT_PERMISSIONS, wildcardMatch } from "./guard.js";
|
|
8
|
+
import { AGENT_PERMISSIONS, imageReadRefusal, wildcardMatch } from "./guard.js";
|
|
9
9
|
import { bus } from "./bus.js";
|
|
10
10
|
import { privateDocsInstruction } from "./docs.js";
|
|
11
11
|
import { parseModel } from "./opencode/client.js";
|
|
@@ -17,7 +17,7 @@ import { accountChildUsage, accountUsage, emptyUsage, sessionUsage } from "./cos
|
|
|
17
17
|
import { AGENT_UPDATES, unseenUpdates, updatesNote } from "./updates.js";
|
|
18
18
|
import { cleanBuildOutputs, dailyBackup, gb, helperFolders, lastBackup, unpushedWork } from "./housekeeping.js";
|
|
19
19
|
import { coordinatorTools, workerTools } from "./tools.js";
|
|
20
|
-
import { changedFiles, createWorkspace, mergeWorkspace, removeWorkspace, restoreWorkspace, syncWorkspace } from "./workspaces.js";
|
|
20
|
+
import { changedFiles, createWorkspace, inProjectFolder, isGitRepo, leaveWorkspace, mergeWorkspace, reattachWorkspace, removeWorkspace, restoreWorkspace, syncWorkspace } from "./workspaces.js";
|
|
21
21
|
const STUCK_AFTER_MS = Number(process.env.SYMBIO_STUCK_MINUTES ?? 10) * 60_000;
|
|
22
22
|
const WATCHDOG_INTERVAL_MS = 30_000;
|
|
23
23
|
// Loop guard: max conversational messages between the same pair in the window (per
|
|
@@ -85,6 +85,8 @@ export class Team {
|
|
|
85
85
|
limitNoticed = new Map(); // pair → when the human was last told
|
|
86
86
|
failedModels = new Map(); // agentId → model → failedAt
|
|
87
87
|
fallbackBusy = new Set();
|
|
88
|
+
/** When each agent last got a fresh session because its conversation was too large to send. */
|
|
89
|
+
oversizedAt = new Map();
|
|
88
90
|
/** agentId → tool call id of an open interactive question. */
|
|
89
91
|
pendingQuestion = new Map();
|
|
90
92
|
/** Connection actions (SSH, git, GitHub API) and their approvals. Set at startup. */
|
|
@@ -251,6 +253,41 @@ export class Team {
|
|
|
251
253
|
return true;
|
|
252
254
|
return project.agents.some((a) => a !== except && (a.name === name || a.aliases?.includes(name)));
|
|
253
255
|
}
|
|
256
|
+
/** The human chose where new helpers work; core's brief describes it, so it's told too. */
|
|
257
|
+
async setHelperWorkspace(project, mode) {
|
|
258
|
+
if ((project.helperWorkspace ?? "own") === mode)
|
|
259
|
+
return;
|
|
260
|
+
project.helperWorkspace = mode;
|
|
261
|
+
await this.store.update(project);
|
|
262
|
+
const coordinator = this.coordinator(project);
|
|
263
|
+
if (!coordinator || coordinator.status === "stopped")
|
|
264
|
+
return;
|
|
265
|
+
await this.refreshInstructions(project, coordinator).catch(() => { });
|
|
266
|
+
await this.system(project, coordinator, mode === "shared"
|
|
267
|
+
? "Symbio: the human set this project's helpers to work in the project folder itself. New helpers work there by default " +
|
|
268
|
+
"(what they write is there at once; nothing to merge). Helpers that already have their own copy keep it: when one is idle " +
|
|
269
|
+
"and its work merged, move it with move_to_project_folder if it should share the folder too."
|
|
270
|
+
: "Symbio: the human set this project's helpers to work in their own copy (own branch) by default. Helpers already in the " +
|
|
271
|
+
"project folder stay there.", false);
|
|
272
|
+
}
|
|
273
|
+
/** The human turned the rules register on or off; every brief mentions it, and core is told. */
|
|
274
|
+
async setRulesRegister(project, on) {
|
|
275
|
+
if (!!project.rulesRegister === on)
|
|
276
|
+
return;
|
|
277
|
+
project.rulesRegister = on;
|
|
278
|
+
await this.store.update(project);
|
|
279
|
+
bus.publish({ type: "project.updated", projectId: project.id });
|
|
280
|
+
for (const agent of project.agents.filter(active))
|
|
281
|
+
await this.refreshInstructions(project, agent).catch(() => { });
|
|
282
|
+
const coordinator = this.coordinator(project);
|
|
283
|
+
if (!coordinator || coordinator.status === "stopped")
|
|
284
|
+
return;
|
|
285
|
+
await this.system(project, coordinator, on
|
|
286
|
+
? "Symbio: the human turned on the rules register for this project. Your instructions now explain it (save_rule, list_rules…). " +
|
|
287
|
+
"Record rules as they come up in the work; if the project already has rules written down somewhere, offer the human to " +
|
|
288
|
+
"bring them into the register as a planned task."
|
|
289
|
+
: "Symbio: the human turned off the rules register for this project. Stop recording rules; what's recorded stays in the file.", false);
|
|
290
|
+
}
|
|
254
291
|
/** Renames a helper. Its branch and folder keep their names; the old name keeps working. */
|
|
255
292
|
async renameAgent(project, agent, newName) {
|
|
256
293
|
if (agent.role === "coordinator")
|
|
@@ -356,7 +393,7 @@ export class Team {
|
|
|
356
393
|
this.planChanged(project);
|
|
357
394
|
const assets = task.assets.length ? `\nShared assets involved: ${task.assets.join(", ")} — request them before changing them.` : "";
|
|
358
395
|
await this.deliver(project, coordinator, helper, "request", `Task ${task.id}: ${task.title}\n\n${task.description}${assets}${extra ? `\n\n${extra}` : ""}\n\n` +
|
|
359
|
-
`When it's done, commit your work and call update_task("${task.id}", "review", <summary>) — that is your report. ` +
|
|
396
|
+
`When it's done, ${helper.branch ? "commit your work and " : ""}call update_task("${task.id}", "review", <summary>) — that is your report. ` +
|
|
360
397
|
`If you get stuck, update_task("${task.id}", "blocked", <why>).`, { protocol: true });
|
|
361
398
|
return `Assigned ${task.id} to ${helper.name}.`;
|
|
362
399
|
}
|
|
@@ -372,9 +409,19 @@ export class Team {
|
|
|
372
409
|
const coordinator = this.coordinator(project);
|
|
373
410
|
if (coordinator && agent.role !== "coordinator" && (status === "review" || status === "blocked")) {
|
|
374
411
|
await this.deliver(project, agent, coordinator, status === "review" ? "task-review" : "task-blocked", `${task.id} "${task.title}" → ${status}${note ? `: ${note}` : ""}` +
|
|
375
|
-
(status === "review"
|
|
412
|
+
(status === "review"
|
|
413
|
+
? agent.branch
|
|
414
|
+
? `\nMerge with merge_work("${agent.name}") when it looks right.`
|
|
415
|
+
: `\nIts work is already in the project folder; check it there, then mark the task done (update_task) or send it back.`
|
|
416
|
+
: ""));
|
|
376
417
|
return `${task.id} is now ${status}. The coordinator has been notified — no separate report needed.`;
|
|
377
418
|
}
|
|
419
|
+
// Helpers without a branch have nothing to merge: the coordinator marking their task done
|
|
420
|
+
// is the moment a fresh session fits (what merging is for the others).
|
|
421
|
+
const helper = task.assignee ? project.agents.find((a) => a.id === task.assignee) : undefined;
|
|
422
|
+
if (agent.role === "coordinator" && status === "done" && helper && !helper.branch && helper.role !== "coordinator") {
|
|
423
|
+
await this.renewAfterTask(project, helper, "its task was done");
|
|
424
|
+
}
|
|
378
425
|
return `${task.id} is now ${task.status}.`;
|
|
379
426
|
}
|
|
380
427
|
/** Resolves MCP callers: the token says which folder, the session says which agent. */
|
|
@@ -460,7 +507,7 @@ export class Team {
|
|
|
460
507
|
if (!project.allowedModels.includes(model)) {
|
|
461
508
|
throw new Error(`Model "${model}" is not allowed for this project. Allowed: ${project.allowedModels.join(", ")}`);
|
|
462
509
|
}
|
|
463
|
-
const workspace = await createWorkspace(project, name);
|
|
510
|
+
const workspace = await createWorkspace(project, name, opts.workspace ?? project.helperWorkspace ?? "own");
|
|
464
511
|
const client = this.client();
|
|
465
512
|
await this.registerTools(project, workspace.directory);
|
|
466
513
|
const session = await client.createSession({
|
|
@@ -494,7 +541,10 @@ export class Team {
|
|
|
494
541
|
await this.deliver(project, coordinator, agent, "request", opts.task, { protocol: true });
|
|
495
542
|
const where = workspace.branch
|
|
496
543
|
? `It works in its own copy of the project on branch ${workspace.branch} (${workspace.directory}); merge its work with merge_work.`
|
|
497
|
-
:
|
|
544
|
+
: (await isGitRepo(project.directory))
|
|
545
|
+
? "It works in the project folder itself, alongside you: what it writes is there at once, and there is nothing to merge. " +
|
|
546
|
+
"Shared-asset grants keep it from colliding with others; it doesn't commit unless you ask."
|
|
547
|
+
: "It works directly in the project folder (no git), so rely on shared-asset grants to avoid conflicts.";
|
|
498
548
|
return [`Created ${name} on ${model} and sent it the task. ${where}`, workspace.warning].filter(Boolean).join("\n");
|
|
499
549
|
}
|
|
500
550
|
async stopAgent(project, agent, reason) {
|
|
@@ -505,8 +555,7 @@ export class Team {
|
|
|
505
555
|
await reg.forget(agent.id);
|
|
506
556
|
this.assetsChanged(project);
|
|
507
557
|
// Helpers have their own folder; the project root is shared, so leave its processes alone.
|
|
508
|
-
|
|
509
|
-
await this.stopAllProcesses(agent);
|
|
558
|
+
await this.stopAllProcesses(project, agent);
|
|
510
559
|
const { committed, uncommitted } = await changedFiles(agent);
|
|
511
560
|
const unmerged = committed.length > 0 || uncommitted.length > 0;
|
|
512
561
|
if (!unmerged)
|
|
@@ -565,7 +614,7 @@ export class Team {
|
|
|
565
614
|
* lossy (compactions), and they inflate OpenCode's memory. The new session starts from
|
|
566
615
|
* a handoff note built from what Symbio knows; the old one stays readable.
|
|
567
616
|
*/
|
|
568
|
-
async renewSession(project, agent, reason) {
|
|
617
|
+
async renewSession(project, agent, reason, opts = {}) {
|
|
569
618
|
if (agent.status === "busy" || agent.status === "waiting" || agent.status === "stuck") {
|
|
570
619
|
throw new Error(`${agent.name} is working; renew its session when it's idle.`);
|
|
571
620
|
}
|
|
@@ -575,7 +624,7 @@ export class Team {
|
|
|
575
624
|
const oldSession = agent.sessionId;
|
|
576
625
|
// Count everything the old session used before leaving it.
|
|
577
626
|
await this.refreshUsage(project, agent).catch(() => { });
|
|
578
|
-
const handoff = await this.handoffNote(project, agent, oldSession);
|
|
627
|
+
const handoff = await this.handoffNote(project, agent, oldSession, opts.moved);
|
|
579
628
|
await this.registerTools(project, agent.directory);
|
|
580
629
|
const session = await client.createSession({
|
|
581
630
|
title: `${project.name} · ${agent.name}`,
|
|
@@ -603,8 +652,54 @@ export class Team {
|
|
|
603
652
|
});
|
|
604
653
|
bus.publish({ type: "project.updated", projectId: project.id });
|
|
605
654
|
}
|
|
655
|
+
/**
|
|
656
|
+
* Moves a helper from its own worktree into the project folder (for pipeline-style work
|
|
657
|
+
* where each step reads the previous step's files). OpenCode ties a session to its folder,
|
|
658
|
+
* so the helper continues in a fresh session there, with the usual handoff note.
|
|
659
|
+
*/
|
|
660
|
+
async moveToProjectFolder(project, agent, reason) {
|
|
661
|
+
if (agent.role === "coordinator")
|
|
662
|
+
throw new Error("core already works in the project folder.");
|
|
663
|
+
if (inProjectFolder(project, agent))
|
|
664
|
+
return `${agent.name} already works in the project folder.`;
|
|
665
|
+
if (agent.status === "stopped")
|
|
666
|
+
throw new Error(`${agent.name} is stopped; restart it first.`);
|
|
667
|
+
if (agent.status === "busy" || agent.status === "waiting" || agent.status === "stuck") {
|
|
668
|
+
throw new Error(`${agent.name} is working; move it when it's idle.`);
|
|
669
|
+
}
|
|
670
|
+
// Its processes run in the folder about to go away (and would keep files open on Windows).
|
|
671
|
+
await this.stopAllProcesses(project, agent);
|
|
672
|
+
await leaveWorkspace(project, agent);
|
|
673
|
+
const branch = agent.branch;
|
|
674
|
+
agent.directory = project.directory;
|
|
675
|
+
agent.branch = null;
|
|
676
|
+
agent.base = null;
|
|
677
|
+
agent.workSince = Date.now();
|
|
678
|
+
await this.store.update(project);
|
|
679
|
+
try {
|
|
680
|
+
await this.renewSession(project, agent, reason, { moved: true });
|
|
681
|
+
}
|
|
682
|
+
catch (err) {
|
|
683
|
+
agent.status = "error";
|
|
684
|
+
agent.problem = `It moved to the project folder but its new session couldn't start (${err.message}). Give it a fresh session to retry.`;
|
|
685
|
+
await this.store.update(project);
|
|
686
|
+
bus.publish({ type: "project.updated", projectId: project.id });
|
|
687
|
+
throw new Error(agent.problem);
|
|
688
|
+
}
|
|
689
|
+
await this.log(project, {
|
|
690
|
+
from: "symbio",
|
|
691
|
+
to: "human",
|
|
692
|
+
kind: "system",
|
|
693
|
+
text: `${agent.name} now works in the project folder (${reason}); its own copy${branch ? ` on ${branch}` : ""} was removed.`,
|
|
694
|
+
});
|
|
695
|
+
const coordinator = this.coordinator(project);
|
|
696
|
+
if (coordinator && reason !== "moved by the coordinator") {
|
|
697
|
+
await this.system(project, coordinator, `Symbio: the human moved ${agent.name} into the project folder. It now works on the project's files directly: nothing of its to merge from now on.`, false);
|
|
698
|
+
}
|
|
699
|
+
return `${agent.name} now works in the project folder, in a fresh session with a handoff note. Its own copy was removed.`;
|
|
700
|
+
}
|
|
606
701
|
/** What an agent needs to carry on in a fresh session, from Symbio's own records. */
|
|
607
|
-
async handoffNote(project, agent, oldSession) {
|
|
702
|
+
async handoffNote(project, agent, oldSession, moved = false) {
|
|
608
703
|
const plan = (await this.plan(project)).plan;
|
|
609
704
|
const reg = await this.assets(project);
|
|
610
705
|
const mine = plan.tasks.filter((t) => t.assignee === agent.id);
|
|
@@ -624,14 +719,21 @@ export class Team {
|
|
|
624
719
|
const cut = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
|
|
625
720
|
const parts = [
|
|
626
721
|
`# Handoff: you are continuing as ${agent.name} in a fresh session`,
|
|
627
|
-
|
|
722
|
+
(moved
|
|
723
|
+
? `You now work in the project folder itself instead of your own copy, so Symbio started a new session there. ` +
|
|
724
|
+
`Nothing else about your role changed. `
|
|
725
|
+
: `Your previous session had grown long, so Symbio started a new one. Nothing about your role changed. `) +
|
|
628
726
|
`This note is what carries over; the project's docs (AGENTS.md, docs/) hold the durable knowledge, ` +
|
|
629
|
-
`and your
|
|
727
|
+
`and your work is in your folder.`,
|
|
630
728
|
agent.purpose ? `**Your purpose:** ${agent.purpose}` : "",
|
|
631
729
|
agent.role === "coordinator"
|
|
632
730
|
? `**Plan:** ${plan.status}${plan.goal ? ` — ${plan.goal}` : ""}\n${plan.tasks.map((t) => `- ${t.id} [${t.status}] ${t.title}${t.assignee ? ` (${nameOf(t.assignee)})` : ""}`).join("\n")}`
|
|
633
731
|
: "",
|
|
634
|
-
agent.role
|
|
732
|
+
agent.role === "coordinator"
|
|
733
|
+
? ""
|
|
734
|
+
: agent.branch
|
|
735
|
+
? `**Your folder:** ${agent.directory} (branch ${agent.branch}).`
|
|
736
|
+
: `**Your folder:** ${agent.directory} (the project folder, shared with the team).`,
|
|
635
737
|
open.length ? `**Your open tasks:**\n${open.map((t) => `- ${t.id} [${t.status}] ${t.title}\n ${cut(t.description, 600)}`).join("\n")}` : "**Open tasks:** none right now; wait for the next one.",
|
|
636
738
|
done.length ? `**Recently finished:**\n${done.map((t) => `- ${t.id} ${t.title}${lastNote(t) ? ` — ${cut(lastNote(t), 300)}` : ""}`).join("\n")}` : "",
|
|
637
739
|
held.length ? `**Shared assets you hold:** ${held.join(", ")} (release them when done).` : "",
|
|
@@ -640,6 +742,17 @@ export class Team {
|
|
|
640
742
|
];
|
|
641
743
|
return parts.filter(Boolean).join("\n\n");
|
|
642
744
|
}
|
|
745
|
+
/** With "after-task" renewal, a helper with no other task under way gets a fresh session. */
|
|
746
|
+
async renewAfterTask(project, agent, reason) {
|
|
747
|
+
const plan = await this.plan(project);
|
|
748
|
+
const renew = project.renewSessions === "after-task" &&
|
|
749
|
+
agent.status === "idle" &&
|
|
750
|
+
!plan.plan.tasks.some((t) => t.assignee === agent.id && t.status !== "done" && t.status !== "todo");
|
|
751
|
+
if (!renew)
|
|
752
|
+
return false;
|
|
753
|
+
await this.renewSession(project, agent, reason).catch((err) => console.error(`[renew] ${agent.name}:`, err.message));
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
643
756
|
async mergeWork(project, agent, force) {
|
|
644
757
|
const { committed, uncommitted } = await changedFiles(agent);
|
|
645
758
|
const reg = await this.assets(project);
|
|
@@ -663,14 +776,7 @@ export class Team {
|
|
|
663
776
|
}
|
|
664
777
|
if (completed.length)
|
|
665
778
|
this.planChanged(project);
|
|
666
|
-
|
|
667
|
-
project.renewSessions === "after-task" &&
|
|
668
|
-
agent.status === "idle" &&
|
|
669
|
-
!plan.plan.tasks.some((t) => t.assignee === agent.id && t.status !== "done" && t.status !== "todo");
|
|
670
|
-
if (renew) {
|
|
671
|
-
await this.renewSession(project, agent, "its task was merged").catch((err) => console.error(`[renew] ${agent.name}:`, err.message));
|
|
672
|
-
}
|
|
673
|
-
else {
|
|
779
|
+
if (!(completed.length > 0 && (await this.renewAfterTask(project, agent, "its task was merged")))) {
|
|
674
780
|
await this.system(project, agent, "Symbio: your committed work was merged into the project, and your workspace now includes the latest project state.", false);
|
|
675
781
|
}
|
|
676
782
|
return (`${result.summary}\n${result.files.map((f) => `- ${f}`).join("\n")}` +
|
|
@@ -839,8 +945,7 @@ export class Team {
|
|
|
839
945
|
for (const agent of project.agents) {
|
|
840
946
|
if (client) {
|
|
841
947
|
await client.interrupt(agent.sessionId).catch(() => { });
|
|
842
|
-
|
|
843
|
-
await this.stopAllProcesses(agent).catch(() => { });
|
|
948
|
+
await this.stopAllProcesses(project, agent).catch(() => { });
|
|
844
949
|
}
|
|
845
950
|
if (agent.branch)
|
|
846
951
|
await removeWorkspace(project, agent).catch(() => { });
|
|
@@ -869,6 +974,14 @@ export class Team {
|
|
|
869
974
|
async onPermissionAsked(project, agent, req) {
|
|
870
975
|
const action = String(req.action ?? "");
|
|
871
976
|
const resources = Array.isArray(req.resources) ? req.resources.map(String) : [];
|
|
977
|
+
// Image files: refused at once, with what to use instead (never a question for the human).
|
|
978
|
+
const refusal = imageReadRefusal(action, resources);
|
|
979
|
+
if (refusal) {
|
|
980
|
+
await this.client()
|
|
981
|
+
.request("POST", `/api/session/${agent.sessionId}/permission/${req.id}/reply`, { decision: "reject", message: refusal })
|
|
982
|
+
.catch(() => { });
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
872
985
|
// Already answered "always" for this kind of request in this project?
|
|
873
986
|
const allowed = resources.length > 0 && resources.every((r) => project.allowedPatterns.some((a) => a.action === action && wildcardMatch(a.pattern, r)));
|
|
874
987
|
if (allowed) {
|
|
@@ -1024,37 +1137,53 @@ export class Team {
|
|
|
1024
1137
|
return;
|
|
1025
1138
|
this.usageTimers.set(agent.id, setTimeout(() => {
|
|
1026
1139
|
this.usageTimers.delete(agent.id);
|
|
1027
|
-
|
|
1140
|
+
// OpenCode may be restarting; the next burst of usage events reads the totals again.
|
|
1141
|
+
this.refreshUsage(project, agent).catch(() => { });
|
|
1028
1142
|
}, 3000));
|
|
1029
1143
|
}
|
|
1030
1144
|
// ---------------------------------------------------------------- processes
|
|
1031
|
-
/**
|
|
1032
|
-
|
|
1033
|
-
|
|
1145
|
+
/**
|
|
1146
|
+
* The agent's running background shells. An agent alone in its folder owns everything
|
|
1147
|
+
* there; in the project folder (shared by the coordinator and shared helpers) a shell
|
|
1148
|
+
* belongs to the session that started it, which OpenCode records in its metadata.
|
|
1149
|
+
*/
|
|
1150
|
+
async ownShells(project, agent) {
|
|
1151
|
+
const running = (await this.client().shells(agent.directory)).filter((s) => s.status === "running");
|
|
1152
|
+
if (!inProjectFolder(project, agent))
|
|
1153
|
+
return running;
|
|
1154
|
+
const sessionsOf = (a) => [a.sessionId, ...(a.previousSessions ?? []).map((p) => p.sessionId), ...Object.keys(a.childSessions ?? {})];
|
|
1155
|
+
const mine = new Set(sessionsOf(agent));
|
|
1156
|
+
const others = new Set(project.agents.filter((a) => a.id !== agent.id).flatMap(sessionsOf));
|
|
1157
|
+
return running.filter((s) => {
|
|
1158
|
+
const owner = s.metadata?.sessionID;
|
|
1159
|
+
if (owner)
|
|
1160
|
+
return mine.has(owner) || (agent.role === "coordinator" && !others.has(owner));
|
|
1161
|
+
// No owner recorded (started outside an agent session): the coordinator manages it.
|
|
1162
|
+
return agent.role === "coordinator";
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
/** Background shells the agent started that are still running. */
|
|
1166
|
+
async listProcesses(project, agent) {
|
|
1167
|
+
const shells = await this.ownShells(project, agent);
|
|
1034
1168
|
if (shells.length === 0)
|
|
1035
1169
|
return "You have no background processes running.";
|
|
1036
1170
|
return shells
|
|
1037
1171
|
.map((s) => `- ${s.id}: ${s.command} (pid ${s.pid ?? "?"}, started ${new Date(s.time.started).toLocaleTimeString()})`)
|
|
1038
1172
|
.join("\n");
|
|
1039
1173
|
}
|
|
1040
|
-
/** Stops one background shell — only
|
|
1041
|
-
async stopProcess(agent, shellId) {
|
|
1042
|
-
const
|
|
1043
|
-
const shell = shells.find((s) => s.id === shellId.trim());
|
|
1174
|
+
/** Stops one background shell — only one the agent itself started. */
|
|
1175
|
+
async stopProcess(project, agent, shellId) {
|
|
1176
|
+
const shell = (await this.ownShells(project, agent)).find((s) => s.id === shellId.trim());
|
|
1044
1177
|
if (!shell) {
|
|
1045
|
-
throw new Error(`No background process "${shellId}"
|
|
1178
|
+
throw new Error(`No running background process "${shellId}" of yours. Use list_processes to see yours.`);
|
|
1046
1179
|
}
|
|
1047
|
-
if (shell.status !== "running")
|
|
1048
|
-
return `${shell.id} already ${shell.status}.`;
|
|
1049
1180
|
await this.client().stopShell(agent.directory, shell.id);
|
|
1050
1181
|
return `Stopped ${shell.id} (${shell.command}).`;
|
|
1051
1182
|
}
|
|
1052
1183
|
/** Stops every background shell an agent left running (used when the agent is stopped). */
|
|
1053
|
-
async stopAllProcesses(agent) {
|
|
1054
|
-
const shells = await this.
|
|
1055
|
-
|
|
1056
|
-
.catch(() => []);
|
|
1057
|
-
for (const s of shells.filter((s) => s.status === "running")) {
|
|
1184
|
+
async stopAllProcesses(project, agent) {
|
|
1185
|
+
const shells = await this.ownShells(project, agent).catch(() => []);
|
|
1186
|
+
for (const s of shells) {
|
|
1058
1187
|
await this.client()
|
|
1059
1188
|
.stopShell(agent.directory, s.id)
|
|
1060
1189
|
.catch(() => { });
|
|
@@ -1135,11 +1264,29 @@ export class Team {
|
|
|
1135
1264
|
}
|
|
1136
1265
|
/** Before starting an agent's turn: make sure its tools and private docs are in place. */
|
|
1137
1266
|
async prepare(project, agent) {
|
|
1267
|
+
await this.reattach(project, agent);
|
|
1138
1268
|
await this.ensureTools(project, agent.directory);
|
|
1139
1269
|
await this.syncPrivateDocs(project, agent);
|
|
1140
1270
|
}
|
|
1271
|
+
/** A helper whose folder disappeared gets it back from its branch (see reattachWorkspace). */
|
|
1272
|
+
async reattach(project, agent) {
|
|
1273
|
+
if (agent.status === "stopped")
|
|
1274
|
+
return;
|
|
1275
|
+
const recreated = await reattachWorkspace(project, agent).catch((err) => {
|
|
1276
|
+
console.error(`[team] could not recreate ${agent.name}'s folder:`, err.message);
|
|
1277
|
+
return false;
|
|
1278
|
+
});
|
|
1279
|
+
if (!recreated)
|
|
1280
|
+
return;
|
|
1281
|
+
const text = `${agent.name}'s folder (${agent.directory}) had disappeared; Symbio recreated it from its branch ${agent.branch}. ` +
|
|
1282
|
+
"Everything it committed is there; changes it hadn't committed were in the folder and are gone.";
|
|
1283
|
+
await this.log(project, { from: "symbio", to: "human", kind: "system", text });
|
|
1284
|
+
await this.system(project, agent, `Symbio: ${text.replace(`${agent.name}'s folder`, "Your folder")} Check your state before continuing.`, false);
|
|
1285
|
+
}
|
|
1141
1286
|
async refreshAll() {
|
|
1142
1287
|
for (const project of this.store.list()) {
|
|
1288
|
+
for (const agent of project.agents.filter(active))
|
|
1289
|
+
await this.reattach(project, agent);
|
|
1143
1290
|
const dirs = new Set(project.agents.filter(active).map((a) => a.directory));
|
|
1144
1291
|
for (const dir of dirs) {
|
|
1145
1292
|
await this.registerTools(project, dir).catch((err) => console.error(`[team] could not register tools for ${project.name} (${dir}):`, err.message));
|
|
@@ -1273,6 +1420,47 @@ export class Team {
|
|
|
1273
1420
|
bus.publish({ type: "agent.stream", projectId: project.id, agentId: agent.id, event: { type: e.type, data: e.data } });
|
|
1274
1421
|
}
|
|
1275
1422
|
}
|
|
1423
|
+
/**
|
|
1424
|
+
* An agent whose conversation got too large to send continues in a fresh session (with the
|
|
1425
|
+
* usual handoff note) and is told why. Once per 15 minutes: if the fresh one fails the same
|
|
1426
|
+
* way, the human is told instead of looping.
|
|
1427
|
+
*/
|
|
1428
|
+
async renewOversized(project, agent, message) {
|
|
1429
|
+
const last = this.oversizedAt.get(agent.id) ?? 0;
|
|
1430
|
+
if (Date.now() - last < 15 * 60_000) {
|
|
1431
|
+
if (agent.problem)
|
|
1432
|
+
return;
|
|
1433
|
+
agent.problem = `Its conversation is too large to send, even after a fresh session (${message}).`;
|
|
1434
|
+
this.setStatus(project, agent, "error");
|
|
1435
|
+
await this.store.update(project);
|
|
1436
|
+
bus.publish({ type: "project.updated", projectId: project.id });
|
|
1437
|
+
await this.log(project, { from: "symbio", to: "human", kind: "alert", text: `${agent.name} can't continue: ${agent.problem}` });
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
this.oversizedAt.set(agent.id, Date.now());
|
|
1441
|
+
this.fallbackBusy.add(agent.id);
|
|
1442
|
+
try {
|
|
1443
|
+
agent.problem = null;
|
|
1444
|
+
if (agent.status !== "idle")
|
|
1445
|
+
this.setStatus(project, agent, "idle");
|
|
1446
|
+
await this.renewSession(project, agent, "its conversation had grown too large to send");
|
|
1447
|
+
await this.system(project, agent, "Symbio: your previous conversation had grown too large to send to the model (the provider said: " +
|
|
1448
|
+
`"${message}"), so you continue in this fresh session; the handoff note above has what carries over. ` +
|
|
1449
|
+
"Usually it's images read in full: each one stays in the conversation and is sent again every turn. " +
|
|
1450
|
+
"To look at an image, read a small copy (scaled down to about 1000 px wide) or have a helper review it. " +
|
|
1451
|
+
"Continue where you left off.");
|
|
1452
|
+
}
|
|
1453
|
+
catch (err) {
|
|
1454
|
+
agent.problem = `Its conversation is too large to send (${message}), and a fresh session couldn't start: ${err.message}. Use New session in its chat.`;
|
|
1455
|
+
this.setStatus(project, agent, "error");
|
|
1456
|
+
await this.store.update(project);
|
|
1457
|
+
bus.publish({ type: "project.updated", projectId: project.id });
|
|
1458
|
+
await this.log(project, { from: "symbio", to: "human", kind: "alert", text: `${agent.name} can't continue: ${agent.problem}` });
|
|
1459
|
+
}
|
|
1460
|
+
finally {
|
|
1461
|
+
this.fallbackBusy.delete(agent.id);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1276
1464
|
/**
|
|
1277
1465
|
* When an agent's model fails for provider reasons (quota, outage, timeouts),
|
|
1278
1466
|
* switch it to the next allowed model and tell it to continue.
|
|
@@ -1285,6 +1473,15 @@ export class Team {
|
|
|
1285
1473
|
const status = Number(error?.status ?? 0);
|
|
1286
1474
|
if (/interrupt|abort|cancel/i.test(`${type} ${message}`))
|
|
1287
1475
|
return; // our own interrupts
|
|
1476
|
+
// The conversation itself is too big to send (seen with Copilot: "Timed out reading request
|
|
1477
|
+
// body", after an agent read several large images). Another model won't help; a fresh session will.
|
|
1478
|
+
if (status === 413 || /request body|request size|payload too large|request too large|request entity too large/i.test(message)) {
|
|
1479
|
+
// Still retrying the same oversized request: stop it before moving on.
|
|
1480
|
+
if (interruptFirst)
|
|
1481
|
+
await this.client().interrupt(agent.sessionId).catch(() => { });
|
|
1482
|
+
await this.renewOversized(project, agent, message);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1288
1485
|
const providerProblem = type.startsWith("provider") ||
|
|
1289
1486
|
[401, 402, 403, 404, 408, 429].includes(status) ||
|
|
1290
1487
|
status >= 500 ||
|
|
@@ -1301,7 +1498,8 @@ export class Team {
|
|
|
1301
1498
|
if (!next) {
|
|
1302
1499
|
if (agent.problem)
|
|
1303
1500
|
return; // already reported
|
|
1304
|
-
|
|
1501
|
+
const problem = `Every allowed model failed recently. Last error (${agent.model}): ${message}`;
|
|
1502
|
+
agent.problem = problem;
|
|
1305
1503
|
this.setStatus(project, agent, "error");
|
|
1306
1504
|
await this.store.update(project);
|
|
1307
1505
|
bus.publish({ type: "project.updated", projectId: project.id });
|
|
@@ -1309,7 +1507,7 @@ export class Team {
|
|
|
1309
1507
|
from: "symbio",
|
|
1310
1508
|
to: "human",
|
|
1311
1509
|
kind: "alert",
|
|
1312
|
-
text: `${agent.name} can't continue: ${
|
|
1510
|
+
text: `${agent.name} can't continue: ${problem}\n\nAdd another model to the project (Models in the sidebar) — stuck agents resume automatically — or use “Retry now” once the quota resets.`,
|
|
1313
1511
|
});
|
|
1314
1512
|
return;
|
|
1315
1513
|
}
|