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.
@@ -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" && agent.branch ? `\nMerge with merge_work("${agent.name}") when it looks right.` : ""));
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
- : "It works directly in the project folder (no git), so rely on shared-asset grants to avoid conflicts.";
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
- if (agent.directory !== project.directory)
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
- `Your previous session had grown long, so Symbio started a new one. Nothing about your role changed. ` +
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 committed work is in your folder.`,
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 !== "coordinator" && agent.branch ? `**Your folder:** ${agent.directory} (branch ${agent.branch}).` : "",
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
- const renew = completed.length > 0 &&
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
- if (agent.directory !== project.directory)
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
- void this.refreshUsage(project, agent);
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
- /** Background shells running in the agent's working folder. */
1032
- async listProcesses(agent) {
1033
- const shells = (await this.client().shells(agent.directory)).filter((s) => s.status === "running");
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 ones started in the agent's own working folder. */
1041
- async stopProcess(agent, shellId) {
1042
- const shells = await this.client().shells(agent.directory);
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}" in your folder. Use list_processes to see yours.`);
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.client()
1055
- .shells(agent.directory)
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
- agent.problem = `Every allowed model failed recently. Last error (${agent.model}): ${message}`;
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: ${agent.problem}\n\nAdd another model to the project (Models in the sidebar) — stuck agents resume automatically — or use “Retry now” once the quota resets.`,
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
  }