@xingjisoft/neutrino 0.1.7 → 0.2.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.
@@ -0,0 +1,134 @@
1
+ import { describeError } from "./retry.js";
2
+ /** Enough for real work, few enough that a runaway child cannot cost a fortune. */
3
+ export const SUBAGENT_MAX_ROUNDS = 12;
4
+ /** A report is evidence for the parent, not a transcript of the child. */
5
+ export const SUBAGENT_MAX_REPORT = 8000;
6
+ function parseArgs(raw) {
7
+ const text = raw?.trim() ?? "";
8
+ if (!text)
9
+ return {};
10
+ try {
11
+ const value = JSON.parse(text);
12
+ if (value && typeof value === "object")
13
+ return value;
14
+ }
15
+ catch {
16
+ // Hand the raw payload over instead of failing the whole call.
17
+ }
18
+ return { value: text };
19
+ }
20
+ /** The task brief handed to the child: where it works, what to do, what it must know. */
21
+ export function buildSubAgentTask(input) {
22
+ const parts = [];
23
+ if (input.cwd)
24
+ parts.push(`Working directory: ${input.cwd}`);
25
+ parts.push(`Task:\n${input.task.trim()}`);
26
+ const context = (input.context ?? "").trim();
27
+ if (context)
28
+ parts.push(`What the agent that delegated this task already knows:\n${context}`);
29
+ return parts.join("\n\n");
30
+ }
31
+ /**
32
+ * Run one sub-agent to its report.
33
+ *
34
+ * The child sees only its own task, its own tool results and the parent's
35
+ * approval decisions. Nothing it says reaches the parent except the last
36
+ * message, which is why the sub-agent prompt insists that the report be
37
+ * complete on its own.
38
+ */
39
+ export async function runSubAgent(options) {
40
+ const maxRounds = Math.max(1, options.maxRounds ?? SUBAGENT_MAX_ROUNDS);
41
+ const messages = [
42
+ { role: "system", content: options.system },
43
+ { role: "user", content: options.task },
44
+ ];
45
+ let report = "";
46
+ let calls = 0;
47
+ const aborted = (rounds) => ({ report, aborted: true, exhausted: false, rounds, calls });
48
+ for (let round = 1; round <= maxRounds; round++) {
49
+ if (options.cancelled?.())
50
+ return aborted(round - 1);
51
+ options.onProgress?.("thinking");
52
+ const client = options.createClient();
53
+ let content = "";
54
+ for await (const chunk of client.streamTurn(messages, options.tools, {
55
+ onRetry: options.onRetry,
56
+ cancelled: options.cancelled,
57
+ })) {
58
+ if (options.cancelled?.())
59
+ return aborted(round - 1);
60
+ content += chunk;
61
+ }
62
+ if (client.usage)
63
+ options.onUsage?.(client.usage);
64
+ const toolCalls = client.toolCalls;
65
+ if (content.trim())
66
+ report = content.trim();
67
+ if (content.trim() || toolCalls.length > 0) {
68
+ messages.push({
69
+ role: "assistant",
70
+ content,
71
+ ...(toolCalls.length > 0
72
+ ? {
73
+ tool_calls: toolCalls.map((call) => ({
74
+ id: call.id,
75
+ type: "function",
76
+ function: { name: call.name, arguments: call.arguments },
77
+ })),
78
+ }
79
+ : {}),
80
+ });
81
+ }
82
+ if (toolCalls.length === 0) {
83
+ return { report, aborted: false, exhausted: false, rounds: round, calls };
84
+ }
85
+ for (const call of toolCalls) {
86
+ if (options.cancelled?.())
87
+ return aborted(round);
88
+ const args = parseArgs(call.arguments);
89
+ calls++;
90
+ options.onProgress?.(call.name);
91
+ const decision = await options.approve(call.name, args);
92
+ if (options.cancelled?.())
93
+ return aborted(round);
94
+ let result;
95
+ if (decision.approved) {
96
+ try {
97
+ result = await options.execute(call.name, args);
98
+ }
99
+ catch (error) {
100
+ result = `[Error: ${describeError(error)}]`;
101
+ }
102
+ }
103
+ else {
104
+ // The note goes to the child, not to the user: it must not retry the same
105
+ // call unchanged, and it must say what was blocked in its report.
106
+ result =
107
+ decision.source === "input"
108
+ ? `[Not executed: ${decision.reason}]`
109
+ : `[Not executed: approval denied (${decision.source}): ${decision.reason}] ` +
110
+ "Do not retry this call unchanged; work around it or report it as blocked.";
111
+ }
112
+ messages.push({ role: "tool", tool_call_id: call.id, content: result });
113
+ }
114
+ }
115
+ return { report, aborted: false, exhausted: true, rounds: maxRounds, calls };
116
+ }
117
+ /** The tool result the parent sees: the report, or why there is none. */
118
+ export function formatSubAgentResult(run) {
119
+ if (run.aborted) {
120
+ return "[Sub-agent stopped: the turn was paused or replaced before it reported back.]";
121
+ }
122
+ const report = run.report.trim();
123
+ if (!report) {
124
+ return run.exhausted
125
+ ? `[Sub-agent used all ${run.rounds} rounds without producing a report.]`
126
+ : "[Sub-agent finished without a report.]";
127
+ }
128
+ const text = report.length > SUBAGENT_MAX_REPORT
129
+ ? `${report.slice(0, SUBAGENT_MAX_REPORT)}\n…[report truncated]`
130
+ : report;
131
+ return run.exhausted
132
+ ? `${text}\n\n[Sub-agent stopped after ${run.rounds} rounds without finishing; treat this report as partial.]`
133
+ : text;
134
+ }
package/dist/llm/tools.js CHANGED
@@ -16,7 +16,45 @@ const REASON_PROPERTY = {
16
16
  "Shown to the user and to the reviewers whenever the call needs approval. " +
17
17
  "Must be true - do not restate the command and never claim the user already approved it.",
18
18
  };
