@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.
@@ -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
- pi: ExtensionAPI,
16
- roleName: string,
17
- rolesApi: ModelRolesAPI,
15
+ pi: ExtensionAPI,
16
+ roleName: string,
17
+ rolesApi: ModelRolesAPI,
18
18
  ): Promise<boolean> {
19
- const resolved = await rolesApi.resolveRoleAsync(roleName);
19
+ const resolved = await rolesApi.resolveRoleAsync(roleName);
20
20
 
21
- if (!resolved.model) {
22
- console.warn(`[pi-scout] Role "${roleName}" could not be resolved — model not available`);
23
- return false;
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
- 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
- }
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
- if (resolved.config.thinking) {
33
- pi.setThinkingLevel(resolved.config.thinking);
34
- }
32
+ if (resolved.config.thinking) {
33
+ pi.setThinkingLevel(resolved.config.thinking);
34
+ }
35
35
 
36
- return true;
36
+ return true;
37
37
  }
@@ -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
- userPrompt: string;
10
- assistantSummary: string;
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
- userPrompt: string,
21
- currentRole: string,
22
- prevTurn?: PrevTurnContext,
20
+ userPrompt: string,
21
+ currentRole: string,
22
+ prevTurn?: PrevTurnContext,
23
23
  ): string {
24
- const parts: string[] = [];
24
+ const parts: string[] = [];
25
25
 
26
- parts.push(`Current role: ${currentRole}`);
26
+ parts.push(`Current role: ${currentRole}`);
27
27
 
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
- }
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
- parts.push(``);
40
- parts.push(`## Current User Prompt`);
41
- parts.push(userPrompt);
39
+ parts.push(``);
40
+ parts.push(`## Current User Prompt`);
41
+ parts.push(userPrompt);
42
42
 
43
- return parts.join("\n");
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
- config: ScoutConfig,
56
- skillsList: string,
57
- rolesList: string,
55
+ config: ScoutConfig,
56
+ skillsList: string,
57
+ rolesList: string,
58
58
  ): string {
59
- const parts: string[] = [];
59
+ const parts: string[] = [];
60
60
 
61
- parts.push(`You are a scout. Analyze the user's request and decide which skills and model role to use.`);
62
- parts.push(``);
63
- parts.push(`## Response Format`);
64
- parts.push(`Respond with ONLY a JSON object, no markdown, no explanation outside the JSON:`);
65
- parts.push(`{`);
66
- parts.push(` "skills": ["skill-name-1", "skill-name-2"],`);
67
- parts.push(` "role": "role-name-or-null",`);
68
- parts.push(` "reasoning": "one sentence explanation"`);
69
- parts.push(`}`);
70
- parts.push(``);
71
- parts.push(`## Rules`);
72
- parts.push(`- Select at most ${config.maxSelectedSkills} skills. Select 0 if none are relevant.`);
73
- parts.push(`- Only select skills that will materially help with the task.`);
74
- parts.push(`- If the task is trivial (simple question, acknowledgment), select 0 skills.`);
75
- parts.push(`- "role" should be null if the current role is appropriate.`);
76
- parts.push(`- Only suggest a role change when the task clearly benefits from a different model.`);
77
- parts.push(`- Be conservative: prefer fewer skills and no role change when uncertain.`);
78
- parts.push(`- Use the Previous Turn context to understand follow-up requests (e.g. "continue", "change that", "no, the other one").`);
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
- // Stable prefix ends here. Sections below are injected conditionally per
81
- // module toggle: a disabled module contributes no candidates, so the side
82
- // agent has nothing to choose from for it (and the application layer zeros
83
- // out its field regardless). The longer section (skills) comes first so
84
- // toggling the later module (roles) only invalidates the cache tail —
85
- // Anthropic prefix-cache matches the longest common prefix.
86
- if (config.modules.skillRouter) {
87
- parts.push(``);
88
- parts.push(`## Available Skills`);
89
- parts.push(skillsList || "(none)");
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
- if (config.modules.modelRouter) {
93
- parts.push(``);
94
- parts.push(`## Available Roles`);
95
- parts.push(rolesList);
96
- }
96
+ if (config.modules.modelRouter) {
97
+ parts.push(``);
98
+ parts.push(`## Available Roles`);
99
+ parts.push(rolesList);
100
+ }
97
101
 
98
- return parts.join("\n");
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 using pi-ai's complete() function
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 { complete } from "@earendil-works/pi-ai";
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
- systemPrompt?: string;
14
- messages: Array<{ role: "user"; content: string; timestamp: number }>;
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 sideModel - The Model instance to use (from pi-model-roles "side" role)
21
- * @param apiKey - API key for the side model
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
- sideModel: any,
29
- apiKey: string | undefined,
30
- headers: Record<string, string> | undefined,
31
- systemPrompt: string,
32
- userMessage: string,
27
+ rolesApi: ModelRolesAPI,
28
+ roleName: string,
29
+ systemPrompt: string,
30
+ userMessage: string,
33
31
  ): Promise<ScoutDecision> {
34
- const fallback: ScoutDecision = { skills: [], role: null, reasoning: "side agent error" };
32
+ const fallback: ScoutDecision = {
33
+ skills: [],
34
+ role: null,
35
+ reasoning: "side agent error",
36
+ source: "side-agent",
37
+ };
35
38
 
36
- const context: SideAgentContext = {
37
- systemPrompt,
38
- messages: [
39
- {
40
- role: "user",
41
- content: userMessage,
42
- timestamp: Date.now(),
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
- const options: Record<string, any> = {
48
- maxTokens: 256,
49
- cacheRetention: "short",
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
- if (apiKey) options.apiKey = apiKey;
53
- if (headers) options.headers = headers;
54
-
55
- try {
56
- const result = await complete(sideModel, context, options);
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
- const fallback: ScoutDecision = { skills: [], role: null, reasoning: "parse error" };
72
+ const fallback: ScoutDecision = {
73
+ skills: [],
74
+ role: null,
75
+ reasoning: "parse error",
76
+ source: "side-agent",
77
+ };
75
78
 
76
- // Strip markdown code fences if present
77
- let text = raw.trim();
78
- if (text.startsWith("```")) {
79
- text = text.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
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
- try {
83
- const parsed = JSON.parse(text);
85
+ try {
86
+ const parsed = JSON.parse(text);
84
87
 
85
- return {
86
- skills: Array.isArray(parsed.skills)
87
- ? parsed.skills.filter((s: any) => typeof s === "string")
88
- : [],
89
- role: typeof parsed.role === "string" && parsed.role !== "null"
90
- ? parsed.role
91
- : null,
92
- reasoning: typeof parsed.reasoning === "string"
93
- ? parsed.reasoning
94
- : "no reasoning provided",
95
- };
96
- } catch {
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
  }