brl-subagent 2.3.4
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/LICENSE +21 -0
- package/README.md +257 -0
- package/package.json +49 -0
- package/presets/code-reviewer.md +37 -0
- package/presets/data-analyst.md +33 -0
- package/presets/debugger.md +36 -0
- package/presets/dev-agent.md +30 -0
- package/presets/rapid-prototyper.md +25 -0
- package/presets/refactorer.md +34 -0
- package/presets/security-auditor.md +39 -0
- package/presets/tech-writer.md +33 -0
- package/presets/test-engineer.md +34 -0
- package/src/concurrency.ts +186 -0
- package/src/diff.ts +102 -0
- package/src/event-bus.ts +86 -0
- package/src/git.ts +187 -0
- package/src/history.ts +177 -0
- package/src/index.ts +3824 -0
- package/src/logging.ts +135 -0
- package/src/messaging.ts +164 -0
- package/src/metrics.ts +221 -0
- package/src/model-availability.ts +73 -0
- package/src/notify-completion.ts +207 -0
- package/src/params.ts +225 -0
- package/src/preflight.ts +150 -0
- package/src/presets.ts +425 -0
- package/src/prompt.ts +169 -0
- package/src/reports.ts +325 -0
- package/src/router.ts +133 -0
- package/src/runner.ts +877 -0
- package/src/sanitize.ts +365 -0
- package/src/scheduler.ts +187 -0
- package/src/session-manager.ts +1065 -0
- package/src/state.ts +590 -0
- package/src/templates.ts +469 -0
- package/src/transcript-tail.ts +327 -0
- package/src/transcript.ts +119 -0
- package/src/tui-format.ts +51 -0
- package/src/tui.ts +3103 -0
- package/src/types.ts +803 -0
- package/src/unit-run.ts +269 -0
- package/src/update.ts +109 -0
- package/src/validate.ts +302 -0
- package/templates/analyze-data.md +9 -0
- package/templates/code-review.md +10 -0
- package/templates/debug-issue.md +9 -0
- package/templates/implement-feature.md +9 -0
- package/templates/prototype.md +9 -0
- package/templates/refactor.md +9 -0
- package/templates/security-audit.md +10 -0
- package/templates/write-docs.md +9 -0
- package/templates/write-tests.md +10 -0
package/src/logging.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — Structured Logging (F10)
|
|
3
|
+
*
|
|
4
|
+
* Provides leveled, structured logging with file output and rotation.
|
|
5
|
+
* Logs are written to .pi/subagent-logs/ in the project directory.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const log = createLogger("runner", cwd);
|
|
9
|
+
* log.info("Subagent started", { model, thinkingLevel });
|
|
10
|
+
* log.error("Subagent failed", { error: err.message });
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import type { LogLevel } from "./types";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Configuration
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** Maximum single log file size before rotation (5MB) */
|
|
22
|
+
const MAX_LOG_SIZE = 5 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
/** Maximum number of rotated log files to keep */
|
|
25
|
+
const MAX_LOG_FILES = 5;
|
|
26
|
+
|
|
27
|
+
/** Log levels in order of verbosity */
|
|
28
|
+
const LOG_LEVELS: Record<LogLevel, number> = {
|
|
29
|
+
debug: 0,
|
|
30
|
+
info: 1,
|
|
31
|
+
warn: 2,
|
|
32
|
+
error: 3,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Current minimum log level — can be changed at runtime */
|
|
36
|
+
let minLevel: LogLevel = "info";
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Logger
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
export interface Logger {
|
|
43
|
+
debug(message: string, data?: Record<string, unknown>): void;
|
|
44
|
+
info(message: string, data?: Record<string, unknown>): void;
|
|
45
|
+
warn(message: string, data?: Record<string, unknown>): void;
|
|
46
|
+
error(message: string, data?: Record<string, unknown>): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function setLogLevel(level: LogLevel): void {
|
|
50
|
+
minLevel = level;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a logger for a specific module.
|
|
55
|
+
* Logs are written to `.pi/subagent-logs/<prefix>.log` relative to `cwd`.
|
|
56
|
+
*/
|
|
57
|
+
export function createLogger(prefix: string, cwd?: string): Logger {
|
|
58
|
+
const logDir = cwd ? path.join(cwd, ".pi", "subagent-logs") : undefined;
|
|
59
|
+
|
|
60
|
+
// Ensure log directory exists — owner-only (0o700) so other local users
|
|
61
|
+
// cannot list log files (F6 / issue #29). File writes below already use
|
|
62
|
+
// 0o600.
|
|
63
|
+
if (logDir) {
|
|
64
|
+
try {
|
|
65
|
+
fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
66
|
+
} catch {
|
|
67
|
+
// Can't create log dir — fall back to console-only logging
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function log(level: LogLevel, message: string, data?: Record<string, unknown>): void {
|
|
72
|
+
if (LOG_LEVELS[level] < LOG_LEVELS[minLevel]) return;
|
|
73
|
+
|
|
74
|
+
const timestamp = new Date().toISOString();
|
|
75
|
+
const entry = {
|
|
76
|
+
timestamp,
|
|
77
|
+
level,
|
|
78
|
+
prefix,
|
|
79
|
+
message,
|
|
80
|
+
...(data ? { data } : {}),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const line = JSON.stringify(entry);
|
|
84
|
+
|
|
85
|
+
// Console output
|
|
86
|
+
const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
87
|
+
consoleMethod(`[${prefix}] ${level.toUpperCase()}: ${message}`);
|
|
88
|
+
|
|
89
|
+
// File output
|
|
90
|
+
if (logDir) {
|
|
91
|
+
try {
|
|
92
|
+
const logFile = path.join(logDir, `${prefix}.log`);
|
|
93
|
+
rotateIfNeeded(logFile);
|
|
94
|
+
fs.appendFileSync(logFile, line + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
95
|
+
} catch {
|
|
96
|
+
// Silently fail — logging should never crash the extension
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
debug: (m, d) => log("debug", m, d),
|
|
103
|
+
info: (m, d) => log("info", m, d),
|
|
104
|
+
warn: (m, d) => log("warn", m, d),
|
|
105
|
+
error: (m, d) => log("error", m, d),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Log rotation
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function rotateIfNeeded(logFile: string): void {
|
|
114
|
+
try {
|
|
115
|
+
const stat = fs.statSync(logFile);
|
|
116
|
+
if (stat.size < MAX_LOG_SIZE) return;
|
|
117
|
+
|
|
118
|
+
// Rotate: remove oldest, shift others, rename current
|
|
119
|
+
for (let i = MAX_LOG_FILES - 1; i >= 0; i--) {
|
|
120
|
+
const oldFile = i === 0 ? logFile : `${logFile}.${i}`;
|
|
121
|
+
const newFile = `${logFile}.${i + 1}`;
|
|
122
|
+
try {
|
|
123
|
+
if (i === MAX_LOG_FILES - 1) {
|
|
124
|
+
fs.unlinkSync(oldFile);
|
|
125
|
+
} else {
|
|
126
|
+
fs.renameSync(oldFile, newFile);
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// File might not exist — that's fine
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// Log file doesn't exist yet — nothing to rotate
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/messaging.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — Subagent-to-Subagent Messaging (E10)
|
|
3
|
+
*
|
|
4
|
+
* Provides an Intercom class that manages inter-subagent communication
|
|
5
|
+
* via a shared message channel. Subagents can send targeted or broadcast
|
|
6
|
+
* messages using a special output format parsed by the conductor.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Types
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export interface Message {
|
|
14
|
+
from: string;
|
|
15
|
+
content: string;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Regex for extracting TO: patterns from subagent output
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Matches standalone lines like:
|
|
25
|
+
* [TO:agent-id]:some message text
|
|
26
|
+
* [TO:*]:broadcast message text
|
|
27
|
+
*
|
|
28
|
+
* Anchored to the line start (with optional leading whitespace) using the
|
|
29
|
+
* multiline flag, so ONLY standalone lines whose trimmed content begins
|
|
30
|
+
* with "[TO:" are matched — a mid-sentence mention (e.g. "append
|
|
31
|
+
* [TO:target]:msg ...") is NOT treated as a message.
|
|
32
|
+
*
|
|
33
|
+
* Not global: extractMessages builds its own stateful /g instance to
|
|
34
|
+
* avoid lastIndex leaks between calls.
|
|
35
|
+
*
|
|
36
|
+
* Group 1 = target id or "*"
|
|
37
|
+
* Group 2 = message content (rest of line after colon, trimmed by caller)
|
|
38
|
+
*/
|
|
39
|
+
export const TO_PATTERN = /^\s*\[TO:([^*\]]+|\*)\]:([^\n]+)/m;
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Intercom class
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export class Intercom {
|
|
46
|
+
/**
|
|
47
|
+
* Map of subagent ID → queue of incoming messages.
|
|
48
|
+
* Keys are created lazily when a message is sent to a subagent.
|
|
49
|
+
*/
|
|
50
|
+
private messages: Map<string, Message[]> = new Map();
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Known subagent IDs. Populated via `register()` as subagents are spawned.
|
|
54
|
+
* Used by `broadcast` to determine recipients.
|
|
55
|
+
*/
|
|
56
|
+
private knownIds: Set<string> = new Set();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Register a subagent ID so it can receive broadcasts.
|
|
60
|
+
*/
|
|
61
|
+
register(id: string): void {
|
|
62
|
+
this.knownIds.add(id);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Remove a subagent ID from the known set (for cleanup).
|
|
67
|
+
*/
|
|
68
|
+
unregister(id: string): void {
|
|
69
|
+
this.knownIds.delete(id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Send a message from one subagent to another.
|
|
74
|
+
*/
|
|
75
|
+
send(fromId: string, toId: string, content: string): void {
|
|
76
|
+
if (!this.messages.has(toId)) {
|
|
77
|
+
this.messages.set(toId, []);
|
|
78
|
+
}
|
|
79
|
+
this.messages.get(toId)!.push({
|
|
80
|
+
from: fromId,
|
|
81
|
+
content,
|
|
82
|
+
timestamp: Date.now(),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Return all messages for a subagent without clearing the queue.
|
|
88
|
+
*/
|
|
89
|
+
receive(id: string): Message[] {
|
|
90
|
+
return this.messages.get(id) ?? [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Return all messages for a subagent and clear the queue.
|
|
95
|
+
*/
|
|
96
|
+
receiveAndClear(id: string): Message[] {
|
|
97
|
+
const msgs = this.messages.get(id) ?? [];
|
|
98
|
+
this.messages.set(id, []);
|
|
99
|
+
return msgs;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Returns true if there are pending messages for this subagent.
|
|
104
|
+
*/
|
|
105
|
+
hasMessages(id: string): boolean {
|
|
106
|
+
const q = this.messages.get(id);
|
|
107
|
+
return q !== undefined && q.length > 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Send a message to ALL known subagent IDs except the sender and any
|
|
112
|
+
* explicitly excluded IDs.
|
|
113
|
+
*/
|
|
114
|
+
broadcast(fromId: string, content: string, excludeIds?: string[]): void {
|
|
115
|
+
const exclude = new Set(excludeIds ?? []);
|
|
116
|
+
exclude.add(fromId); // never send back to self
|
|
117
|
+
|
|
118
|
+
for (const id of this.knownIds) {
|
|
119
|
+
if (!exclude.has(id)) {
|
|
120
|
+
this.send(fromId, id, content);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Return the list of all known subagent IDs.
|
|
127
|
+
*/
|
|
128
|
+
getKnownIds(): string[] {
|
|
129
|
+
return [...this.knownIds];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Output parsing helpers
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Extract all [TO:...] messages from a subagent's output text.
|
|
139
|
+
* Returns an array of { target, content } objects.
|
|
140
|
+
*/
|
|
141
|
+
export function extractMessages(
|
|
142
|
+
output: string,
|
|
143
|
+
): Array<{ target: string; content: string }> {
|
|
144
|
+
const results: Array<{ target: string; content: string }> = [];
|
|
145
|
+
const regex = new RegExp(TO_PATTERN.source, `${TO_PATTERN.flags}g`);
|
|
146
|
+
let match: RegExpExecArray | null;
|
|
147
|
+
while ((match = regex.exec(output)) !== null) {
|
|
148
|
+
results.push({
|
|
149
|
+
target: match[1],
|
|
150
|
+
content: match[2].trim(),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return results;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Format a list of incoming messages into a string that can be appended
|
|
158
|
+
* to a subagent's task prompt.
|
|
159
|
+
*/
|
|
160
|
+
export function formatPendingMessages(messages: Message[]): string {
|
|
161
|
+
return messages
|
|
162
|
+
.map((m) => `From ${m.from}: ${m.content}`)
|
|
163
|
+
.join("\n");
|
|
164
|
+
}
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — SLA Metrics (E4)
|
|
3
|
+
*
|
|
4
|
+
* Computes performance metrics from run history and detects degradation
|
|
5
|
+
* against a baseline.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SubagentRun, SLAMetrics, DegradationReport, ErrorCategory } from "./types";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Percentile helper
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compute the p-th percentile of a sorted numeric array (0-100).
|
|
16
|
+
* Uses linear interpolation between ranks for accuracy.
|
|
17
|
+
*/
|
|
18
|
+
function percentile(sorted: number[], p: number): number {
|
|
19
|
+
if (sorted.length === 0) return 0;
|
|
20
|
+
if (sorted.length === 1) return sorted[0];
|
|
21
|
+
const rank = (p / 100) * (sorted.length - 1);
|
|
22
|
+
const lower = Math.floor(rank);
|
|
23
|
+
const upper = Math.ceil(rank);
|
|
24
|
+
if (lower === upper) return sorted[lower];
|
|
25
|
+
const weight = rank - lower;
|
|
26
|
+
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// SLA metric computation
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Compute SLA metrics from a list of SubagentRun records.
|
|
35
|
+
*
|
|
36
|
+
* Duration calculations use only runs that have finishedAt and durationMs set.
|
|
37
|
+
* Runs with status "running" are excluded from duration stats but counted in totalRuns.
|
|
38
|
+
*
|
|
39
|
+
* @param runs - Array of SubagentRun records (most recent first is fine; sorting happens internally)
|
|
40
|
+
* @returns Computed SLAMetrics
|
|
41
|
+
*/
|
|
42
|
+
export function computeSLAMetrics(runs: SubagentRun[]): SLAMetrics {
|
|
43
|
+
const totalRuns = runs.length;
|
|
44
|
+
|
|
45
|
+
if (totalRuns === 0) {
|
|
46
|
+
return {
|
|
47
|
+
totalRuns: 0,
|
|
48
|
+
successRate: 0,
|
|
49
|
+
failureRate: 0,
|
|
50
|
+
averageDurationMs: 0,
|
|
51
|
+
p50DurationMs: 0,
|
|
52
|
+
p95DurationMs: 0,
|
|
53
|
+
p99DurationMs: 0,
|
|
54
|
+
totalCost: 0,
|
|
55
|
+
averageCost: 0,
|
|
56
|
+
errorCategoryBreakdown: {},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Count successes and failures (running runs are neither)
|
|
61
|
+
const succeeded = runs.filter((r) => r.status === "done").length;
|
|
62
|
+
const failed = runs.filter((r) => r.status === "failed").length;
|
|
63
|
+
const finishedRuns = succeeded + failed;
|
|
64
|
+
|
|
65
|
+
const successRate = finishedRuns > 0 ? succeeded / finishedRuns : 0;
|
|
66
|
+
const failureRate = finishedRuns > 0 ? failed / finishedRuns : 0;
|
|
67
|
+
|
|
68
|
+
// Duration stats — only runs with durationMs set
|
|
69
|
+
const durations = runs
|
|
70
|
+
.filter((r) => r.durationMs !== undefined && r.durationMs !== null)
|
|
71
|
+
.map((r) => r.durationMs!);
|
|
72
|
+
|
|
73
|
+
const sortedDurations = [...durations].sort((a, b) => a - b);
|
|
74
|
+
|
|
75
|
+
const averageDurationMs =
|
|
76
|
+
durations.length > 0
|
|
77
|
+
? durations.reduce((sum, d) => sum + d, 0) / durations.length
|
|
78
|
+
: 0;
|
|
79
|
+
|
|
80
|
+
const p50DurationMs = percentile(sortedDurations, 50);
|
|
81
|
+
const p95DurationMs = percentile(sortedDurations, 95);
|
|
82
|
+
const p99DurationMs = percentile(sortedDurations, 99);
|
|
83
|
+
|
|
84
|
+
// Cost stats
|
|
85
|
+
const costs = runs.map((r) => r.cost ?? 0);
|
|
86
|
+
const totalCost = costs.reduce((sum, c) => sum + c, 0);
|
|
87
|
+
const averageCost = totalRuns > 0 ? totalCost / totalRuns : 0;
|
|
88
|
+
|
|
89
|
+
// Error category breakdown
|
|
90
|
+
const errorCategoryBreakdown: Record<string, number> = {};
|
|
91
|
+
for (const run of runs) {
|
|
92
|
+
const cat = run.originalParams?.errorCategory as ErrorCategory | undefined;
|
|
93
|
+
if (cat) {
|
|
94
|
+
errorCategoryBreakdown[cat] = (errorCategoryBreakdown[cat] ?? 0) + 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
totalRuns,
|
|
100
|
+
successRate,
|
|
101
|
+
failureRate,
|
|
102
|
+
averageDurationMs,
|
|
103
|
+
p50DurationMs,
|
|
104
|
+
p95DurationMs,
|
|
105
|
+
p99DurationMs,
|
|
106
|
+
totalCost,
|
|
107
|
+
averageCost,
|
|
108
|
+
errorCategoryBreakdown,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Degradation detection
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Compare current SLA metrics against a baseline to detect degradation.
|
|
118
|
+
*
|
|
119
|
+
* Degradation is flagged if:
|
|
120
|
+
* - Success rate dropped by more than 10 percentage points, OR
|
|
121
|
+
* - P95 latency increased by more than 2x
|
|
122
|
+
*
|
|
123
|
+
* @param current - Current SLA metrics
|
|
124
|
+
* @param baseline - Baseline SLA metrics to compare against
|
|
125
|
+
* @returns DegradationReport with comparison results and recommendations
|
|
126
|
+
*/
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Cost trend & sparkline (E1: Dashboard)
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Return the last N run costs as an array of numbers, most recent last.
|
|
133
|
+
* Filters to runs that have a cost field set.
|
|
134
|
+
*/
|
|
135
|
+
export function computeCostTrend(runs: SubagentRun[], count: number): number[] {
|
|
136
|
+
return runs
|
|
137
|
+
.filter((r) => r.cost !== undefined && r.cost !== null)
|
|
138
|
+
.slice(-count)
|
|
139
|
+
.map((r) => r.cost!);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Unicode sparkline characters, ordered from lowest to highest.
|
|
144
|
+
*/
|
|
145
|
+
const SPARKLINE_CHARS = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Convert an array of numbers to a Unicode sparkline string.
|
|
149
|
+
* Values are scaled to the 8-character range. Empty array returns empty string.
|
|
150
|
+
*/
|
|
151
|
+
export function formatSparkline(values: number[]): string {
|
|
152
|
+
if (values.length === 0) return '';
|
|
153
|
+
const min = Math.min(...values);
|
|
154
|
+
const max = Math.max(...values);
|
|
155
|
+
const range = max - min;
|
|
156
|
+
if (range === 0) {
|
|
157
|
+
// All values equal — show middle character
|
|
158
|
+
return values.map(() => SPARKLINE_CHARS[3]).join('');
|
|
159
|
+
}
|
|
160
|
+
return values
|
|
161
|
+
.map((v) => {
|
|
162
|
+
const normalized = (v - min) / range;
|
|
163
|
+
const index = Math.min(Math.floor(normalized * SPARKLINE_CHARS.length), SPARKLINE_CHARS.length - 1);
|
|
164
|
+
return SPARKLINE_CHARS[index];
|
|
165
|
+
})
|
|
166
|
+
.join('');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function computeDegradation(
|
|
170
|
+
current: SLAMetrics,
|
|
171
|
+
baseline: SLAMetrics,
|
|
172
|
+
): DegradationReport {
|
|
173
|
+
const recommendations: string[] = [];
|
|
174
|
+
|
|
175
|
+
// Success rate change (percentage points, negative = drop)
|
|
176
|
+
const successRateChange =
|
|
177
|
+
baseline.successRate > 0
|
|
178
|
+
? (current.successRate - baseline.successRate) * 100
|
|
179
|
+
: 0;
|
|
180
|
+
|
|
181
|
+
// P95 latency change (ratio: current / baseline)
|
|
182
|
+
const p95Change =
|
|
183
|
+
baseline.p95DurationMs > 0
|
|
184
|
+
? current.p95DurationMs / baseline.p95DurationMs
|
|
185
|
+
: current.p95DurationMs > 0
|
|
186
|
+
? Infinity
|
|
187
|
+
: 0;
|
|
188
|
+
|
|
189
|
+
// Degradation criteria
|
|
190
|
+
const successRateDegraded = successRateChange < -10;
|
|
191
|
+
const p95Degraded = p95Change > 2;
|
|
192
|
+
const degraded = successRateDegraded || p95Degraded;
|
|
193
|
+
|
|
194
|
+
// Generate recommendations
|
|
195
|
+
if (successRateDegraded) {
|
|
196
|
+
recommendations.push(
|
|
197
|
+
`Success rate dropped by ${Math.abs(successRateChange).toFixed(1)}% ` +
|
|
198
|
+
`(from ${(baseline.successRate * 100).toFixed(1)}% to ${(current.successRate * 100).toFixed(1)}%). ` +
|
|
199
|
+
`Check error category breakdown for root cause.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (p95Degraded) {
|
|
204
|
+
recommendations.push(
|
|
205
|
+
`P95 latency increased ${p95Change.toFixed(1)}x ` +
|
|
206
|
+
`(from ${baseline.p95DurationMs.toFixed(0)}ms to ${current.p95DurationMs.toFixed(0)}ms). ` +
|
|
207
|
+
`Consider increasing timeout or reducing task complexity.`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!degraded) {
|
|
212
|
+
recommendations.push("No significant degradation detected. Metrics are within normal range.");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
degraded,
|
|
217
|
+
successRateChange,
|
|
218
|
+
p95Change,
|
|
219
|
+
recommendations,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth-aware model availability check (issue #4).
|
|
3
|
+
*
|
|
4
|
+
* A model may exist in pi's CATALOG (built-in model list) while its provider
|
|
5
|
+
* has no configured credentials (no API key / OAuth). Spawning a subagent with
|
|
6
|
+
* such a model fails at spawn time ("No API key found") instead of triggering
|
|
7
|
+
* the preset-model fallback. This module checks BOTH catalog presence AND
|
|
8
|
+
* provider auth configuration before a model is considered available.
|
|
9
|
+
*
|
|
10
|
+
* Structural subset of the pi SDK `ModelRegistry` (see
|
|
11
|
+
* @earendil-works/pi-coding-agent/dist/core/model-registry.d.ts):
|
|
12
|
+
* - find(provider, modelId): Model | undefined
|
|
13
|
+
* - getAll(): Model[]
|
|
14
|
+
* - hasConfiguredAuth(model): boolean
|
|
15
|
+
* - getProviderAuthStatus(provider): AuthStatus // { configured: boolean; ... }
|
|
16
|
+
*
|
|
17
|
+
* Kept free of pi runtime imports so it can be unit-tested in isolation.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface ModelLike {
|
|
21
|
+
provider: string;
|
|
22
|
+
id: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ProviderAuthStatusLike {
|
|
26
|
+
configured?: boolean;
|
|
27
|
+
source?: string;
|
|
28
|
+
label?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ModelRegistryLike {
|
|
32
|
+
find?(provider: string, modelId: string): ModelLike | undefined;
|
|
33
|
+
getAll?(): ModelLike[];
|
|
34
|
+
hasConfiguredAuth?(model: ModelLike): boolean;
|
|
35
|
+
getProviderAuthStatus?(provider: string): ProviderAuthStatusLike | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check that provider/model exists in the catalog AND that the provider has
|
|
40
|
+
* configured auth. Falls back to catalog-only presence when the registry
|
|
41
|
+
* exposes no auth API (old behavior).
|
|
42
|
+
*/
|
|
43
|
+
export function modelIsAvailable(
|
|
44
|
+
registry: ModelRegistryLike | undefined,
|
|
45
|
+
m: { provider: string; id: string },
|
|
46
|
+
): boolean {
|
|
47
|
+
try {
|
|
48
|
+
// Find the model in the catalog
|
|
49
|
+
let model: ModelLike | undefined;
|
|
50
|
+
if (registry?.find) {
|
|
51
|
+
model = registry.find(m.provider, m.id);
|
|
52
|
+
}
|
|
53
|
+
if (!model) {
|
|
54
|
+
const models = registry?.getAll?.() ?? [];
|
|
55
|
+
model = models.find((x) => x.provider === m.provider && x.id === m.id);
|
|
56
|
+
}
|
|
57
|
+
if (!model) return false;
|
|
58
|
+
|
|
59
|
+
// Auth-aware check: model exists in catalog BUT provider must have configured auth
|
|
60
|
+
if (typeof registry?.hasConfiguredAuth === "function") {
|
|
61
|
+
return registry.hasConfiguredAuth(model);
|
|
62
|
+
}
|
|
63
|
+
// Fallback: getProviderAuthStatus (AuthStatus = { configured: boolean; ... })
|
|
64
|
+
if (typeof registry?.getProviderAuthStatus === "function") {
|
|
65
|
+
const status = registry.getProviderAuthStatus(m.provider);
|
|
66
|
+
return status?.configured === true;
|
|
67
|
+
}
|
|
68
|
+
// No auth API — trust the catalog (old behavior)
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|