teapot-coding-agent 0.11.0 → 0.12.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/README.md +6 -1
- package/dist/agent/agent.js +408 -55
- package/dist/agent/llm.js +4 -2
- package/dist/agent/tools.js +123 -1
- package/dist/config-schema.js +51 -0
- package/dist/index.js +28 -4
- package/dist/master.js +270 -2
- package/dist/server/api.js +109 -11
- package/package.json +4 -2
- package/public/assets/addon-fit-DIOBYJe3.js +2 -1
- package/public/assets/addon-fit-DIOBYJe3.js.map +1 -0
- package/public/assets/index-4DOCB8Mz.js +15 -0
- package/public/assets/index-4DOCB8Mz.js.map +1 -0
- package/public/assets/index-Q714iow7.css +1 -0
- package/public/assets/xterm-C3BHN0de.js +2 -1
- package/public/assets/xterm-C3BHN0de.js.map +1 -0
- package/public/index.html +2 -2
- package/skills/gyaru-review/SKILL.md +41 -0
- package/skills/qa-adversarial/SKILL.md +41 -0
- package/public/assets/index-BJB4iFSq.js +0 -14
- package/public/assets/index-C4cJD5q_.css +0 -1
package/dist/agent/llm.js
CHANGED
|
@@ -36,7 +36,7 @@ function sanitize(messages) {
|
|
|
36
36
|
return m;
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
|
-
export async function chat(cfg, messages, tools, signal) {
|
|
39
|
+
export async function chat(cfg, messages, tools, signal, onDelta) {
|
|
40
40
|
try {
|
|
41
41
|
const res = await client(cfg).chat.completions.create({
|
|
42
42
|
model: cfg.model,
|
|
@@ -72,6 +72,8 @@ export async function chat(cfg, messages, tools, signal) {
|
|
|
72
72
|
if (!message.content && !message.tool_calls) {
|
|
73
73
|
throw new Error("LLM API error: empty completion");
|
|
74
74
|
}
|
|
75
|
+
// non-streaming fallback still feeds the UI one final snapshot
|
|
76
|
+
onDelta?.({ text: message.content ?? "", reasoning: reasoning ?? "" });
|
|
75
77
|
return {
|
|
76
78
|
message,
|
|
77
79
|
reasoning,
|
|
@@ -165,7 +167,7 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
|
|
|
165
167
|
catch (err) {
|
|
166
168
|
// provider may not support streaming at all — one clean fallback
|
|
167
169
|
if (!gotChunk && !signal?.aborted)
|
|
168
|
-
return chat(cfg, messages, tools, signal);
|
|
170
|
+
return chat(cfg, messages, tools, signal, onDelta);
|
|
169
171
|
// user interrupt: hand back whatever streamed so far so the harness can
|
|
170
172
|
// keep the partial output visible instead of losing it
|
|
171
173
|
if (signal?.aborted && (text || reasoning)) {
|
package/dist/agent/tools.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Tool specs are plain JSON-schema function definitions — provider-agnostic.
|
|
4
4
|
*/
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { existsSync, promises as fs } from "node:fs";
|
|
6
|
+
import { existsSync, promises as fs, realpathSync } from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { discoverSkills, isValidSkillName, readSkillFile, saveSkill, SKILL_FILE, } from "./skills.js";
|
|
9
9
|
const str = (v, fallback = "") => (typeof v === "string" ? v : fallback);
|
|
@@ -23,6 +23,19 @@ function safeJoin(cwd, p) {
|
|
|
23
23
|
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
24
24
|
throw new Error(`path escapes workspace: ${p}`);
|
|
25
25
|
}
|
|
26
|
+
// symlinks inside the workspace can point anywhere — resolve the real
|
|
27
|
+
// target and confine THAT too (path.resolve alone doesn't follow links)
|
|
28
|
+
let real = abs;
|
|
29
|
+
try {
|
|
30
|
+
real = realpathSync(abs);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* target may not exist yet (write_file) — lexical check above still holds */
|
|
34
|
+
}
|
|
35
|
+
const relReal = path.relative(cwd, real);
|
|
36
|
+
if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
|
|
37
|
+
throw new Error(`path escapes workspace via symlink: ${p}`);
|
|
38
|
+
}
|
|
26
39
|
return abs;
|
|
27
40
|
}
|
|
28
41
|
/** Run a command in its own process group; kill the whole group on timeout. */
|
|
@@ -699,6 +712,110 @@ export const TOOLS = [
|
|
|
699
712
|
return { ok: res.ok || text.length > 0, result: clipText(text, num(args.limit, 20_000)) };
|
|
700
713
|
},
|
|
701
714
|
},
|
|
715
|
+
{
|
|
716
|
+
name: "spawn_agent",
|
|
717
|
+
description: "Spawn a sub-agent to work a task in parallel (same workspace, own session). " +
|
|
718
|
+
'context "none" = fresh start with just the task; "fork" = inherit this conversation ' +
|
|
719
|
+
"byte-exactly (provider prefix cache stays warm) before the task is appended. " +
|
|
720
|
+
"Returns the sub-agent id immediately; its finish summary is delivered back to you.",
|
|
721
|
+
parameters: {
|
|
722
|
+
type: "object",
|
|
723
|
+
properties: {
|
|
724
|
+
task: { type: "string", description: "self-contained instructions for the sub-agent" },
|
|
725
|
+
context: { type: "string", enum: ["none", "fork"], description: "default none" },
|
|
726
|
+
name: { type: "string", description: "optional short name fragment for the id" },
|
|
727
|
+
},
|
|
728
|
+
required: ["task"],
|
|
729
|
+
},
|
|
730
|
+
async run(args, ctx) {
|
|
731
|
+
const sa = ctx.subAgents;
|
|
732
|
+
if (!sa)
|
|
733
|
+
return { ok: false, result: "sub-agents are not available here" };
|
|
734
|
+
const task = str(args.task).trim();
|
|
735
|
+
if (!task)
|
|
736
|
+
return { ok: false, result: "task required" };
|
|
737
|
+
const context = args.context === "fork" ? "fork" : "none";
|
|
738
|
+
try {
|
|
739
|
+
const r = await sa.spawn({
|
|
740
|
+
task,
|
|
741
|
+
context,
|
|
742
|
+
name: str(args.name).trim() || undefined,
|
|
743
|
+
});
|
|
744
|
+
return { ok: true, result: `spawned sub-agent ${r.id} — it works in parallel; use list_children / message_agent to steer it, stop_children to halt` };
|
|
745
|
+
}
|
|
746
|
+
catch (e) {
|
|
747
|
+
return { ok: false, result: `spawn failed: ${e.message}` };
|
|
748
|
+
}
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
name: "list_children",
|
|
753
|
+
description: "List your live sub-agents: id, status, current goal.",
|
|
754
|
+
parameters: { type: "object", properties: {} },
|
|
755
|
+
async run(_args, ctx) {
|
|
756
|
+
const sa = ctx.subAgents;
|
|
757
|
+
if (!sa)
|
|
758
|
+
return { ok: false, result: "sub-agents are not available here" };
|
|
759
|
+
const kids = sa.list();
|
|
760
|
+
if (!kids.length)
|
|
761
|
+
return { ok: true, result: "(no sub-agents)" };
|
|
762
|
+
return {
|
|
763
|
+
ok: true,
|
|
764
|
+
result: kids
|
|
765
|
+
.map((k) => `${k.id} · ${k.status} · ${clip(k.goal, 60)}`)
|
|
766
|
+
.join("\n"),
|
|
767
|
+
};
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
{
|
|
771
|
+
name: "stop_children",
|
|
772
|
+
description: "Stop one or more of your sub-agents. Without ids: stops ALL of them (and their descendants).",
|
|
773
|
+
parameters: {
|
|
774
|
+
type: "object",
|
|
775
|
+
properties: {
|
|
776
|
+
ids: { type: "array", items: { type: "string" }, description: "sub-agent ids; omit for all" },
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
async run(args, ctx) {
|
|
780
|
+
const sa = ctx.subAgents;
|
|
781
|
+
if (!sa)
|
|
782
|
+
return { ok: false, result: "sub-agents are not available here" };
|
|
783
|
+
const ids = Array.isArray(args.ids) ? args.ids.map(String) : undefined;
|
|
784
|
+
const r = await sa.stop(ids);
|
|
785
|
+
return {
|
|
786
|
+
ok: true,
|
|
787
|
+
result: r.stopped.length ? `stopped: ${r.stopped.join(", ")}` : "(nothing running to stop)",
|
|
788
|
+
};
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
name: "message_agent",
|
|
793
|
+
description: "Send a message to a specific sub-agent (steer it mid-flight or answer its ask_user question).",
|
|
794
|
+
parameters: {
|
|
795
|
+
type: "object",
|
|
796
|
+
properties: {
|
|
797
|
+
id: { type: "string", description: "sub-agent id" },
|
|
798
|
+
text: { type: "string" },
|
|
799
|
+
},
|
|
800
|
+
required: ["id", "text"],
|
|
801
|
+
},
|
|
802
|
+
async run(args, ctx) {
|
|
803
|
+
const sa = ctx.subAgents;
|
|
804
|
+
if (!sa)
|
|
805
|
+
return { ok: false, result: "sub-agents are not available here" };
|
|
806
|
+
const id = str(args.id);
|
|
807
|
+
const text = str(args.text);
|
|
808
|
+
if (!text.trim())
|
|
809
|
+
return { ok: false, result: "text required" };
|
|
810
|
+
try {
|
|
811
|
+
await sa.message(id, text);
|
|
812
|
+
return { ok: true, result: `message delivered to ${id}` };
|
|
813
|
+
}
|
|
814
|
+
catch (e) {
|
|
815
|
+
return { ok: false, result: e.message };
|
|
816
|
+
}
|
|
817
|
+
},
|
|
818
|
+
},
|
|
702
819
|
{
|
|
703
820
|
name: "load_skill",
|
|
704
821
|
description: "Load a skill's full instructions by name. Use when the system prompt's skill list " +
|
|
@@ -812,12 +929,17 @@ export function toolSpecs() {
|
|
|
812
929
|
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
813
930
|
}));
|
|
814
931
|
}
|
|
932
|
+
/** tools that mutate the workspace — blocked for read-only personas */
|
|
933
|
+
const MUTATING_TOOLS = new Set(["write_file", "edit_file", "apply_patch", "bash"]);
|
|
815
934
|
export async function executeTool(name, rawArgs, ctx) {
|
|
816
935
|
const def = TOOLS.find((t) => t.name === name);
|
|
817
936
|
if (!def)
|
|
818
937
|
return { ok: false, result: `unknown tool: ${name}` };
|
|
819
938
|
if (ctx.signal?.aborted)
|
|
820
939
|
return { ok: false, result: "aborted (harness shutdown)" };
|
|
940
|
+
// read-only personas (researcher/reviewer) are enforced, not just asked
|
|
941
|
+
if (ctx.readOnly && MUTATING_TOOLS.has(name))
|
|
942
|
+
return { ok: false, result: `${name} is blocked: this agent runs with read-only tools` };
|
|
821
943
|
let args;
|
|
822
944
|
try {
|
|
823
945
|
args = rawArgs ? JSON.parse(rawArgs) : {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schema for teapot's config — the single source of truth for what a
|
|
3
|
+
* valid config looks like. Used to gate web-UI edits (PUT /api/config) and
|
|
4
|
+
* any programmatic writes, with human-readable error messages.
|
|
5
|
+
*
|
|
6
|
+
* NOTE: validation gates, it does not become the storage format — the master
|
|
7
|
+
* persists the raw user JSON so unknown fields survive round-trips.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
export const ProviderSchema = z.object({
|
|
11
|
+
baseUrl: z.union([z.string().url("baseUrl must be a valid URL"), z.literal("")]).optional(),
|
|
12
|
+
apiKey: z.string().optional(),
|
|
13
|
+
model: z.string().optional(),
|
|
14
|
+
});
|
|
15
|
+
export const AgentEntrySchema = z.object({
|
|
16
|
+
id: z
|
|
17
|
+
.string()
|
|
18
|
+
.regex(/^[A-Za-z0-9._-]{1,64}$/, "id: letters/digits/dots/dashes only, max 64"),
|
|
19
|
+
workspace: z.string().min(1, "workspace required"),
|
|
20
|
+
provider: z.string().optional(),
|
|
21
|
+
model: z.string().optional(),
|
|
22
|
+
baseUrl: z.string().optional(),
|
|
23
|
+
apiKey: z.string().optional(),
|
|
24
|
+
contextWindowTokens: z.number().int().positive().optional(),
|
|
25
|
+
parent: z.string().optional(),
|
|
26
|
+
});
|
|
27
|
+
export const TaskSchema = z.object({
|
|
28
|
+
id: z.string().min(1, "task id required").max(64),
|
|
29
|
+
agent: z.string().min(1, "task agent required"),
|
|
30
|
+
schedule: z.string().min(3, "schedule required (cron or 'every Nm')"),
|
|
31
|
+
prompt: z.string().min(1, "prompt required"),
|
|
32
|
+
forked: z.boolean().optional(),
|
|
33
|
+
});
|
|
34
|
+
/** the patch shape accepted by PUT /api/config */
|
|
35
|
+
export const ConfigPatchSchema = z.object({
|
|
36
|
+
providers: z.record(z.string().min(1, "provider name required"), ProviderSchema).optional(),
|
|
37
|
+
defaultProvider: z.string().optional(),
|
|
38
|
+
progressIntervalMs: z.number().int().min(10_000, "progress interval must be ≥ 10s").optional(),
|
|
39
|
+
progressMinChars: z.number().int().min(100).optional(),
|
|
40
|
+
contextTokenBudget: z.number().int().min(1_000).optional(),
|
|
41
|
+
contextWindowTokens: z.number().int().min(1_000).optional(),
|
|
42
|
+
maxSpawnDepth: z.number().int().min(0).max(8).optional(),
|
|
43
|
+
tasks: z.array(TaskSchema).optional(),
|
|
44
|
+
});
|
|
45
|
+
/** Format a ZodError into a compact operator-readable message. */
|
|
46
|
+
export function formatZodError(error) {
|
|
47
|
+
return error.issues
|
|
48
|
+
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
49
|
+
.join("; ")
|
|
50
|
+
.slice(0, 500);
|
|
51
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,21 +1,45 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* teapot master entry point.
|
|
4
|
-
* Usage: teapot [config.json]
|
|
4
|
+
* Usage: teapot [--port N] [--config file.json] [config.json]
|
|
5
5
|
* Config may also come from TEAPOT_* env vars (see src/master.ts).
|
|
6
|
+
* First run (no config file): boot anyway and finish setup in the web UI.
|
|
6
7
|
*/
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
7
9
|
import { loadConfig, resolveConfigPath, Master } from "./master.js";
|
|
8
10
|
import { buildApp, serveApp } from "./server/api.js";
|
|
9
11
|
async function main() {
|
|
10
|
-
|
|
12
|
+
// CLI: teapot [--port N] [-p N] [--config file] [-c file] [config.json]
|
|
13
|
+
let cfgArg;
|
|
14
|
+
let portOverride;
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
for (let i = 0; i < args.length; i++) {
|
|
17
|
+
const a = args[i];
|
|
18
|
+
if (a === "--port" || a === "-p")
|
|
19
|
+
portOverride = Number(args[++i]);
|
|
20
|
+
else if (a === "--config" || a === "-c")
|
|
21
|
+
cfgArg = args[++i];
|
|
22
|
+
else if (!cfgArg && !a.startsWith("-"))
|
|
23
|
+
cfgArg = a;
|
|
24
|
+
}
|
|
25
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
26
|
+
console.log("teapot [--port N] [--config file.json] [config.json]");
|
|
27
|
+
console.log(" env: TEAPOT_PORT, TEAPOT_CONFIG_DIR, TEAPOT_DATA_DIR, TEAPOT_API_TOKEN");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const configPath = resolveConfigPath(cfgArg);
|
|
31
|
+
const configExisted = existsSync(configPath);
|
|
11
32
|
const config = loadConfig(configPath);
|
|
12
|
-
|
|
33
|
+
if (portOverride !== undefined && !Number.isNaN(portOverride))
|
|
34
|
+
config.port = portOverride;
|
|
35
|
+
console.log(`[teapot] config: ${configPath}${configExisted ? "" : " (not found — first run)"}`);
|
|
13
36
|
const hasProviders = Object.keys(config.providers ?? {}).length > 0;
|
|
14
37
|
if (!config.llm.apiKey && !hasProviders)
|
|
15
|
-
console.warn("[teapot] warning: no API key configured");
|
|
38
|
+
console.warn("[teapot] warning: no API key configured — finish setup in the web UI");
|
|
16
39
|
if (!config.llm.model && !hasProviders)
|
|
17
40
|
console.warn("[teapot] warning: no model configured");
|
|
18
41
|
const master = new Master(config, configPath);
|
|
42
|
+
master.configFileExists = configExisted;
|
|
19
43
|
await master.start();
|
|
20
44
|
const app = buildApp(master);
|
|
21
45
|
serveApp(app, config.port);
|
package/dist/master.js
CHANGED
|
@@ -7,9 +7,46 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSy
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import os from "node:os";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
10
11
|
import { Agent } from "./agent/agent.js";
|
|
11
12
|
import { parseSchedule, matches, nextFireAt } from "./scheduler/cron.js";
|
|
12
13
|
import { bus } from "./bus.js";
|
|
14
|
+
/** how deep sub-agent spawning may nest (parent=0, its subs=1, …) */
|
|
15
|
+
const MAX_SPAWN_DEPTH = 3;
|
|
16
|
+
/**
|
|
17
|
+
* Skills shipped with the package — resolved relative to this module so it
|
|
18
|
+
* works from a global `npm install -g` install, an npx cache, or a repo
|
|
19
|
+
* checkout alike. Lowest-priority skill root (workspace > global > bundled).
|
|
20
|
+
*/
|
|
21
|
+
const BUNDLED_SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../skills");
|
|
22
|
+
/** default sub-agent personas — mentionable from the composer (@name) and
|
|
23
|
+
* usable by agents via spawn_agent({persona}) */
|
|
24
|
+
export const SUB_PERSONAS = {
|
|
25
|
+
reviewer: {
|
|
26
|
+
label: "🔍 reviewer",
|
|
27
|
+
directive: "ROLE: code reviewer. Inspect the change set, judge correctness, style, tests; report concrete findings as a prioritized list.",
|
|
28
|
+
readOnly: true,
|
|
29
|
+
},
|
|
30
|
+
tester: {
|
|
31
|
+
label: "🧪 tester",
|
|
32
|
+
directive: "ROLE: test engineer. Write and run tests for the task at hand, hunt edge cases, report pass/fail with exact commands.",
|
|
33
|
+
},
|
|
34
|
+
researcher: {
|
|
35
|
+
label: "🔎 researcher",
|
|
36
|
+
directive: "ROLE: read-only explorer. Search the codebase/docs, map how things work, and report a compact briefing with file:line references.",
|
|
37
|
+
readOnly: true,
|
|
38
|
+
},
|
|
39
|
+
implementer: {
|
|
40
|
+
label: "🔨 implementer",
|
|
41
|
+
directive: "ROLE: hands-on implementer. Make the change end-to-end (code + tests), keep edits small and verified, then report what changed and why.",
|
|
42
|
+
},
|
|
43
|
+
"gyaru-reviewer": {
|
|
44
|
+
label: "💅 gyaru reviewer",
|
|
45
|
+
directive: "ROLE: pre-commit diff reviewer in a blunt gyaru voice. Read the whole diff and hunt: debug leftovers, unclear UI copy, silent data-loss risks, convention drift. Every finding must be concrete — suggested fix or an explicit shrug.",
|
|
46
|
+
readOnly: true,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
;
|
|
13
50
|
const CONFIG_DIR = process.env.TEAPOT_CONFIG_DIR ??
|
|
14
51
|
path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "teapot-coding-agent");
|
|
15
52
|
const DATA_DIR = process.env.TEAPOT_DATA_DIR ??
|
|
@@ -141,6 +178,8 @@ export class Master {
|
|
|
141
178
|
startedAt = Date.now();
|
|
142
179
|
config;
|
|
143
180
|
configPath;
|
|
181
|
+
/** false on first boot with no config file → web UI shows the setup wizard */
|
|
182
|
+
configFileExists = true;
|
|
144
183
|
constructor(config, configPath) {
|
|
145
184
|
this.config = config;
|
|
146
185
|
this.configPath = configPath;
|
|
@@ -176,13 +215,17 @@ export class Master {
|
|
|
176
215
|
a.opts.progressMinChars =
|
|
177
216
|
patch.progressMinChars;
|
|
178
217
|
}
|
|
218
|
+
if (patch.contextTokenBudget !== undefined)
|
|
219
|
+
this.config.contextTokenBudget = patch.contextTokenBudget;
|
|
220
|
+
if (patch.maxSpawnDepth !== undefined)
|
|
221
|
+
this.config.maxSpawnDepth = patch.maxSpawnDepth;
|
|
179
222
|
if (patch.tasks) {
|
|
180
223
|
this.config.tasks = patch.tasks;
|
|
181
224
|
// rebuild schedule table live
|
|
182
225
|
this.tasks = patch.tasks.map((t) => ({
|
|
183
226
|
task: t,
|
|
184
227
|
schedule: parseSchedule(t.schedule),
|
|
185
|
-
lastRunMin: -1,
|
|
228
|
+
lastRunMin: t.lastRunMin ?? -1,
|
|
186
229
|
}));
|
|
187
230
|
}
|
|
188
231
|
this.saveConfig();
|
|
@@ -203,12 +246,59 @@ export class Master {
|
|
|
203
246
|
this.tasks.push({
|
|
204
247
|
task: t,
|
|
205
248
|
schedule: parseSchedule(t.schedule),
|
|
206
|
-
lastRunMin: -1,
|
|
249
|
+
lastRunMin: t.lastRunMin ?? -1,
|
|
207
250
|
});
|
|
208
251
|
}
|
|
209
252
|
// single low-frequency tick for everything periodic (idle cost ≈ 0)
|
|
210
253
|
setInterval(() => void this.tick(), 15_000).unref();
|
|
211
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* First-run wizard bootstrap: write a minimal config file, apply it to the
|
|
257
|
+
* running master, and optionally create + start the first agent. Only
|
|
258
|
+
* available while no config file exists — after this, edits go through
|
|
259
|
+
* PUT /api/config behind whatever auth is configured.
|
|
260
|
+
*/
|
|
261
|
+
async applySetup(body) {
|
|
262
|
+
if (this.configFileExists)
|
|
263
|
+
throw new Error("setup already completed — edit the config file instead");
|
|
264
|
+
const llm = {
|
|
265
|
+
baseUrl: body.baseUrl,
|
|
266
|
+
...(body.apiKey ? { apiKey: body.apiKey } : {}),
|
|
267
|
+
model: body.model,
|
|
268
|
+
};
|
|
269
|
+
const raw = {
|
|
270
|
+
llm,
|
|
271
|
+
...(body.password ? { password: body.password } : {}),
|
|
272
|
+
agents: [],
|
|
273
|
+
};
|
|
274
|
+
const dir = path.dirname(this.configPath);
|
|
275
|
+
mkdirSync(dir, { recursive: true });
|
|
276
|
+
writeFileSync(this.configPath, JSON.stringify(raw, null, 2) + "\n");
|
|
277
|
+
// hot-apply to the running master (no restart needed)
|
|
278
|
+
this.config.llm = { ...this.config.llm, ...llm };
|
|
279
|
+
if (body.password)
|
|
280
|
+
this.config.password = body.password;
|
|
281
|
+
this.configFileExists = true;
|
|
282
|
+
this.raw.llm = llm;
|
|
283
|
+
if (body.password)
|
|
284
|
+
this.raw.password = body.password;
|
|
285
|
+
let agentId;
|
|
286
|
+
if (body.workspace?.trim()) {
|
|
287
|
+
const ws = path.resolve(body.workspace.replace(/^~/, process.env.HOME ?? "~"));
|
|
288
|
+
await mkdirSync(ws, { recursive: true });
|
|
289
|
+
const name = (body.agentName?.trim() || path.basename(ws))
|
|
290
|
+
.replace(/[^\w.-]/g, "-")
|
|
291
|
+
.slice(0, 40) || "agent";
|
|
292
|
+
let id = name;
|
|
293
|
+
let n = 2;
|
|
294
|
+
while (this.agents.has(id))
|
|
295
|
+
id = `${name.slice(0, 38)}-${n++}`;
|
|
296
|
+
const agent = await this.addAgent({ id, workspace: ws }, { persist: true, fresh: true });
|
|
297
|
+
agentId = id;
|
|
298
|
+
agent.start("created by setup wizard");
|
|
299
|
+
}
|
|
300
|
+
return { ok: true, agentId };
|
|
301
|
+
}
|
|
212
302
|
/**
|
|
213
303
|
* Create an agent; optionally persist it to the config file.
|
|
214
304
|
* Each incarnation gets its own session directory under
|
|
@@ -233,6 +323,9 @@ export class Master {
|
|
|
233
323
|
};
|
|
234
324
|
if (!llm.model)
|
|
235
325
|
throw new Error(`agent ${ac.id}: no model configured (set model on the agent or on its provider)`);
|
|
326
|
+
// spawn-tree depth: computed from the config chain (ac may not be
|
|
327
|
+
// registered yet when this runs — spawnChildFor persists AFTER creation)
|
|
328
|
+
const myDepth = ac.parent ? this.depthOf(ac.parent) + 1 : 0;
|
|
236
329
|
const sessionDir = this.resolveSessionDir(ac.id, opts.fresh === true);
|
|
237
330
|
await mkdirSync(sessionDir, { recursive: true });
|
|
238
331
|
const agent = new Agent({
|
|
@@ -248,7 +341,12 @@ export class Master {
|
|
|
248
341
|
? { contextWindowTokens: (ac.contextWindowTokens ?? this.config.contextWindowTokens) }
|
|
249
342
|
: {}),
|
|
250
343
|
globalSkillsDir: path.join(CONFIG_DIR, "skills"),
|
|
344
|
+
bundledSkillsDir: BUNDLED_SKILLS_DIR,
|
|
251
345
|
provider: provName,
|
|
346
|
+
spawnDepth: myDepth,
|
|
347
|
+
...(ac.readOnly ? { readOnlyTools: true } : {}),
|
|
348
|
+
...(ac.parent ? { parent: ac.parent } : {}),
|
|
349
|
+
...(ac.chatFn ? { chatFn: ac.chatFn } : {}),
|
|
252
350
|
});
|
|
253
351
|
// console line + broadcast: the web UI only refreshes on bus traffic, so
|
|
254
352
|
// every appended event must reach it (otherwise messages sit invisible
|
|
@@ -256,7 +354,24 @@ export class Master {
|
|
|
256
354
|
agent.log.onEvent = (e) => {
|
|
257
355
|
printAgentEvent(e);
|
|
258
356
|
bus.emit("update", { kind: "event", agentId: e.agent, event: e });
|
|
357
|
+
this.onChildEvent(ac, e);
|
|
259
358
|
};
|
|
359
|
+
// sub-agent management hooks — only when this agent can legally spawn
|
|
360
|
+
if (myDepth < this.maxSpawnDepth()) {
|
|
361
|
+
const self = agent;
|
|
362
|
+
const hooks = {
|
|
363
|
+
depth: myDepth,
|
|
364
|
+
spawn: (o) => this.spawnChildFor(self, o),
|
|
365
|
+
list: () => this.childrenOf(ac.id).map((c) => ({
|
|
366
|
+
id: c.id,
|
|
367
|
+
status: c.agent.status,
|
|
368
|
+
goal: c.agent.goal.text,
|
|
369
|
+
})),
|
|
370
|
+
stop: (ids) => this.stopChildrenFor(ac.id, ids),
|
|
371
|
+
message: (id, text) => this.messageChild(ac.id, id, text),
|
|
372
|
+
};
|
|
373
|
+
agent.toolCtx.subAgents = hooks;
|
|
374
|
+
}
|
|
260
375
|
await agent.init();
|
|
261
376
|
this.agents.set(ac.id, agent);
|
|
262
377
|
if (opts.persist) {
|
|
@@ -265,6 +380,156 @@ export class Master {
|
|
|
265
380
|
}
|
|
266
381
|
return agent;
|
|
267
382
|
}
|
|
383
|
+
/** spawn-tree depth of an agent (0 = top level); unknown → 0 */
|
|
384
|
+
depthOf(id, seen = new Set()) {
|
|
385
|
+
const cap = this.maxSpawnDepth();
|
|
386
|
+
let depth = 0;
|
|
387
|
+
let cur = this.config.agents.find((a) => a.id === id);
|
|
388
|
+
while (cur?.parent && !seen.has(cur.id) && depth < cap + 2) {
|
|
389
|
+
seen.add(cur.id);
|
|
390
|
+
cur = this.config.agents.find((a) => a.id === cur.parent);
|
|
391
|
+
depth++;
|
|
392
|
+
}
|
|
393
|
+
return depth;
|
|
394
|
+
}
|
|
395
|
+
/** configured nesting limit (config.maxSpawnDepth, default 3) */
|
|
396
|
+
maxSpawnDepth() {
|
|
397
|
+
const v = this.config.maxSpawnDepth;
|
|
398
|
+
return typeof v === "number" && v >= 0 ? v : MAX_SPAWN_DEPTH;
|
|
399
|
+
}
|
|
400
|
+
/** direct children of an agent, with their Agent instances */
|
|
401
|
+
childrenOf(id) {
|
|
402
|
+
const kids = this.config.agents.filter((a) => a.parent === id);
|
|
403
|
+
const out = [];
|
|
404
|
+
for (const k of kids) {
|
|
405
|
+
const inst = this.agents.get(k.id);
|
|
406
|
+
if (inst)
|
|
407
|
+
out.push({ id: k.id, agent: inst });
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Create a sub-agent on behalf of `parent`. context "fork" writes a
|
|
413
|
+
* sub_fork header into the child log pointing at the parent's current
|
|
414
|
+
* tip — the parent's history is NEVER copied into the child's file; the
|
|
415
|
+
* child resolves it at restore time.
|
|
416
|
+
*/
|
|
417
|
+
async spawnChildFor(parent, o) {
|
|
418
|
+
const parentId = parent.opts_id();
|
|
419
|
+
const persona = o.persona && Object.prototype.hasOwnProperty.call(SUB_PERSONAS, o.persona)
|
|
420
|
+
? o.persona
|
|
421
|
+
: undefined;
|
|
422
|
+
const base = `${parentId}-sub${persona ? `-${persona}` : ""}${o.name ? `-${o.name.replace(/[^\w.-]/g, "-").slice(0, 24)}` : ""}`.slice(0, 60);
|
|
423
|
+
let id = base;
|
|
424
|
+
let n = 2;
|
|
425
|
+
while (this.agents.has(id) || this.config.agents.some((a) => a.id === id))
|
|
426
|
+
id = `${base.slice(0, 56)}-${n++}`;
|
|
427
|
+
// persona directives shape how the sub approaches the task
|
|
428
|
+
const directive = persona ? `${SUB_PERSONAS[persona].label}\n${SUB_PERSONAS[persona].directive}\n\n` : "";
|
|
429
|
+
const pcfg = this.config.agents.find((a) => a.id === parentId);
|
|
430
|
+
let forkTip;
|
|
431
|
+
let forkBranch;
|
|
432
|
+
if (o.context === "fork") {
|
|
433
|
+
// capture the fork tip BEFORE creating the child (parent keeps running)
|
|
434
|
+
forkTip = parent.log.lastEventId(parent.currentBranch);
|
|
435
|
+
forkBranch = parent.currentBranch;
|
|
436
|
+
}
|
|
437
|
+
const child = await this.addAgent({
|
|
438
|
+
id,
|
|
439
|
+
workspace: parent.workspace,
|
|
440
|
+
provider: pcfg?.provider,
|
|
441
|
+
model: pcfg?.model,
|
|
442
|
+
contextWindowTokens: pcfg?.contextWindowTokens,
|
|
443
|
+
parent: parentId,
|
|
444
|
+
...(persona && SUB_PERSONAS[persona]?.readOnly ? { readOnly: true } : {}),
|
|
445
|
+
}, { persist: true, fresh: true });
|
|
446
|
+
if (o.context === "fork" && forkTip) {
|
|
447
|
+
await child.log.append("sub_fork", id, "br0", {
|
|
448
|
+
parentAgent: parentId,
|
|
449
|
+
parentSession: path.basename(parent.snapshot().sessionDir),
|
|
450
|
+
parentBranch: forkBranch,
|
|
451
|
+
upToEvent: forkTip,
|
|
452
|
+
});
|
|
453
|
+
// the inherited prefix becomes visible history for the child's loop
|
|
454
|
+
child.importMessages(parent.exportMessages());
|
|
455
|
+
}
|
|
456
|
+
await child.setGoal(`${directive}${o.task}`.slice(0, 2000));
|
|
457
|
+
await child.enqueuePrompt((o.context === "fork"
|
|
458
|
+
? `[harness] You are sub-agent ${id}, spawned by @${parentId} with the conversation above. `
|
|
459
|
+
: `[harness] You are sub-agent ${id}, spawned by @${parentId}. `) +
|
|
460
|
+
`Work solely on this task:\n\n${directive}${o.task}`, "harness");
|
|
461
|
+
child.start(`spawned by ${parentId}`);
|
|
462
|
+
console.log(`[teapot] sub-agent ${id} spawned by ${parentId} (context: ${o.context ?? "none"}${persona ? `, persona: ${persona}` : ""})`);
|
|
463
|
+
return { id };
|
|
464
|
+
}
|
|
465
|
+
/** Stop direct children (and their descendants by default) of an agent. */
|
|
466
|
+
async stopChildrenFor(parentId, ids) {
|
|
467
|
+
const stopped = [];
|
|
468
|
+
const walk = (pid) => {
|
|
469
|
+
for (const { id, agent } of this.childrenOf(pid)) {
|
|
470
|
+
if (ids && !ids.includes(id)) {
|
|
471
|
+
// still descend: stopping a parent implies stopping its subtree
|
|
472
|
+
walk(id);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
agent.stop("stopped by parent agent");
|
|
476
|
+
stopped.push(id);
|
|
477
|
+
if (ids) {
|
|
478
|
+
// explicit id → also take its subtree
|
|
479
|
+
const sub = this.childrenOf(id);
|
|
480
|
+
for (const { id: gid, agent: g } of sub) {
|
|
481
|
+
g.stop("stopped with parent");
|
|
482
|
+
stopped.push(gid);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
walk(id); // full-subtree default
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
walk(parentId);
|
|
491
|
+
return { stopped };
|
|
492
|
+
}
|
|
493
|
+
/** Deliver a parent's message into a child's mailbox (and wake it). */
|
|
494
|
+
async messageChild(parentId, childId, text) {
|
|
495
|
+
const cfg = this.config.agents.find((a) => a.id === childId);
|
|
496
|
+
if (!cfg || cfg.parent !== parentId)
|
|
497
|
+
throw new Error(`not your sub-agent: ${childId}`);
|
|
498
|
+
const child = this.agents.get(childId);
|
|
499
|
+
if (!child)
|
|
500
|
+
throw new Error(`sub-agent not running: ${childId}`);
|
|
501
|
+
child.enqueuePrompt(`[harness] Message from @${parentId}:\n\n${text}`, "harness");
|
|
502
|
+
if (child.status !== "running")
|
|
503
|
+
child.start(`message from ${parentId}`);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Mirror interesting child events into the parent's timeline (tagged with
|
|
507
|
+
* the acting sub id) and forward terminal outcomes to the parent's mailbox.
|
|
508
|
+
*/
|
|
509
|
+
onChildEvent(ac, e) {
|
|
510
|
+
const parentId = ac.parent;
|
|
511
|
+
if (!parentId)
|
|
512
|
+
return;
|
|
513
|
+
const parent = this.agents.get(parentId);
|
|
514
|
+
if (!parent)
|
|
515
|
+
return;
|
|
516
|
+
// forward outcomes so the parent hears the result without polling
|
|
517
|
+
if (e.type === "message" && e.data.final) {
|
|
518
|
+
const summary = String(e.data.content ?? "").trim();
|
|
519
|
+
parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} finished. Final report:\n${summary.slice(0, 2000) || "(no summary)"}`, "harness");
|
|
520
|
+
}
|
|
521
|
+
else if (e.type === "error") {
|
|
522
|
+
parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} hit an error: ${String(e.data.message ?? "").slice(0, 500)}`, "harness");
|
|
523
|
+
}
|
|
524
|
+
// mirror feed-worthy activity; nested subs keep their original actor id
|
|
525
|
+
const MIRROR = new Set(["prompt", "message", "tool_call", "tool_result", "progress", "error", "question", "state"]);
|
|
526
|
+
if (!MIRROR.has(e.type))
|
|
527
|
+
return;
|
|
528
|
+
const inner = e.type === "sub" ? e.data : null;
|
|
529
|
+
const actor = inner?.sub ?? ac.id;
|
|
530
|
+
const payload = inner ? { sub: actor, type: inner.type, data: inner.data } : { sub: ac.id, type: e.type, data: e.data };
|
|
531
|
+
void parent.log.append("sub", parent.currentSession, parent.currentBranch, payload);
|
|
532
|
+
}
|
|
268
533
|
/** sessions root + helpers */
|
|
269
534
|
sessionsRoot() {
|
|
270
535
|
return path.join(this.config.dataDir, "sessions");
|
|
@@ -390,6 +655,9 @@ export class Master {
|
|
|
390
655
|
continue; // dedupe within the same minute
|
|
391
656
|
t.lastRunMin = minuteKey;
|
|
392
657
|
t.lastRunAt = Date.now();
|
|
658
|
+
// persist so a master restart doesn't re-fire the same minute
|
|
659
|
+
t.task.lastRunMin = minuteKey;
|
|
660
|
+
this.saveConfig();
|
|
393
661
|
const agent = this.agents.get(t.task.agent);
|
|
394
662
|
if (!agent)
|
|
395
663
|
continue;
|