symbio 0.4.0 → 0.6.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.
@@ -2,6 +2,8 @@
2
2
  // specific agent (identified by its session); coordinator-only tools refuse
3
3
  // other callers.
4
4
  import { describeTask, TASK_STATUSES } from "./plan.js";
5
+ import { CONFIRMATIONS, listRules, saveArea, saveRule, saveSource, STATUSES } from "./rules.js";
6
+ import { DEFAULT_IMAGE_SIZE, viewImage } from "./images.js";
5
7
  const str = (description) => ({ type: "string", description });
6
8
  function obj(properties, required = []) {
7
9
  return { type: "object", properties, required, additionalProperties: false };
@@ -328,20 +330,20 @@ const remoteUpload = {
328
330
  run: ({ connection, local_path, remote_path, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "remote_upload", connectionId: String(connection), localPath: String(local_path), remotePath: String(remote_path) }, String(reason)),
329
331
  };
330
332
  const gitFields = {
331
- connection: str("GitHub connection name"),
332
- remote: str("Remote name or https://github.com/… URL (default: origin)"),
333
+ connection: str("GitHub or Azure DevOps connection name"),
334
+ remote: str("Remote name or HTTPS URL (github.com, dev.azure.com) (default: origin)"),
333
335
  branch: str("Branch (default: your current branch)"),
334
336
  reason: reasonField,
335
337
  };
336
338
  const gitPush = {
337
339
  name: "git_push",
338
- description: "Push committed work from your working folder to GitHub (the token is applied by Symbio; plain `git push` has no credentials).",
340
+ description: "Push committed work from your working folder to GitHub or Azure DevOps (the token is applied by Symbio; plain `git push` may have no credentials).",
339
341
  inputSchema: obj(gitFields, ["connection", "reason"]),
340
342
  run: ({ connection, remote, branch, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "git_push", connectionId: String(connection), remote: remote ? String(remote) : null, branch: branch ? String(branch) : null }, String(reason)),
341
343
  };
342
344
  const gitPull = {
343
345
  name: "git_pull",
344
- description: "Pull from GitHub into your working folder (merge, no rebase).",
346
+ description: "Pull from GitHub or Azure DevOps into your working folder (merge, no rebase).",
345
347
  inputSchema: obj(gitFields, ["connection", "reason"]),
346
348
  run: ({ connection, remote, branch, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "git_pull", connectionId: String(connection), remote: remote ? String(remote) : null, branch: branch ? String(branch) : null }, String(reason)),
347
349
  };
@@ -352,18 +354,131 @@ const githubApi = {
352
354
  connection: str("GitHub connection name"),
353
355
  method: { type: "string", enum: ["GET", "POST", "PATCH", "PUT", "DELETE"] },
354
356
  path: str("API path, e.g. /user/repos"),
355
- body: { type: "object", description: "JSON body for POST/PATCH/PUT" },
357
+ // Some endpoints take a list (e.g. Azure DevOps ref updates), others an object.
358
+ body: { anyOf: [{ type: "object" }, { type: "array" }], description: "JSON body for POST/PATCH/PUT (an object, or a list where the API expects one)" },
356
359
  reason: reasonField,
357
360
  }, ["connection", "method", "path", "reason"]),
358
361
  run: ({ connection, method, path, body, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "github_api", connectionId: String(connection), method: String(method), path: String(path), body }, String(reason)),
359
362
  };
