@d3ara1n/pi-scout 1.0.2 → 1.1.1
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 +27 -4
- package/package.json +1 -1
- package/src/config.ts +36 -40
- package/src/index.ts +332 -264
- package/src/model-switch.ts +17 -17
- package/src/scout-prompt.ts +64 -60
- package/src/short-circuit.test.ts +105 -0
- package/src/short-circuit.ts +170 -0
- package/src/side-agent.ts +65 -65
- package/src/skill-inject.ts +41 -38
- package/src/types.ts +34 -1
package/src/model-switch.ts
CHANGED
|
@@ -12,26 +12,26 @@ import type { ModelRolesAPI } from "@d3ara1n/pi-model-roles";
|
|
|
12
12
|
* @returns true if the switch was successful
|
|
13
13
|
*/
|
|
14
14
|
export async function switchToRole(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
pi: ExtensionAPI,
|
|
16
|
+
roleName: string,
|
|
17
|
+
rolesApi: ModelRolesAPI,
|
|
18
18
|
): Promise<boolean> {
|
|
19
|
-
|
|
19
|
+
const resolved = await rolesApi.resolveRoleAsync(roleName);
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
if (!resolved.model) {
|
|
22
|
+
console.warn(`[pi-scout] Role "${roleName}" could not be resolved — model not available`);
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
const success = await pi.setModel(resolved.model);
|
|
27
|
+
if (!success) {
|
|
28
|
+
console.warn(`[pi-scout] setModel() returned false for role "${roleName}" — no API key?`);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
if (resolved.config.thinking) {
|
|
33
|
+
pi.setThinkingLevel(resolved.config.thinking);
|
|
34
|
+
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
return true;
|
|
37
37
|
}
|
package/src/scout-prompt.ts
CHANGED
|
@@ -6,8 +6,8 @@ import type { ScoutConfig } from "./types.ts";
|
|
|
6
6
|
|
|
7
7
|
/** Previous turn context for better routing decisions. */
|
|
8
8
|
export interface PrevTurnContext {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
userPrompt: string;
|
|
10
|
+
assistantSummary: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -17,30 +17,30 @@ export interface PrevTurnContext {
|
|
|
17
17
|
* understand follow-up prompts like "continue", "change that", etc.
|
|
18
18
|
*/
|
|
19
19
|
export function buildScoutUserMessage(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
userPrompt: string,
|
|
21
|
+
currentRole: string,
|
|
22
|
+
prevTurn?: PrevTurnContext,
|
|
23
23
|
): string {
|
|
24
|
-
|
|
24
|
+
const parts: string[] = [];
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
parts.push(`Current role: ${currentRole}`);
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
if (prevTurn && (prevTurn.userPrompt || prevTurn.assistantSummary)) {
|
|
29
|
+
parts.push(``);
|
|
30
|
+
parts.push(`## Previous Turn`);
|
|
31
|
+
if (prevTurn.userPrompt) {
|
|
32
|
+
parts.push(`User: ${prevTurn.userPrompt}`);
|
|
33
|
+
}
|
|
34
|
+
if (prevTurn.assistantSummary) {
|
|
35
|
+
parts.push(`Assistant: ${prevTurn.assistantSummary}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
parts.push(``);
|
|
40
|
+
parts.push(`## Current User Prompt`);
|
|
41
|
+
parts.push(userPrompt);
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
return parts.join("\n");
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -52,48 +52,52 @@ export function buildScoutUserMessage(
|
|
|
52
52
|
* prompt caching to activate.
|
|
53
53
|
*/
|
|
54
54
|
export function buildScoutSystemPrompt(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
config: ScoutConfig,
|
|
56
|
+
skillsList: string,
|
|
57
|
+
rolesList: string,
|
|
58
58
|
): string {
|
|
59
|
-
|
|
59
|
+
const parts: string[] = [];
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
61
|
+
parts.push(
|
|
62
|
+
`You are a scout. Analyze the user's request and decide which skills and model role to use.`,
|
|
63
|
+
);
|
|
64
|
+
parts.push(``);
|
|
65
|
+
parts.push(`## Response Format`);
|
|
66
|
+
parts.push(`Respond with ONLY a JSON object, no markdown, no explanation outside the JSON:`);
|
|
67
|
+
parts.push(`{`);
|
|
68
|
+
parts.push(` "skills": ["skill-name-1", "skill-name-2"],`);
|
|
69
|
+
parts.push(` "role": "role-name-or-null",`);
|
|
70
|
+
parts.push(` "reasoning": "one sentence explanation"`);
|
|
71
|
+
parts.push(`}`);
|
|
72
|
+
parts.push(``);
|
|
73
|
+
parts.push(`## Rules`);
|
|
74
|
+
parts.push(`- Select at most ${config.maxSelectedSkills} skills. Select 0 if none are relevant.`);
|
|
75
|
+
parts.push(`- Only select skills that will materially help with the task.`);
|
|
76
|
+
parts.push(`- If the task is trivial (simple question, acknowledgment), select 0 skills.`);
|
|
77
|
+
parts.push(`- "role" should be null if the current role is appropriate.`);
|
|
78
|
+
parts.push(`- Only suggest a role change when the task clearly benefits from a different model.`);
|
|
79
|
+
parts.push(`- Be conservative: prefer fewer skills and no role change when uncertain.`);
|
|
80
|
+
parts.push(
|
|
81
|
+
`- Use the Previous Turn context to understand follow-up requests (e.g. "continue", "change that", "no, the other one").`,
|
|
82
|
+
);
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
84
|
+
// Stable prefix ends here. Sections below are injected conditionally per
|
|
85
|
+
// module toggle: a disabled module contributes no candidates, so the side
|
|
86
|
+
// agent has nothing to choose from for it (and the application layer zeros
|
|
87
|
+
// out its field regardless). The longer section (skills) comes first so
|
|
88
|
+
// toggling the later module (roles) only invalidates the cache tail —
|
|
89
|
+
// Anthropic prefix-cache matches the longest common prefix.
|
|
90
|
+
if (config.modules.skillRouter) {
|
|
91
|
+
parts.push(``);
|
|
92
|
+
parts.push(`## Available Skills`);
|
|
93
|
+
parts.push(skillsList || "(none)");
|
|
94
|
+
}
|
|
91
95
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
if (config.modules.modelRouter) {
|
|
97
|
+
parts.push(``);
|
|
98
|
+
parts.push(`## Available Roles`);
|
|
99
|
+
parts.push(rolesList);
|
|
100
|
+
}
|
|
97
101
|
|
|
98
|
-
|
|
102
|
+
return parts.join("\n");
|
|
99
103
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the short-circuit layer.
|
|
3
|
+
* Run: node --test packages/pi-scout/src/short-circuit.test.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { normalizeAckPrompt, buildAckSet, evaluateShortCircuit } from "./short-circuit.ts";
|
|
9
|
+
import type { ShortCircuitConfig } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
const CFG = (overrides: Partial<ShortCircuitConfig> = {}): ShortCircuitConfig => ({
|
|
12
|
+
trivialAck: true,
|
|
13
|
+
maxAckLength: 12,
|
|
14
|
+
ackPhrases: [],
|
|
15
|
+
...overrides,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// ── normalizeAckPrompt ─────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
test("normalizeAckPrompt strips trailing CJK/Latin punctuation", () => {
|
|
21
|
+
assert.equal(normalizeAckPrompt("好的。"), "好的");
|
|
22
|
+
assert.equal(normalizeAckPrompt("OK!"), "ok");
|
|
23
|
+
assert.equal(normalizeAckPrompt(" 嗯 "), "嗯");
|
|
24
|
+
assert.equal(normalizeAckPrompt("はい。"), "はい");
|
|
25
|
+
assert.equal(normalizeAckPrompt("네!"), "네");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("normalizeAckPrompt preserves internal punctuation", () => {
|
|
29
|
+
assert.equal(normalizeAckPrompt("好的,那我们继续"), "好的,那我们继续");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("normalizeAckPrompt lowercases", () => {
|
|
33
|
+
assert.equal(normalizeAckPrompt("OK"), "ok");
|
|
34
|
+
assert.equal(normalizeAckPrompt("Sure"), "sure");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("normalizeAckPrompt empty for pure punctuation", () => {
|
|
38
|
+
assert.equal(normalizeAckPrompt("。。。"), "");
|
|
39
|
+
assert.equal(normalizeAckPrompt(" "), "");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ── buildAckSet ────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
test("buildAckSet includes defaults and user extras, normalized+deduped", () => {
|
|
45
|
+
const set = buildAckSet(["收到啦", "OK", " custom "]);
|
|
46
|
+
assert.ok(set.has("好的"));
|
|
47
|
+
assert.ok(set.has("ok"));
|
|
48
|
+
assert.ok(set.has("はい"));
|
|
49
|
+
assert.ok(set.has("네"));
|
|
50
|
+
assert.ok(set.has("收到啦"));
|
|
51
|
+
assert.ok(set.has("custom"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("buildAckSet ignores empty normalized entries", () => {
|
|
55
|
+
const set = buildAckSet(["。。。", ""]);
|
|
56
|
+
assert.equal(set.size > 0, true);
|
|
57
|
+
assert.ok(!set.has(""));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ── evaluateShortCircuit ───────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
test("trivial ack short-circuits across 中/英/日/韓", () => {
|
|
63
|
+
const cfg = CFG();
|
|
64
|
+
for (const prompt of ["好的", "ok", "はい", "네", "嗯嗯", "sure", "了解"]) {
|
|
65
|
+
const r = evaluateShortCircuit(prompt, cfg);
|
|
66
|
+
assert.ok(r, `expected short-circuit for: ${prompt}`);
|
|
67
|
+
assert.equal(r!.reasoning, "trivial ack");
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("trivial ack tolerates trailing punctuation", () => {
|
|
72
|
+
const r = evaluateShortCircuit("好的。", CFG());
|
|
73
|
+
assert.ok(r);
|
|
74
|
+
assert.equal(r!.reasoning, "trivial ack");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("long prompt starting with ack is NOT short-circuited", () => {
|
|
78
|
+
const r = evaluateShortCircuit("好的,那我们重构整个模块吧", CFG());
|
|
79
|
+
assert.equal(r, null);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("prompt longer than maxAckLength is not an ack", () => {
|
|
83
|
+
assert.equal(evaluateShortCircuit("understood thank you", CFG({ maxAckLength: 5 })), null);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("trivialAck disabled → ack falls through to model", () => {
|
|
87
|
+
const r = evaluateShortCircuit("好的", CFG({ trivialAck: false }));
|
|
88
|
+
assert.equal(r, null);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("user ackPhrases extend the table", () => {
|
|
92
|
+
const r = evaluateShortCircuit("收到啦", CFG({ ackPhrases: ["收到啦"] }));
|
|
93
|
+
assert.ok(r);
|
|
94
|
+
assert.equal(r!.reasoning, "trivial ack");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("empty / punctuation-only prompt is not an ack", () => {
|
|
98
|
+
assert.equal(evaluateShortCircuit("", CFG()), null);
|
|
99
|
+
assert.equal(evaluateShortCircuit("。。。", CFG()), null);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("ordinary prompt falls through to model", () => {
|
|
103
|
+
assert.equal(evaluateShortCircuit("帮我看看这个函数有没有性能问题", CFG()), null);
|
|
104
|
+
assert.equal(evaluateShortCircuit("what is the weather today", CFG()), null);
|
|
105
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-circuit layer — skip the side model on trivial prompts.
|
|
3
|
+
*
|
|
4
|
+
* Borrowed from OpenHuman's hybrid-gate pattern (PR #775): cheap signals
|
|
5
|
+
* short-circuit the obvious cases, the model only handles the ambiguous
|
|
6
|
+
* middle. There is no quality loss — the short-circuit fires only when the
|
|
7
|
+
* routing decision is already certain, everything else falls through to the
|
|
8
|
+
* side model.
|
|
9
|
+
*
|
|
10
|
+
* One rule, multilingual by construction:
|
|
11
|
+
*
|
|
12
|
+
* **Trivial acknowledgment** — a short prompt that is *entirely* an ack
|
|
13
|
+
* ("好的" / "ok" / "はい" / "네"). Matched against a built-in 中/英/日/韓
|
|
14
|
+
* phrase table. A trivial ack means "no skills, don't switch models" — both
|
|
15
|
+
* module answers are certain, so skipping the side model is always safe, even
|
|
16
|
+
* with model-router on. Long prompts are never treated as acks even if they
|
|
17
|
+
* start with an ack word, so "好的,那我们重构整个模块" always reaches the model.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ShortCircuitConfig } from "./types.ts";
|
|
21
|
+
|
|
22
|
+
/** Result of a successful short-circuit. */
|
|
23
|
+
export interface ShortCircuitResult {
|
|
24
|
+
/** Short human-readable reason, surfaced in the status bar. */
|
|
25
|
+
reasoning: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Built-in trivial-acknowledgment phrases, 中/英/日/韓.
|
|
30
|
+
*
|
|
31
|
+
* High-precision only — every entry is unambiguously a pure ack. When unsure,
|
|
32
|
+
* prefer omission: a missed ack simply reaches the model, while a false ack
|
|
33
|
+
* would silently drop routing. User-supplied `ackPhrases` are merged on top.
|
|
34
|
+
*/
|
|
35
|
+
const DEFAULT_ACK_PHRASES: string[] = [
|
|
36
|
+
// 中文
|
|
37
|
+
"好的",
|
|
38
|
+
"好",
|
|
39
|
+
"嗯",
|
|
40
|
+
"嗯嗯",
|
|
41
|
+
"嗯呢",
|
|
42
|
+
"行",
|
|
43
|
+
"可以",
|
|
44
|
+
"对",
|
|
45
|
+
"对的",
|
|
46
|
+
"是",
|
|
47
|
+
"是的",
|
|
48
|
+
"继续",
|
|
49
|
+
"继续吧",
|
|
50
|
+
"往下",
|
|
51
|
+
"没问题",
|
|
52
|
+
"明白",
|
|
53
|
+
"收到",
|
|
54
|
+
"了解",
|
|
55
|
+
"知道了",
|
|
56
|
+
"晓得",
|
|
57
|
+
"中",
|
|
58
|
+
"成",
|
|
59
|
+
"行吧",
|
|
60
|
+
"可以的",
|
|
61
|
+
"好的呀",
|
|
62
|
+
"好嘞",
|
|
63
|
+
"妥",
|
|
64
|
+
"妥了",
|
|
65
|
+
"嗯哼",
|
|
66
|
+
// English
|
|
67
|
+
"ok",
|
|
68
|
+
"okay",
|
|
69
|
+
"oki",
|
|
70
|
+
"okie",
|
|
71
|
+
"okk",
|
|
72
|
+
"k",
|
|
73
|
+
"sure",
|
|
74
|
+
"yes",
|
|
75
|
+
"yeah",
|
|
76
|
+
"yep",
|
|
77
|
+
"yup",
|
|
78
|
+
"continue",
|
|
79
|
+
"go",
|
|
80
|
+
"ahead",
|
|
81
|
+
"go ahead",
|
|
82
|
+
"sounds good",
|
|
83
|
+
"got it",
|
|
84
|
+
"understood",
|
|
85
|
+
"will do",
|
|
86
|
+
"agreed",
|
|
87
|
+
"roger",
|
|
88
|
+
"proceed",
|
|
89
|
+
"ack",
|
|
90
|
+
"acknowledged",
|
|
91
|
+
"fine",
|
|
92
|
+
// 日本語
|
|
93
|
+
"はい",
|
|
94
|
+
"うん",
|
|
95
|
+
"おk",
|
|
96
|
+
"続けて",
|
|
97
|
+
"了解",
|
|
98
|
+
"承知",
|
|
99
|
+
"わかった",
|
|
100
|
+
"分かった",
|
|
101
|
+
"ええ",
|
|
102
|
+
"継続",
|
|
103
|
+
"進めて",
|
|
104
|
+
"いいよ",
|
|
105
|
+
"いいです",
|
|
106
|
+
"オッケー",
|
|
107
|
+
"おけ",
|
|
108
|
+
// 한국어
|
|
109
|
+
"네",
|
|
110
|
+
"응",
|
|
111
|
+
"응응",
|
|
112
|
+
"계속",
|
|
113
|
+
"알겠어",
|
|
114
|
+
"알겠음",
|
|
115
|
+
"좋아",
|
|
116
|
+
"그래",
|
|
117
|
+
"오키",
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Normalize a prompt/phrase for ack matching: trim, lowercase, strip
|
|
122
|
+
* leading/trailing punctuation. Internal punctuation is preserved so that
|
|
123
|
+
* non-ack content keeps the string out of the ack set.
|
|
124
|
+
* @internal — exported for testing.
|
|
125
|
+
*/
|
|
126
|
+
export function normalizeAckPrompt(input: string): string {
|
|
127
|
+
return input
|
|
128
|
+
.trim()
|
|
129
|
+
.toLowerCase()
|
|
130
|
+
.replace(/^[。!?.!?,,、~~…·\s]+/, "")
|
|
131
|
+
.replace(/[。!?.!?,,、~~…·\s]+$/, "");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build the full ack-phrase set: built-in defaults plus user extras,
|
|
136
|
+
* all normalized. Deduplicated via Set.
|
|
137
|
+
* @internal — exported for testing.
|
|
138
|
+
*/
|
|
139
|
+
export function buildAckSet(extra: string[]): Set<string> {
|
|
140
|
+
const all = [...DEFAULT_ACK_PHRASES, ...extra];
|
|
141
|
+
const set = new Set<string>();
|
|
142
|
+
for (const phrase of all) {
|
|
143
|
+
const n = normalizeAckPrompt(phrase);
|
|
144
|
+
if (n.length > 0) set.add(n);
|
|
145
|
+
}
|
|
146
|
+
return set;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Evaluate whether a prompt can be short-circuited as a trivial ack.
|
|
151
|
+
* Returns the decision when it can, `null` when the prompt must reach the
|
|
152
|
+
* side model.
|
|
153
|
+
*
|
|
154
|
+
* @param prompt - The user's prompt text
|
|
155
|
+
* @param config - Short-circuit tuning
|
|
156
|
+
*/
|
|
157
|
+
export function evaluateShortCircuit(
|
|
158
|
+
prompt: string,
|
|
159
|
+
config: ShortCircuitConfig,
|
|
160
|
+
): ShortCircuitResult | null {
|
|
161
|
+
if (!config.trivialAck) return null;
|
|
162
|
+
|
|
163
|
+
const normalized = normalizeAckPrompt(prompt);
|
|
164
|
+
if (normalized.length === 0 || normalized.length > config.maxAckLength) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const ackSet = buildAckSet(config.ackPhrases);
|
|
169
|
+
return ackSet.has(normalized) ? { reasoning: "trivial ack" } : null;
|
|
170
|
+
}
|
package/src/side-agent.ts
CHANGED
|
@@ -1,69 +1,67 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Side agent invocation logic.
|
|
3
3
|
*
|
|
4
|
-
* Calls the side agent model
|
|
4
|
+
* Calls the side agent via model-roles' complete() (auth resolved internally)
|
|
5
5
|
* and parses the JSON decision response.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import type { ModelRolesAPI } from "@d3ara1n/pi-model-roles";
|
|
9
9
|
import type { ScoutDecision } from "./types.ts";
|
|
10
10
|
|
|
11
11
|
/** Minimal type for side agent context — matches pi-ai's Context interface. */
|
|
12
12
|
interface SideAgentContext {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
systemPrompt?: string;
|
|
14
|
+
messages: Array<{ role: "user"; content: string; timestamp: number }>;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Call the side agent and return its decision.
|
|
19
19
|
*
|
|
20
|
-
* @param
|
|
21
|
-
* @param
|
|
22
|
-
* @param headers - Custom headers for the side model
|
|
20
|
+
* @param rolesApi - ModelRolesAPI (provides complete() with auth resolved internally)
|
|
21
|
+
* @param roleName - Role name whose model + auth to use (from scout config)
|
|
23
22
|
* @param systemPrompt - Scout system prompt (includes skills/roles for cache friendliness)
|
|
24
23
|
* @param userMessage - Fully assembled user message (includes context, current role, user prompt)
|
|
25
24
|
* @returns Parsed ScoutDecision, or a safe fallback on error
|
|
26
25
|
*/
|
|
27
26
|
export async function callSideAgent(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
userMessage: string,
|
|
27
|
+
rolesApi: ModelRolesAPI,
|
|
28
|
+
roleName: string,
|
|
29
|
+
systemPrompt: string,
|
|
30
|
+
userMessage: string,
|
|
33
31
|
): Promise<ScoutDecision> {
|
|
34
|
-
|
|
32
|
+
const fallback: ScoutDecision = {
|
|
33
|
+
skills: [],
|
|
34
|
+
role: null,
|
|
35
|
+
reasoning: "side agent error",
|
|
36
|
+
source: "side-agent",
|
|
37
|
+
};
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
const context: SideAgentContext = {
|
|
40
|
+
systemPrompt,
|
|
41
|
+
messages: [
|
|
42
|
+
{
|
|
43
|
+
role: "user",
|
|
44
|
+
content: userMessage,
|
|
45
|
+
timestamp: Date.now(),
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
try {
|
|
51
|
+
const result = await rolesApi.complete(roleName, context, {
|
|
52
|
+
cacheRetention: "short",
|
|
53
|
+
});
|
|
54
|
+
const text =
|
|
55
|
+
result.content
|
|
56
|
+
?.filter((block: any) => block.type === "text")
|
|
57
|
+
?.map((block: any) => block.text)
|
|
58
|
+
?.join("") ?? "";
|
|
51
59
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const text = result.content
|
|
58
|
-
?.filter((block: any) => block.type === "text")
|
|
59
|
-
?.map((block: any) => block.text)
|
|
60
|
-
?.join("") ?? "";
|
|
61
|
-
|
|
62
|
-
return parseDecision(text);
|
|
63
|
-
} catch (err) {
|
|
64
|
-
console.warn("[pi-scout] Side agent call failed:", err);
|
|
65
|
-
return fallback;
|
|
66
|
-
}
|
|
60
|
+
return parseDecision(text);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.warn("[pi-scout] Side agent call failed:", err);
|
|
63
|
+
return fallback;
|
|
64
|
+
}
|
|
67
65
|
}
|
|
68
66
|
|
|
69
67
|
/**
|
|
@@ -71,30 +69,32 @@ export async function callSideAgent(
|
|
|
71
69
|
* Tolerant of markdown wrapping, extra whitespace, etc.
|
|
72
70
|
*/
|
|
73
71
|
function parseDecision(raw: string): ScoutDecision {
|
|
74
|
-
|
|
72
|
+
const fallback: ScoutDecision = {
|
|
73
|
+
skills: [],
|
|
74
|
+
role: null,
|
|
75
|
+
reasoning: "parse error",
|
|
76
|
+
source: "side-agent",
|
|
77
|
+
};
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
// Strip markdown code fences if present
|
|
80
|
+
let text = raw.trim();
|
|
81
|
+
if (text.startsWith("```")) {
|
|
82
|
+
text = text.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
|
|
83
|
+
}
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(text);
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
console.warn("[pi-scout] Failed to parse side agent response:", raw.slice(0, 200));
|
|
98
|
-
return fallback;
|
|
99
|
-
}
|
|
88
|
+
return {
|
|
89
|
+
skills: Array.isArray(parsed.skills)
|
|
90
|
+
? parsed.skills.filter((s: any) => typeof s === "string")
|
|
91
|
+
: [],
|
|
92
|
+
role: typeof parsed.role === "string" && parsed.role !== "null" ? parsed.role : null,
|
|
93
|
+
reasoning: typeof parsed.reasoning === "string" ? parsed.reasoning : "no reasoning provided",
|
|
94
|
+
source: "side-agent",
|
|
95
|
+
};
|
|
96
|
+
} catch {
|
|
97
|
+
console.warn("[pi-scout] Failed to parse side agent response:", raw.slice(0, 200));
|
|
98
|
+
return fallback;
|
|
99
|
+
}
|
|
100
100
|
}
|