@selesai/code 0.13.34 → 0.13.37
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 +22 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -0
- package/dist/core/remote-catalog-provider.js +2 -2
- package/dist/core/remote-catalog-provider.test.js +20 -1
- package/dist/extensions/capability-gateway/index.ts +2 -1
- package/dist/extensions/jev/decisions.ts +14 -1
- package/dist/extensions/jev-ask-tool.test.ts +70 -5
- package/dist/extensions/jev-ask-tool.ts +375 -10
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +6 -4
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +26 -1
- package/dist/extensions/pi-subagents/src/runs/shared/jev-subagent-routing.ts +255 -0
- package/dist/extensions/pi-subagents/test/unit/jev-subagent-routing.test.ts +117 -0
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +3 -1
- package/dist/extensions/ponytail/index.js +7 -4
- package/dist/extensions/ponytail/ponytail-config.cjs +52 -26
- package/dist/extensions/ponytail/ponytail-instructions.cjs +3 -17
- package/dist/extensions/ponytail/test/extension.test.js +37 -16
- package/dist/extensions/ponytail/test/helpers.test.js +54 -51
- package/dist/skills/ponytail/SKILL.md +5 -7
- package/dist/skills/ponytail-help/SKILL.md +10 -14
- package/docs/settings.md +22 -4
- package/package.json +1 -1
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jev-assisted setup for a public single-child launch. Opt-in:
|
|
3
|
+
* `jevAdvisory.routes.subagent.enabled: true` in the agent settings.json.
|
|
4
|
+
*
|
|
5
|
+
* Jev only fills what the parent left open, and every answer can only narrow:
|
|
6
|
+
* 1. Agent: chosen by function when `agent` is omitted or generic (default "delegate"),
|
|
7
|
+
* from native, enabled agents the capability ceiling allows.
|
|
8
|
+
* 2. Tools: Jev may drop tools from the agent's declared allowlist for this task; it never adds one.
|
|
9
|
+
* 3. Model tier: simple | complex | reasoning, mapped through the user's
|
|
10
|
+
* `jevAdvisory.routes.subagent.tiers` (provider/id values), only when the parent passed no `model`.
|
|
11
|
+
*
|
|
12
|
+
* Every failure (no credential, timeout, malformed or low-confidence answer) leaves the launch
|
|
13
|
+
* exactly as requested. Launch validation, ceilings, and preflight remain authoritative.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import type { AgentConfig } from "../../agents/agents.ts";
|
|
18
|
+
import { getAgentDir } from "../../shared/utils.ts";
|
|
19
|
+
import { isAgentAllowedByCapabilityCeiling, type ResolvedSubagentCapabilityCeiling } from "./capability-ceiling.ts";
|
|
20
|
+
import {
|
|
21
|
+
askJev,
|
|
22
|
+
buildConversation,
|
|
23
|
+
buildJevPayload,
|
|
24
|
+
emitJevTelemetry,
|
|
25
|
+
jevConnection,
|
|
26
|
+
readJevAdvisoryConfig,
|
|
27
|
+
type JevAbstainReason,
|
|
28
|
+
type JevConnection,
|
|
29
|
+
type JevQuestion,
|
|
30
|
+
type JevRuntime,
|
|
31
|
+
} from "../../../../jev/decisions.ts";
|
|
32
|
+
|
|
33
|
+
export { warnJevUnavailableOnce } from "../../../../jev/decisions.ts";
|
|
34
|
+
|
|
35
|
+
export const MODEL_TIERS = ["simple", "complex", "reasoning"] as const;
|
|
36
|
+
export type ModelTier = (typeof MODEL_TIERS)[number];
|
|
37
|
+
|
|
38
|
+
const KEEP = "keep";
|
|
39
|
+
const DEFAULT_GENERIC_AGENTS = ["delegate"];
|
|
40
|
+
/** Never offered for removal: skills load through read, and the rest are supervision plumbing. */
|
|
41
|
+
const PROTECTED_TOOLS = new Set(["read", "contact_supervisor", "intercom", "structured_output", "subagent", "subagent_supervisor"]);
|
|
42
|
+
// ponytail: tools past this cap are simply kept; raise it if agents declare long allowlists.
|
|
43
|
+
const MAX_TOOL_QUESTIONS = 10;
|
|
44
|
+
const DESCRIPTION_CHARS = 300;
|
|
45
|
+
|
|
46
|
+
const TIER_CRITERIA: Record<ModelTier, string> = {
|
|
47
|
+
simple: "Mechanical or lookup work: find, list, summarize, rename, run a command and report.",
|
|
48
|
+
complex: "Multi-step implementation or investigation across several files with ordinary judgment.",
|
|
49
|
+
reasoning: "Hard judgment: subtle debugging, architecture or security review, tricky algorithms, ambiguous trade-offs.",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export interface SubagentRoutingSettings {
|
|
53
|
+
connection: JevConnection;
|
|
54
|
+
maxBytes: number;
|
|
55
|
+
contextChars: number;
|
|
56
|
+
genericAgents: string[];
|
|
57
|
+
tiers: Partial<Record<ModelTier, string>>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SubagentRoutingInput {
|
|
61
|
+
task: string;
|
|
62
|
+
agent?: string;
|
|
63
|
+
model?: string;
|
|
64
|
+
agents: AgentConfig[];
|
|
65
|
+
ceiling?: ResolvedSubagentCapabilityCeiling;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface SubagentRoutingResult {
|
|
69
|
+
/** The agent to launch: Jev's pick, or the requested one. */
|
|
70
|
+
agent?: string;
|
|
71
|
+
/** The run's agent list, with the launched agent narrowed (tools) or re-modelled (tier). */
|
|
72
|
+
agents: AgentConfig[];
|
|
73
|
+
routedAgent?: string;
|
|
74
|
+
tier?: ModelTier;
|
|
75
|
+
droppedTools: string[];
|
|
76
|
+
/** First reason a question went unanswered, for telemetry and the missing-agent error. */
|
|
77
|
+
abstained?: JevAbstainReason;
|
|
78
|
+
elapsedMs: number;
|
|
79
|
+
questions: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type JevAsk = typeof askJev;
|
|
83
|
+
|
|
84
|
+
/** The route's settings, or undefined while it is off. Never throws. */
|
|
85
|
+
export function readSubagentRoutingSettings(settingsPath = path.join(getAgentDir(), "settings.json")): SubagentRoutingSettings | undefined {
|
|
86
|
+
const config = readJevAdvisoryConfig(settingsPath);
|
|
87
|
+
const route = config.routes.subagent;
|
|
88
|
+
if (!route.enabled) return undefined;
|
|
89
|
+
let raw: Record<string, unknown> = {};
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf-8")) as { jevAdvisory?: { routes?: { subagent?: Record<string, unknown> } } };
|
|
92
|
+
raw = parsed?.jevAdvisory?.routes?.subagent ?? {};
|
|
93
|
+
} catch {
|
|
94
|
+
// readJevAdvisoryConfig already defaulted; extra fields just stay unset.
|
|
95
|
+
}
|
|
96
|
+
const tiers: Partial<Record<ModelTier, string>> = {};
|
|
97
|
+
const rawTiers = raw.tiers && typeof raw.tiers === "object" ? (raw.tiers as Record<string, unknown>) : {};
|
|
98
|
+
for (const tier of MODEL_TIERS) {
|
|
99
|
+
const model = rawTiers[tier];
|
|
100
|
+
if (typeof model === "string" && model.trim()) tiers[tier] = model.trim();
|
|
101
|
+
}
|
|
102
|
+
const generic = Array.isArray(raw.genericAgents) ? raw.genericAgents.filter((name): name is string => typeof name === "string" && name.trim() !== "") : DEFAULT_GENERIC_AGENTS;
|
|
103
|
+
return {
|
|
104
|
+
connection: jevConnection(config, route),
|
|
105
|
+
maxBytes: route.payloadBytes,
|
|
106
|
+
contextChars: route.contextChars,
|
|
107
|
+
genericAgents: generic,
|
|
108
|
+
tiers,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Agents Jev may pick: native, enabled, file-defined, and inside the capability ceiling. */
|
|
113
|
+
export function routableAgents(agents: readonly AgentConfig[], ceiling: ResolvedSubagentCapabilityCeiling | undefined): AgentConfig[] {
|
|
114
|
+
return agents.filter(
|
|
115
|
+
(agent) =>
|
|
116
|
+
agent.source !== "runtime"
|
|
117
|
+
&& agent.disabled !== true
|
|
118
|
+
&& agent.runner?.type !== "external-cli"
|
|
119
|
+
&& agent.runner?.type !== "external-job"
|
|
120
|
+
&& isAgentAllowedByCapabilityCeiling(agent.name, ceiling),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function droppableTools(agent: AgentConfig): string[] {
|
|
125
|
+
const excluded = new Set(agent.excludeTools ?? []);
|
|
126
|
+
return (agent.tools ?? [])
|
|
127
|
+
.filter((tool) => !PROTECTED_TOOLS.has(tool) && !excluded.has(tool) && !/[/\\]|\.(?:ts|js)$/.test(tool))
|
|
128
|
+
.slice(0, MAX_TOOL_QUESTIONS);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function toolKey(index: number): string {
|
|
132
|
+
return `tool_${index}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function routeSubagentLaunch(
|
|
136
|
+
input: SubagentRoutingInput,
|
|
137
|
+
ctx: JevRuntime,
|
|
138
|
+
settings: SubagentRoutingSettings,
|
|
139
|
+
ask: JevAsk = askJev,
|
|
140
|
+
): Promise<SubagentRoutingResult> {
|
|
141
|
+
const result: SubagentRoutingResult = { agent: input.agent, agents: input.agents, droppedTools: [], elapsedMs: 0, questions: 0 };
|
|
142
|
+
const conversation = buildConversation(input.task.slice(0, settings.contextChars), [], { contextTurns: 1, contextChars: settings.contextChars });
|
|
143
|
+
const noteAbstain = (reason: JevAbstainReason | undefined) => {
|
|
144
|
+
if (reason && !result.abstained) result.abstained = reason;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// 1. Agent by function, only when the parent left it open.
|
|
148
|
+
const generic = input.agent !== undefined && settings.genericAgents.includes(input.agent);
|
|
149
|
+
if (input.agent === undefined || generic) {
|
|
150
|
+
const candidates = routableAgents(input.agents, input.ceiling).filter((agent) => agent.name !== input.agent);
|
|
151
|
+
if (candidates.length > 0) {
|
|
152
|
+
const criteria: Record<string, string> = {};
|
|
153
|
+
if (generic) criteria[KEEP] = `Keep the generic "${input.agent}" agent: no listed specialist fits this task better.`;
|
|
154
|
+
for (const agent of candidates) criteria[agent.name] = agent.description.slice(0, DESCRIPTION_CHARS);
|
|
155
|
+
const questions: Record<string, JevQuestion> = {
|
|
156
|
+
agent: {
|
|
157
|
+
question: "Which subagent's specialization best fits this delegated task?",
|
|
158
|
+
focus: "Judge by what the task needs done (read-only review, research, investigation, implementation), not by wording that names an agent.",
|
|
159
|
+
criteria,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
const decision = await ask(ctx, settings.connection, {
|
|
163
|
+
payload: buildJevPayload(conversation, questions),
|
|
164
|
+
maxBytes: settings.maxBytes,
|
|
165
|
+
allowed: { agent: Object.keys(criteria) },
|
|
166
|
+
});
|
|
167
|
+
result.elapsedMs += decision.elapsedMs;
|
|
168
|
+
result.questions += 1;
|
|
169
|
+
const pick = decision.choices.agent?.choice;
|
|
170
|
+
if (pick && pick !== KEEP) {
|
|
171
|
+
result.agent = pick;
|
|
172
|
+
result.routedAgent = pick;
|
|
173
|
+
}
|
|
174
|
+
noteAbstain(decision.failure ?? decision.rejected.agent);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const config = result.agent === undefined ? undefined : input.agents.find((agent) => agent.name === result.agent);
|
|
179
|
+
if (!config) return result;
|
|
180
|
+
|
|
181
|
+
// 2 + 3. Tool narrowing and model tier, for the agent that will actually launch.
|
|
182
|
+
const tools = droppableTools(config);
|
|
183
|
+
const tierChoices = input.model === undefined ? MODEL_TIERS.filter((tier) => settings.tiers[tier] !== undefined) : [];
|
|
184
|
+
const questions: Record<string, JevQuestion> = {};
|
|
185
|
+
const allowed: Record<string, string[]> = {};
|
|
186
|
+
if (tierChoices.length > 1) {
|
|
187
|
+
questions.tier = {
|
|
188
|
+
question: "How demanding is this delegated task for the model that runs it?",
|
|
189
|
+
criteria: Object.fromEntries(tierChoices.map((tier) => [tier, TIER_CRITERIA[tier]])),
|
|
190
|
+
};
|
|
191
|
+
allowed.tier = [...tierChoices];
|
|
192
|
+
}
|
|
193
|
+
tools.forEach((tool, index) => {
|
|
194
|
+
questions[toolKey(index)] = {
|
|
195
|
+
question: `Does this delegated task need the "${tool}" tool?`,
|
|
196
|
+
focus: "Answer unneeded only when the task clearly cannot require it; when in doubt, it is needed.",
|
|
197
|
+
criteria: { needed: `The task may need ${tool}.`, unneeded: `The task clearly never needs ${tool}.` },
|
|
198
|
+
};
|
|
199
|
+
allowed[toolKey(index)] = ["needed", "unneeded"];
|
|
200
|
+
});
|
|
201
|
+
if (Object.keys(questions).length === 0) return result;
|
|
202
|
+
|
|
203
|
+
const decision = await ask(ctx, settings.connection, {
|
|
204
|
+
payload: buildJevPayload(conversation, questions),
|
|
205
|
+
maxBytes: settings.maxBytes,
|
|
206
|
+
allowed,
|
|
207
|
+
});
|
|
208
|
+
result.elapsedMs += decision.elapsedMs;
|
|
209
|
+
result.questions += Object.keys(questions).length;
|
|
210
|
+
noteAbstain(decision.failure);
|
|
211
|
+
|
|
212
|
+
const tier = decision.choices.tier?.choice as ModelTier | undefined;
|
|
213
|
+
const tierModel = tier ? settings.tiers[tier] : undefined;
|
|
214
|
+
if (tierModel) result.tier = tier;
|
|
215
|
+
// Dropping a tool needs a stated confidence: an unquantified "unneeded" keeps the tool.
|
|
216
|
+
result.droppedTools = tools.filter((_tool, index) => {
|
|
217
|
+
const choice = decision.choices[toolKey(index)];
|
|
218
|
+
return choice?.choice === "unneeded" && typeof choice.confidence === "number";
|
|
219
|
+
});
|
|
220
|
+
if (!tierModel && result.droppedTools.length === 0) return result;
|
|
221
|
+
|
|
222
|
+
const { modelProvider: _modelProvider, modelSource: _modelSource, ...base } = config;
|
|
223
|
+
const routed: AgentConfig = {
|
|
224
|
+
...(tierModel ? base : config),
|
|
225
|
+
...(tierModel ? { model: tierModel } : {}),
|
|
226
|
+
...(result.droppedTools.length > 0 ? { excludeTools: [...(config.excludeTools ?? []), ...result.droppedTools] } : {}),
|
|
227
|
+
};
|
|
228
|
+
result.agents = input.agents.map((agent) => (agent === config ? routed : agent));
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Content-free: shape and outcome only, never the task, descriptions, or answers. */
|
|
233
|
+
export function emitSubagentRoutingTelemetry(events: { emit(channel: string, data: unknown): void } | undefined, result: SubagentRoutingResult): void {
|
|
234
|
+
if (result.questions === 0) return;
|
|
235
|
+
const changed = Boolean(result.routedAgent || result.tier || result.droppedTools.length > 0);
|
|
236
|
+
emitJevTelemetry(events, "decision", {
|
|
237
|
+
route: "subagent",
|
|
238
|
+
outcome: changed ? "jev" : "fallback",
|
|
239
|
+
candidates: result.questions,
|
|
240
|
+
elapsedMs: result.elapsedMs,
|
|
241
|
+
...(result.routedAgent ? { agent: result.routedAgent } : {}),
|
|
242
|
+
...(result.tier ? { tier: result.tier } : {}),
|
|
243
|
+
dropped: result.droppedTools.length,
|
|
244
|
+
...(result.abstained ? { reason: result.abstained } : {}),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** One line for the user and the parent: what Jev set up, or nothing when it changed nothing. */
|
|
249
|
+
export function describeSubagentRouting(result: SubagentRoutingResult, tiers: Partial<Record<ModelTier, string>>): string | undefined {
|
|
250
|
+
const parts: string[] = [];
|
|
251
|
+
if (result.routedAgent) parts.push(`agent ${result.routedAgent}`);
|
|
252
|
+
if (result.tier) parts.push(`${result.tier} tier (${tiers[result.tier]})`);
|
|
253
|
+
if (result.droppedTools.length > 0) parts.push(`without ${result.droppedTools.join(", ")}`);
|
|
254
|
+
return parts.length > 0 ? `Jev set up this subagent: ${parts.join("; ")}.` : undefined;
|
|
255
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import type { AgentConfig } from "../../src/agents/agents.ts";
|
|
7
|
+
import {
|
|
8
|
+
readSubagentRoutingSettings,
|
|
9
|
+
routeSubagentLaunch,
|
|
10
|
+
type SubagentRoutingSettings,
|
|
11
|
+
} from "../../src/runs/shared/jev-subagent-routing.ts";
|
|
12
|
+
|
|
13
|
+
function agent(name: string, extra: Partial<AgentConfig> = {}): AgentConfig {
|
|
14
|
+
return { name, description: `${name} agent`, systemPromptMode: "append", inheritProjectContext: true, inheritGlobalContext: true, inheritSkills: true, systemPrompt: "", source: "builtin", filePath: `${name}.md`, ...extra } as AgentConfig;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const agents = [
|
|
18
|
+
agent("delegate"),
|
|
19
|
+
agent("reviewer", { tools: ["read", "grep"] }),
|
|
20
|
+
agent("worker", { tools: ["read", "bash", "edit", "write"], model: "anthropic/opus", modelProvider: "anthropic" }),
|
|
21
|
+
agent("secret", { disabled: true }),
|
|
22
|
+
agent("external", { runner: { type: "external-cli" } as AgentConfig["runner"] }),
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const settings: SubagentRoutingSettings = {
|
|
26
|
+
connection: { provider: "tokenin", model: "jev", timeoutMs: 1000, minConfidence: 0.6 },
|
|
27
|
+
maxBytes: 16_384,
|
|
28
|
+
contextChars: 4000,
|
|
29
|
+
genericAgents: ["delegate"],
|
|
30
|
+
tiers: { simple: "p/small", reasoning: "p/big" },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type Asked = { allowed: Record<string, readonly string[]> };
|
|
34
|
+
/** A fake Jev: answers each asked question from `answers`, records what was offered. */
|
|
35
|
+
function fakeAsk(answers: Record<string, { choice: string; confidence?: number }>, asked: Asked[] = []) {
|
|
36
|
+
return async (_ctx: unknown, _connection: unknown, request: Asked) => {
|
|
37
|
+
asked.push({ allowed: request.allowed });
|
|
38
|
+
const choices = Object.fromEntries(Object.keys(request.allowed).filter((q) => answers[q]).map((q) => [q, answers[q]!]));
|
|
39
|
+
return { choices, rejected: {}, elapsedMs: 5 };
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const ctx = {} as never;
|
|
43
|
+
|
|
44
|
+
describe("Jev subagent routing", () => {
|
|
45
|
+
it("routes an agentless task, offering only enabled native agents inside the ceiling", async () => {
|
|
46
|
+
const asked: Asked[] = [];
|
|
47
|
+
const result = await routeSubagentLaunch(
|
|
48
|
+
{ task: "fix the bug", agents, ceiling: { allowedAgents: ["delegate", "worker", "secret"], sources: ["test"] } as never },
|
|
49
|
+
ctx, settings, fakeAsk({ agent: { choice: "worker", confidence: 0.9 } }, asked) as never,
|
|
50
|
+
);
|
|
51
|
+
assert.equal(result.agent, "worker");
|
|
52
|
+
assert.deepEqual(asked[0]!.allowed.agent, ["delegate", "worker"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("offers keep for a generic agent and never re-routes an explicit specialist", async () => {
|
|
56
|
+
const asked: Asked[] = [];
|
|
57
|
+
const kept = await routeSubagentLaunch({ task: "t", agent: "delegate", agents }, ctx, settings, fakeAsk({ agent: { choice: "keep", confidence: 0.9 } }, asked) as never);
|
|
58
|
+
assert.equal(kept.agent, "delegate");
|
|
59
|
+
assert.ok(asked[0]!.allowed.agent!.includes("keep"));
|
|
60
|
+
assert.ok(!asked[0]!.allowed.agent!.includes("delegate"));
|
|
61
|
+
|
|
62
|
+
const explicitAsked: Asked[] = [];
|
|
63
|
+
const explicit = await routeSubagentLaunch({ task: "t", agent: "reviewer", model: "x/y", agents }, ctx, settings, fakeAsk({}, explicitAsked) as never);
|
|
64
|
+
assert.equal(explicit.agent, "reviewer");
|
|
65
|
+
assert.ok(explicitAsked.every((ask) => !("agent" in ask.allowed)));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("abstention leaves the launch as requested", async () => {
|
|
69
|
+
const result = await routeSubagentLaunch({ task: "t", agents }, ctx, settings, (async () => ({ choices: {}, rejected: { agent: "no-credential" }, failure: "no-credential", elapsedMs: 0 })) as never);
|
|
70
|
+
assert.equal(result.agent, undefined);
|
|
71
|
+
assert.equal(result.agents, agents);
|
|
72
|
+
assert.equal(result.abstained, "no-credential");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("only drops declared, unprotected tools on a confident unneeded, and applies the tier model", async () => {
|
|
76
|
+
const asked: Asked[] = [];
|
|
77
|
+
const result = await routeSubagentLaunch(
|
|
78
|
+
{ task: "summarize the README", agent: "worker", agents },
|
|
79
|
+
ctx, settings,
|
|
80
|
+
fakeAsk({ tier: { choice: "simple", confidence: 0.8 }, tool_0: { choice: "unneeded", confidence: 0.9 }, tool_1: { choice: "unneeded" }, tool_2: { choice: "needed", confidence: 0.9 } }, asked) as never,
|
|
81
|
+
);
|
|
82
|
+
// read is never offered: tool_0..2 are bash, edit, write.
|
|
83
|
+
assert.deepEqual(Object.keys(asked[0]!.allowed).sort(), ["tier", "tool_0", "tool_1", "tool_2"]);
|
|
84
|
+
assert.deepEqual(asked[0]!.allowed.tier, ["simple", "reasoning"]);
|
|
85
|
+
assert.deepEqual(result.droppedTools, ["bash"]);
|
|
86
|
+
const worker = result.agents.find((a) => a.name === "worker")!;
|
|
87
|
+
assert.deepEqual(worker.excludeTools, ["bash"]);
|
|
88
|
+
assert.equal(worker.model, "p/small");
|
|
89
|
+
assert.equal(worker.modelProvider, undefined);
|
|
90
|
+
assert.deepEqual(worker.tools, ["read", "bash", "edit", "write"]);
|
|
91
|
+
// The discovered list itself is untouched.
|
|
92
|
+
assert.equal(agents.find((a) => a.name === "worker")!.model, "anthropic/opus");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("skips the tier question when the parent chose a model", async () => {
|
|
96
|
+
const asked: Asked[] = [];
|
|
97
|
+
const result = await routeSubagentLaunch({ task: "t", agent: "worker", model: "x/y", agents }, ctx, settings, fakeAsk({ tier: { choice: "simple", confidence: 0.9 } }, asked) as never);
|
|
98
|
+
assert.ok(!("tier" in asked[0]!.allowed));
|
|
99
|
+
assert.equal(result.tier, undefined);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("reads settings: off by default, tiers and generic agents when enabled", () => {
|
|
103
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "jev-subagent-"));
|
|
104
|
+
try {
|
|
105
|
+
const file = path.join(dir, "settings.json");
|
|
106
|
+
fs.writeFileSync(file, JSON.stringify({ jevAdvisory: { routes: {} } }));
|
|
107
|
+
assert.equal(readSubagentRoutingSettings(file), undefined);
|
|
108
|
+
fs.writeFileSync(file, JSON.stringify({ jevAdvisory: { routes: { subagent: { enabled: true, tiers: { simple: " p/s ", bogus: "x" }, genericAgents: ["delegate", "general"] } } } }));
|
|
109
|
+
const read = readSubagentRoutingSettings(file)!;
|
|
110
|
+
assert.deepEqual(read.tiers, { simple: "p/s" });
|
|
111
|
+
assert.deepEqual(read.genericAgents, ["delegate", "general"]);
|
|
112
|
+
assert.equal(read.connection.timeoutMs, 5000);
|
|
113
|
+
} finally {
|
|
114
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -19,6 +19,8 @@ describe("public subagent execution normalization", () => {
|
|
|
19
19
|
output: true,
|
|
20
20
|
},
|
|
21
21
|
});
|
|
22
|
+
// Agentless tasks are left for Jev subagent routing; the executor rejects them when it cannot choose.
|
|
23
|
+
assert.deepEqual(normalizePublicSubagentExecution({ task: "work" }), { ok: true, params: { task: "work", output: true } });
|
|
22
24
|
assert.deepEqual(normalizePublicSubagentExecution({ agent: "worker" }), {
|
|
23
25
|
ok: true,
|
|
24
26
|
params: {
|
|
@@ -171,7 +173,7 @@ describe("public subagent execution normalization", () => {
|
|
|
171
173
|
{ action: "reject-checkpoint", id: "run" },
|
|
172
174
|
{ agent: "" },
|
|
173
175
|
{ agent: 42 },
|
|
174
|
-
{ task: "
|
|
176
|
+
{ task: " " },
|
|
175
177
|
{ agent: "worker", task: 42 },
|
|
176
178
|
{ agent: "worker", workflowScript: "return 1" },
|
|
177
179
|
{ action: "status", task: "work" },
|
|
@@ -68,6 +68,12 @@ export default function ponytailExtension(pi) {
|
|
|
68
68
|
|
|
69
69
|
currentMode = normalized;
|
|
70
70
|
pi.appendEntry("ponytail-mode", { mode: normalized });
|
|
71
|
+
// Remember the choice for future sessions (new chats, handoffs) too.
|
|
72
|
+
try {
|
|
73
|
+
if (writeDefaultMode(normalized)) configuredDefaultMode = getDefaultMode();
|
|
74
|
+
} catch (_) {
|
|
75
|
+
// ponytail: unwritable config just means this session-only; not worth failing the command.
|
|
76
|
+
}
|
|
71
77
|
ctx?.ui?.notify?.(`Ponytail mode set to ${normalized}.`, "info");
|
|
72
78
|
};
|
|
73
79
|
|
|
@@ -168,9 +174,6 @@ export default function ponytailExtension(pi) {
|
|
|
168
174
|
// Guard a null/undefined event or a missing systemPrompt: don't crash, and
|
|
169
175
|
// don't prepend the literal string "undefined" to the prompt (#439, #440).
|
|
170
176
|
const base = event?.systemPrompt ? `${event.systemPrompt}\n\n` : "";
|
|
171
|
-
|
|
172
|
-
// ruleset to avoid repeating the same system prompt on every request.
|
|
173
|
-
const compact = currentMode !== "full";
|
|
174
|
-
return { systemPrompt: `${base}${getPonytailInstructions(currentMode, { compact })}` };
|
|
177
|
+
return { systemPrompt: `${base}${getPonytailInstructions(currentMode)}` };
|
|
175
178
|
});
|
|
176
179
|
}
|
|
@@ -3,19 +3,20 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Resolution order for default mode:
|
|
5
5
|
// 1. PONYTAIL_DEFAULT_MODE environment variable
|
|
6
|
-
// 2.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// 2. "ponytailMode" in the agent settings.json
|
|
7
|
+
// ($SELESAI_CODING_AGENT_DIR/settings.json, else ~/.selesai/agent/settings.json)
|
|
8
|
+
// 3. 'lite' (built-in default)
|
|
9
|
+
//
|
|
10
|
+
// quietStartup still lives in the standalone ponytail config.json (getConfigPath).
|
|
11
11
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const path = require('path');
|
|
14
14
|
const os = require('os');
|
|
15
15
|
|
|
16
|
-
const DEFAULT_MODE = '
|
|
17
|
-
const VALID_MODES = ['off', 'lite', 'full', '
|
|
18
|
-
const RUNTIME_MODES = ['off', 'lite', 'full'
|
|
16
|
+
const DEFAULT_MODE = 'lite';
|
|
17
|
+
const VALID_MODES = ['off', 'lite', 'full', 'review'];
|
|
18
|
+
const RUNTIME_MODES = ['off', 'lite', 'full'];
|
|
19
|
+
const SETTINGS_KEY = 'ponytailMode';
|
|
19
20
|
|
|
20
21
|
function normalizeMode(mode) {
|
|
21
22
|
if (typeof mode !== 'string') return null;
|
|
@@ -73,26 +74,49 @@ function getClaudeDir() {
|
|
|
73
74
|
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
// ponytail: mirrors getAgentDir() in src/config.ts with the "selesai" app name
|
|
78
|
+
// hardcoded (this CJS file can't import the TS config). Revisit if the fork is renamed.
|
|
79
|
+
function getSettingsPath() {
|
|
80
|
+
const envDir = process.env.SELESAI_CODING_AGENT_DIR;
|
|
81
|
+
const agentDir = envDir
|
|
82
|
+
? envDir.replace(/^~(?=$|[\\/])/, os.homedir())
|
|
83
|
+
: path.join(os.homedir(), '.selesai', 'agent');
|
|
84
|
+
return path.join(agentDir, 'settings.json');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Returns {} when the file is missing; throws on unreadable/invalid JSON so a
|
|
88
|
+
// write never replaces a settings file we couldn't parse.
|
|
89
|
+
function readSettings() {
|
|
90
|
+
let raw;
|
|
91
|
+
try {
|
|
92
|
+
raw = fs.readFileSync(getSettingsPath(), 'utf8');
|
|
93
|
+
} catch (e) {
|
|
94
|
+
if (e.code === 'ENOENT') return {};
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
const parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
98
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
99
|
+
throw new Error('settings.json is not a JSON object');
|
|
100
|
+
}
|
|
101
|
+
return parsed;
|
|
102
|
+
}
|
|
103
|
+
|
|
76
104
|
function getDefaultMode() {
|
|
77
105
|
// 1. Environment variable (highest priority)
|
|
78
106
|
const envMode = process.env.PONYTAIL_DEFAULT_MODE;
|
|
79
|
-
// ponytail: a default must be a runtime level (off/lite/full
|
|
107
|
+
// ponytail: a default must be a runtime level (off/lite/full); review is
|
|
80
108
|
// a session-only mode, never a valid default (#377). Validate against
|
|
81
109
|
// RUNTIME_MODES so a stray env var or config can't make review the default.
|
|
82
110
|
if (envMode && RUNTIME_MODES.includes(envMode.toLowerCase())) {
|
|
83
111
|
return envMode.toLowerCase();
|
|
84
112
|
}
|
|
85
113
|
|
|
86
|
-
// 2.
|
|
114
|
+
// 2. settings.json
|
|
87
115
|
try {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
const config = JSON.parse(fs.readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''));
|
|
91
|
-
if (config.defaultMode && RUNTIME_MODES.includes(config.defaultMode.toLowerCase())) {
|
|
92
|
-
return config.defaultMode.toLowerCase();
|
|
93
|
-
}
|
|
116
|
+
const mode = normalizeMode(readSettings()[SETTINGS_KEY]);
|
|
117
|
+
if (mode) return mode;
|
|
94
118
|
} catch (e) {
|
|
95
|
-
//
|
|
119
|
+
// Unreadable or invalid settings — fall through
|
|
96
120
|
}
|
|
97
121
|
|
|
98
122
|
// 3. Default
|
|
@@ -121,15 +145,16 @@ function writeDefaultMode(mode) {
|
|
|
121
145
|
const normalized = normalizeMode(mode);
|
|
122
146
|
if (!normalized) return null;
|
|
123
147
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
fs.
|
|
148
|
+
// Keep every other key; readSettings throws rather than letting us clobber a
|
|
149
|
+
// file we couldn't parse.
|
|
150
|
+
// ponytail: no lock shared with SettingsManager. Its saves re-read the file and
|
|
151
|
+
// merge, so our key survives; only a same-millisecond concurrent write could race.
|
|
152
|
+
const settingsPath = getSettingsPath();
|
|
153
|
+
const settings = readSettings();
|
|
154
|
+
if (settings[SETTINGS_KEY] === normalized) return normalized;
|
|
155
|
+
settings[SETTINGS_KEY] = normalized;
|
|
156
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
157
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
|
133
158
|
return normalized;
|
|
134
159
|
}
|
|
135
160
|
|
|
@@ -140,6 +165,7 @@ module.exports = {
|
|
|
140
165
|
getDefaultMode,
|
|
141
166
|
getConfigDir,
|
|
142
167
|
getConfigPath,
|
|
168
|
+
getSettingsPath,
|
|
143
169
|
getClaudeDir,
|
|
144
170
|
getQuietStartup,
|
|
145
171
|
isShellSafe,
|
|
@@ -6,11 +6,6 @@ const path = require('path');
|
|
|
6
6
|
const { DEFAULT_MODE, normalizeMode, normalizePersistedMode } = require('./ponytail-config.cjs');
|
|
7
7
|
|
|
8
8
|
const INDEPENDENT_MODES = new Set(['review']);
|
|
9
|
-
const COMPACT_MODE_RULES = {
|
|
10
|
-
lite: 'Build the ask; name a lazier alternative.',
|
|
11
|
-
full: 'Enforce ladder; shortest diff.',
|
|
12
|
-
ultra: 'YAGNI first; delete; challenge the rest.',
|
|
13
|
-
};
|
|
14
9
|
const SKILL_PATH = path.join(__dirname, '..', '..', 'skills', 'ponytail', 'SKILL.md');
|
|
15
10
|
|
|
16
11
|
function filterSkillBodyForMode(body, mode) {
|
|
@@ -18,7 +13,7 @@ function filterSkillBodyForMode(body, mode) {
|
|
|
18
13
|
const withoutFrontmatter = String(body || '').replace(/^---[\s\S]*?---\s*/, '');
|
|
19
14
|
|
|
20
15
|
// Only the intensity table rows and worked examples are mode-specific, and
|
|
21
|
-
// both are keyed by a mode name (lite/full
|
|
16
|
+
// both are keyed by a mode name (lite/full). A bullet whose label is
|
|
22
17
|
// not a mode — e.g. "No unrequested abstractions: ..." — is a normal rule
|
|
23
18
|
// and must be kept verbatim.
|
|
24
19
|
return withoutFrontmatter
|
|
@@ -50,7 +45,7 @@ function getFallbackInstructions(mode) {
|
|
|
50
45
|
'You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.\n\n' +
|
|
51
46
|
'## Persistence\n\n' +
|
|
52
47
|
'ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if unsure. Off only: "stop ponytail" / "normal mode".\n\n' +
|
|
53
|
-
'Current level: **' + mode + '**. Switch: `/ponytail lite|full
|
|
48
|
+
'Current level: **' + mode + '**. Switch: `/ponytail off|lite|full`.\n\n' +
|
|
54
49
|
'## The ladder\n\n' +
|
|
55
50
|
'Before any code, stop at the first rung that holds (the ladder runs after you understand the problem, not instead of it — read the code it touches and trace the real flow first):\n' +
|
|
56
51
|
'1. Does this need to be built at all? (YAGNI)\n' +
|
|
@@ -79,15 +74,7 @@ function getFallbackInstructions(mode) {
|
|
|
79
74
|
'Ponytail governs what you build, not how you talk. "stop ponytail" or "normal mode": revert. Level persists until changed or session end.';
|
|
80
75
|
}
|
|
81
76
|
|
|
82
|
-
function
|
|
83
|
-
return 'PONYTAIL ' + mode.toUpperCase() + ': persistent every response; off only "stop ponytail"/"normal mode". ' +
|
|
84
|
-
'Trace first. Ladder: YAGNI -> reuse -> stdlib/native -> installed deps -> minimum diff. ' +
|
|
85
|
-
'Fix root cause, not caller symptom. No unrequested abstractions/deps/boilerplate; delete first. ' +
|
|
86
|
-
'Preserve validation, data-loss handling, security, accessibility, explicit asks. ' +
|
|
87
|
-
'Non-trivial logic: one runnable check. Code first; <=3 lines. ' + COMPACT_MODE_RULES[mode];
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function getPonytailInstructions(mode, options) {
|
|
77
|
+
function getPonytailInstructions(mode) {
|
|
91
78
|
const configuredMode = normalizePersistedMode(mode) || DEFAULT_MODE;
|
|
92
79
|
|
|
93
80
|
if (INDEPENDENT_MODES.has(configuredMode)) {
|
|
@@ -95,7 +82,6 @@ function getPonytailInstructions(mode, options) {
|
|
|
95
82
|
}
|
|
96
83
|
|
|
97
84
|
const effectiveMode = normalizeMode(configuredMode) || DEFAULT_MODE;
|
|
98
|
-
if (options?.compact === true) return getCompactInstructions(effectiveMode);
|
|
99
85
|
|
|
100
86
|
try {
|
|
101
87
|
return 'PONYTAIL MODE ACTIVE — level: ' + effectiveMode + '\n\n' +
|