363
+ const azureDevopsApi = {
364
+ name: "azure_devops_api",
365
+ description: "Call the Azure DevOps REST API of the connection's organization. The path is relative to https://dev.azure.com/<organization>/ " +
366
+ "and api-version is added if missing. E.g. GET <project>/_apis/git/repositories to find a repository's id; " +
367
+ "POST <project>/_apis/git/repositories/<repo>/pullrequests with {sourceRefName: 'refs/heads/<branch>', targetRefName: " +
368
+ "'refs/heads/main', title, description} to open a pull request (push the branch first with git_push); " +
369
+ "GET <project>/_apis/git/repositories/<repo>/pullrequests?searchCriteria.status=active to list them. Read-only GET calls never need approval.",
370
+ inputSchema: obj({
371
+ connection: str("Azure DevOps connection name"),
372
+ method: { type: "string", enum: ["GET", "POST", "PATCH", "PUT", "DELETE"] },
373
+ path: str("Path inside the organization, e.g. MyProject/_apis/git/repositories"),
374
+ // Some endpoints take a list (e.g. Azure DevOps ref updates), others an object.
375
+ body: { anyOf: [{ type: "object" }, { type: "array" }], description: "JSON body for POST/PATCH/PUT (an object, or a list where the API expects one)" },
376
+ reason: reasonField,
377
+ }, ["connection", "method", "path", "reason"]),
378
+ run: ({ connection, method, path, body, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "azure_devops_api", connectionId: String(connection), method: String(method), path: String(path), body }, String(reason)),
379
+ };
380
+ // ---- rules register (only when the project keeps one)
381
+ function registerOn(project) {
382
+ if (!project.rulesRegister)
383
+ throw new Error("This project doesn't keep a rules register (the human can turn it on in the project's Settings).");
384
+ }
385
+ // ---- images
386
+ const viewImageTool = {
387
+ name: "view_image",
388
+ description: "Look at an image (png, jpg, gif, webp, bmp): returns a scaled-down copy, so it doesn't bloat your conversation. " +
389
+ "Reading image files directly is blocked: a full-size image stays in your conversation and is re-sent every turn. " +
390
+ "For details, ask for a crop: a region of the original, in its pixels, shown at full resolution.",
391
+ inputSchema: obj({
392
+ path: str("The image file (absolute, or relative to your folder)"),
393
+ max_size: { type: "integer", description: `Longest side of the copy in pixels (default ${DEFAULT_IMAGE_SIZE}, at most 2048)` },
394
+ crop: obj({ left: { type: "integer" }, top: { type: "integer" }, width: { type: "integer" }, height: { type: "integer" } }, ["left", "top", "width", "height"]),
395
+ }, ["path"]),
396
+ run: (args, { agent }) => viewImage(agent.directory, args),
397
+ };
398
+ const listRulesTool = {
399
+ name: "list_rules",
400
+ description: "The project's rules register: its areas, sources and rules (id, status, confirmation, text, sources). Filter to keep it short.",
401
+ inputSchema: obj({
402
+ query: str("Words to look for in the id, text, current state or citations"),
403
+ area: str("Only this area (its prefix, e.g. 'AP')"),
404
+ status: { type: "string", enum: [...STATUSES] },
405
+ }),
406
+ async run(args, { project }) {
407
+ registerOn(project);
408
+ return listRules(project, args);
409
+ },
410
+ };
411
+ const saveRuleTool = {
412
+ name: "save_rule",
413
+ description: "Add a rule to the register, or update one (give its id; only the fields you pass change). A new rule gets the next id in its " +
414
+ "area. State only what the sources say; if nothing backs it yet, add it without citations (it shows as unsourced) rather than " +
415
+ "inventing a source. Citations quote the source literally.",
416
+ inputSchema: obj({
417
+ id: str("To update an existing rule, e.g. 'AP-031'. Leave out to add a new one."),
418
+ area: str("Area prefix (define areas with save_rule_area)"),
419
+ stage: str("Stage id within the area (e.g. 'B')"),
420
+ text: str("What the rule says (Markdown)"),
421
+ action: str("What the work does about it, e.g. 'blocks submission', 'warning only', 'none'"),
422
+ citations: {
423
+ type: "array",
424
+ description: "Where it comes from. Each: source code (define sources with save_rule_source), location (p.20, l.10…), literal excerpt.",
425
+ items: obj({ source: str("Source code, e.g. 'MA'"), location: str("Where in the source"), excerpt: str("The source's words, copied literally") }, ["source"]),
426
+ },
427
+ imposed_by: str("Who imposes it (an authority code)"),
428
+ confirmation: { type: "string", enum: [...CONFIRMATIONS], description: "found = extracted, nobody checked; checked-by-agent; checked-by-human; contested" },
429
+ status: { type: "string", enum: [...STATUSES], description: "Is it met by the work today?" },
430
+ current: str("How things are today, especially where they differ from the rule"),
431
+ check: str("How to check that it's met"),
432
+ superseded_by: str("Id of the rule that replaced this one (the old rule stays for the record)"),
433
+ }, []),
434
+ async run(args, { project, agent }) {
435
+ registerOn(project);
436
+ return saveRule(project, args, agent.name);
437
+ },
438
+ };
439
+ const saveRuleAreaTool = {
440
+ name: "save_rule_area",
441
+ description: "Define or update an area of the rules register (a part of the project with its own rule ids) and its stages, in order.",
442
+ inputSchema: obj({
443
+ id: str("Short prefix for its rule ids, e.g. 'AP' → AP-001. Must differ from every source code."),
444
+ name: str("Name, e.g. 'Student — Submission'"),
445
+ group: str("Optional larger group it belongs to"),
446
+ description: str("What it covers"),
447
+ stages: { type: "array", items: obj({ id: str("e.g. 'A'"), name: str("e.g. 'Access'") }, ["name"]), description: "Its steps or sections, in order" },
448
+ }, ["id", "name"]),
449
+ async run(args, { project }) {
450
+ registerOn(project);
451
+ return saveArea(project, args);
452
+ },
453
+ };
454
+ const saveRuleSourceTool = {
455
+ name: "save_rule_source",
456
+ description: "Define or update a source rules are cited from (a document, a meeting, a decision), with the file when there is one.",
457
+ inputSchema: obj({
458
+ id: str("Short code used in citations, e.g. 'MA' or 'D38'. Must differ from every area prefix."),
459
+ name: str("e.g. 'Student manual 2026'"),
460
+ file: str("The file, relative to the project folder (so the human can open it at the cited place)"),
461
+ authority: str("Code of who stands behind it, e.g. 'EC'"),
462
+ authority_name: str("Name of that authority, the first time you use its code"),
463
+ }, ["id", "name"]),
464
+ async run(args, { project }) {
465
+ registerOn(project);
466
+ return saveSource(project, args);
467
+ },
468
+ };
360
469
  const COMMON = [
470
+ viewImageTool,
471
+ listRulesTool,
472
+ saveRuleTool,
473
+ saveRuleAreaTool,
474
+ saveRuleSourceTool,
361
475
  listConnections,
362
476
  remoteExec,
363
477
  remoteUpload,
364
478
  gitPush,
365
479
  gitPull,
366
480
  githubApi,
481
+ azureDevopsApi,
367
482
  sendMessage,
368
483
  askHuman,
369
484
  listTeam,
@@ -416,12 +531,15 @@ function withInbox(tool) {
416
531
  }
417
532
  const arrived = await ctx.team.takeInbox(ctx.agent).catch(() => []);
418
533
  if (arrived.length === 0) {
419
- if (result.startsWith("Error: "))
534
+ if (typeof result === "string" && result.startsWith("Error: "))
420
535
  throw new Error(result.slice(7));
421
536
  return result;
422
537
  }
538
+ const inbox = `📬 Messages that arrived while you were working (already delivered — they won't be repeated):\n\n${arrived.join("\n\n---\n\n")}`;
539
+ if (typeof result !== "string")
540
+ return { content: [...result.content, { type: "text", text: inbox }] };
423
541
  const header = tool === checkMessages ? "" : `${result}\n\n`;
424
- return `${header}📬 Messages that arrived while you were working (already delivered — they won't be repeated):\n\n${arrived.join("\n\n---\n\n")}`;
542
+ return `${header}${inbox}`;
425
543
  },