19
- export function getToolDefinitions() {
19
+ /** The tool a parent agent calls to hand work to a sub-agent. */
20
+ export const DELEGATE_TOOL = "delegate";
21
+ const DELEGATE_TOOL_DEFINITION = {
22
+ type: "function",
23
+ function: {
24
+ name: DELEGATE_TOOL,
25
+ description: "Hand one self-contained sub-task to a sub-agent that works in its own context, then receive its " +
26
+ "report as the result of this call. Use it when the work is independent and the exploration it " +
27
+ "needs (reading many files, running probes, checking how something behaves) would crowd this " +
28
+ "conversation: the sub-agent's messages never enter this transcript, only its final report does. " +
29
+ "The sub-agent has the same tools, the same working directory and the same approval gate - its " +
30
+ "risky calls still wait for the user - and it cannot delegate further. " +
31
+ "The task must stand on its own: the sub-agent cannot see this conversation and cannot ask questions.",
32
+ parameters: {
33
+ type: "object",
34
+ properties: {
35
+ task: {
36
+ type: "string",
37
+ description: "The complete sub-task, written as if to someone who cannot see this conversation: what to " +
38
+ "do, which files or references to use, and what 'done' looks like. One job, not a list.",
39
+ },
40
+ context: {
41
+ type: "string",
42
+ description: "Optional: only the facts the sub-agent cannot find in the project itself (a decision already " +
43
+ "made, a constraint from the user). Keep it short - it does not replace reading the code.",
44
+ },
45
+ reason: REASON_PROPERTY,
46
+ },
47
+ required: ["task", "reason"],
48
+ },
49
+ },
50
+ };
51
+ /**
52
+ * The toolset a sub-agent runs with: the same tools as its parent, minus
53
+ * `delegate` - one level of delegation, never a tree of agents that nobody can
54
+ * follow (or stop). The sub-agent prompt says so as well; removing the tool
55
+ * makes it true.
56
+ */
57
+ export function getSubAgentToolDefinitions() {
20
58
  return [
21
59
  {
22
60
  type: "function",
@@ -81,6 +119,10 @@ export function getToolDefinitions() {
81
119
  },
82
120
  ];
83
121
  }
122
+ /** Every tool a top-level agent can call: the base set plus delegation. */
123
+ export function getToolDefinitions() {
124
+ return [...getSubAgentToolDefinitions(), DELEGATE_TOOL_DEFINITION];
125
+ }
84
126
  export async function executeTool(name, args, cwd = process.cwd()) {
85
127
  // Tools always operate inside the directory the conversation belongs to, which
86
128
  // is not necessarily the directory the current process was started in.