426
544
  };
427
545
  }
@@ -58,6 +58,39 @@ export const AGENT_UPDATES = [
58
58
  "with search({ namespace: \"symbio\" }). Never conclude they're gone without calling one; if a call really fails, tell the " +
59
59
  "human what the error said.",
60
60
  },
61
+ {
62
+ id: "2026-11-05-rules-register",
63
+ text: "Rules register: a project can keep a register of its rules (requirements, decisions, constraints), each with its source, " +
64
+ "a literal excerpt, who imposes it, how confirmed it is and whether the work meets it; the human browses it in the Docs tab. " +
65
+ "It's on only when the human turns it on in the project's Settings (new projects have it on by default); then your " +
66
+ "instructions explain the tools (list_rules, save_rule, save_rule_area, save_rule_source). Until then, nothing changes.",
67
+ },
68
+ {
69
+ id: "2026-11-05-conversation-size",
70
+ text: "Keep conversations small: whatever an agent reads stays in its conversation and is sent to the model again every turn. " +
71
+ "Reading several full-size images made a coordinator's conversation too large to send at all (\"Timed out reading request " +
72
+ "body\"), whatever the model. So reading image files directly is now refused: look at images with the new view_image tool " +
73
+ "(a scaled-down copy; ask for a crop to see details at full resolution). Read only the part of a big file you need, and " +
74
+ "leave reviewing many images to a helper. If a conversation still becomes too large, Symbio moves that agent to a fresh " +
75
+ "session with a handoff note instead of trying other models.",
76
+ },
77
+ {
78
+ id: "2026-11-06-credentials",
79
+ text: "Credentials, clarified: the old rule \"never look for credentials or put secrets in commands\" was meant for Symbio's own " +
80
+ "secrets (its vault, connections, SSH keys), and it stopped agents from doing legitimate work. Now: Symbio's secrets stay " +
81
+ "off-limits (use connections only through the tools). The project's own credentials (connection strings, .env, Web.config…) " +
82
+ "and credentials the human gives you may be used for the task, e.g. querying a database the work needs. Don't spread them: " +
83
+ "never put one into anything committed or shared (new files, docs, the rules register, the plan, messages to other agents), " +
84
+ "and prefer commands that read it from the config file. If the human says a system is a read-only mirror or production, only " +
85
+ "read from it unless they explicitly OK changes. If you refused such a task before, you can do it now.",
86
+ },
87
+ {
88
+ id: "2026-11-06-azure-devops",
89
+ text: "Azure DevOps connections: the human can now add an Azure DevOps organization (with a personal access token) in Settings and " +
90
+ "enable it per project. git_push and git_pull work with it (HTTPS remotes of that organization; SSH remotes must be switched to " +
91
+ "HTTPS), and the new azure_devops_api tool calls its REST API, e.g. to open a pull request after pushing the branch. Reading " +
92
+ "(GET) never needs approval. If a push or a pull request failed for lack of credentials, ask the human to add the connection.",
93
+ },
61
94
  ];
62
95
  /** Entries a project hasn't been told about yet. */
63
96
  export function unseenUpdates(seen) {
@@ -115,6 +115,20 @@ export async function restoreWorkspace(project, agent) {
115
115
  await git(project.directory, "worktree", "add", agent.directory, agent.branch);
116
116
  return { directory: agent.directory, branch: agent.branch, base, reset: true };
117
117
  }
118
+ /**
119
+ * An active helper's folder that disappeared (deleted outside Symbio): recreate it from its
120
+ * branch, which keeps everything it committed. Unlike restoreWorkspace, the branch is never
121
+ * moved. Returns false when there was nothing to do.
122
+ */
123
+ export async function reattachWorkspace(project, agent) {
124
+ if (!agent.branch || existsSync(agent.directory) || !(await isGitRepo(project.directory)))
125
+ return false;
126
+ await git(project.directory, "worktree", "prune").catch(() => { });
127
+ const branchExists = await git(project.directory, "rev-parse", "--verify", "--quiet", agent.branch).then(() => true, () => false);
128
+ await fs.mkdir(path.dirname(agent.directory), { recursive: true });
129
+ await git(project.directory, "worktree", "add", ...(branchExists ? [agent.directory, agent.branch] : ["-b", agent.branch, agent.directory, "HEAD"]));
130
+ return true;
131
+ }
118
132
  export async function removeWorkspace(project, agent) {
119
133
  if (!agent.branch)
120
134
  return;