pi-harness-runtime 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Shared Blackboard — RFC-0011
3
+ *
4
+ * Durable file-based coordination so agents communicate without the human
5
+ * acting as message bus.
6
+ *
7
+ * Layout:
8
+ * harness/blackboard/
9
+ * status.json
10
+ * next_action.json
11
+ * tasks.json
12
+ * agent_registry.json
13
+ * locks/
14
+ * reports/
15
+ * context/
16
+ * events.jsonl
17
+ */
18
+
19
+ import type {
20
+ BlackboardRecord,
21
+ NextAction,
22
+ AgentRegistry,
23
+ AgentReport,
24
+ LockInfo,
25
+ RuntimeEvent,
26
+ TaskGraph,
27
+ } from "../packages/types/src/runtime-types.ts";
28
+ import { writeJson, readJson, appendJsonl, ensureUsageDir } from "../cli.ts";
29
+ // @ts-expect-error - Bun has built-in Node.js types
30
+ import { join, dirname } from "node:path";
31
+
32
+ export class SharedBlackboard {
33
+ private readonly jobDir: string;
34
+ private record: BlackboardRecord | null = null;
35
+
36
+ constructor(jobId: string, rootDir: string) {
37
+ this.jobDir = join(rootDir, "jobs", jobId, "blackboard");
38
+ }
39
+
40
+ /**
41
+ * Initialize a new blackboard for a job
42
+ */
43
+ init(jobId: string, taskGraph: TaskGraph): void {
44
+ const now = new Date().toISOString();
45
+ this.record = {
46
+ jobId,
47
+ status: "created",
48
+ nextAction: undefined,
49
+ tasks: taskGraph,
50
+ agentRegistry: { agents: {} },
51
+ reports: {},
52
+ locks: {},
53
+ updatedAt: now,
54
+ };
55
+ this.save();
56
+ }
57
+
58
+ /**
59
+ * Load blackboard from disk
60
+ */
61
+ load(): BlackboardRecord | null {
62
+ const path = join(this.jobDir, "status.json");
63
+ this.record = readJson(path) as BlackboardRecord | null;
64
+ return this.record;
65
+ }
66
+
67
+ /**
68
+ * Save blackboard to disk
69
+ */
70
+ save(): void {
71
+ if (!this.record) return;
72
+ this.record.updatedAt = new Date().toISOString();
73
+ ensureUsageDir();
74
+ const path = join(this.jobDir, "status.json");
75
+ writeJson(path, this.record);
76
+ }
77
+
78
+ /**
79
+ * Update job status
80
+ */
81
+ updateStatus(status: BlackboardRecord["status"]): void {
82
+ if (!this.record) return;
83
+ this.record.status = status;
84
+ this.save();
85
+ this.appendEvent("StatusUpdated", { status });
86
+ }
87
+
88
+ /**
89
+ * Set the next action for agents to pick up
90
+ */
91
+ setNextAction(action: NextAction): void {
92
+ if (!this.record) return;
93
+ this.record.nextAction = action;
94
+ this.save();
95
+ this.appendEvent("NextActionUpdated", {
96
+ taskId: action.taskId,
97
+ agentId: action.agentId,
98
+ priority: action.priority,
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Clear the next action (after agent picks it up)
104
+ */
105
+ clearNextAction(): void {
106
+ if (!this.record) return;
107
+ this.record.nextAction = undefined;
108
+ this.save();
109
+ }
110
+
111
+ /**
112
+ * Register an agent
113
+ */
114
+ registerAgent(
115
+ agentId: string,
116
+ name: string,
117
+ provider: string,
118
+ model?: string,
119
+ ): void {
120
+ if (!this.record) return;
121
+ this.record.agentRegistry.agents[agentId] = {
122
+ id: agentId,
123
+ name,
124
+ provider,
125
+ model,
126
+ status: "idle",
127
+ startedAt: new Date().toISOString(),
128
+ };
129
+ this.save();
130
+ }
131
+
132
+ /**
133
+ * Update agent status
134
+ */
135
+ updateAgentStatus(
136
+ agentId: string,
137
+ status: AgentRegistry["agents"][string]["status"],
138
+ currentTaskId?: string,
139
+ ): void {
140
+ if (!this.record) return;
141
+ const agent = this.record.agentRegistry.agents[agentId];
142
+ if (!agent) return;
143
+ agent.status = status;
144
+ agent.currentTaskId = currentTaskId;
145
+ agent.lastHeartbeat = new Date().toISOString();
146
+ this.save();
147
+ }
148
+
149
+ /**
150
+ * Unregister an agent
151
+ */
152
+ unregisterAgent(agentId: string): void {
153
+ if (!this.record) return;
154
+ delete this.record.agentRegistry.agents[agentId];
155
+ this.save();
156
+ }
157
+
158
+ /**
159
+ * Write an agent report
160
+ */
161
+ writeReport(report: AgentReport): void {
162
+ if (!this.record) return;
163
+ this.record.reports[report.agentId] = report;
164
+ this.save();
165
+ this.appendEvent("AgentReportWritten", {
166
+ agentId: report.agentId,
167
+ taskId: report.taskId,
168
+ status: report.status,
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Acquire a lock on a task
174
+ */
175
+ acquireLock(taskId: string, agentId: string): boolean {
176
+ if (!this.record) return false;
177
+ if (this.record.locks[taskId]) {
178
+ return false; // Already locked
179
+ }
180
+ this.record.locks[taskId] = {
181
+ taskId,
182
+ agentId,
183
+ acquiredAt: new Date().toISOString(),
184
+ };
185
+ this.save();
186
+ this.appendEvent("LockAcquired", { taskId, agentId });
187
+ return true;
188
+ }
189
+
190
+ /**
191
+ * Release a lock on a task
192
+ */
193
+ releaseLock(taskId: string, agentId: string): boolean {
194
+ if (!this.record) return false;
195
+ const lock = this.record.locks[taskId];
196
+ if (!lock || lock.agentId !== agentId) {
197
+ return false; // Not locked by this agent
198
+ }
199
+ delete this.record.locks[taskId];
200
+ this.save();
201
+ this.appendEvent("LockReleased", { taskId, agentId });
202
+ return true;
203
+ }
204
+
205
+ /**
206
+ * Check if a task is locked
207
+ */
208
+ isLocked(taskId: string): boolean {
209
+ return !!this.record?.locks[taskId];
210
+ }
211
+
212
+ /**
213
+ * Get lock info for a task
214
+ */
215
+ getLock(taskId: string): LockInfo | null {
216
+ return this.record?.locks[taskId] ?? null;
217
+ }
218
+
219
+ /**
220
+ * Get the current record
221
+ */
222
+ getRecord(): BlackboardRecord | null {
223
+ return this.record;
224
+ }
225
+
226
+ /**
227
+ * Get active agents
228
+ */
229
+ getActiveAgents(): AgentRegistry["agents"][string][] {
230
+ if (!this.record) return [];
231
+ return Object.values(this.record.agentRegistry.agents);
232
+ }
233
+
234
+ /**
235
+ * Check for stale agents (no heartbeat in N minutes)
236
+ */
237
+ getStaleAgents(
238
+ maxAgeMinutes: number = 10,
239
+ ): AgentRegistry["agents"][string][] {
240
+ if (!this.record) return [];
241
+ const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
242
+ return Object.values(this.record.agentRegistry.agents).filter((a) => {
243
+ if (!a.lastHeartbeat) return false;
244
+ return Date.parse(a.lastHeartbeat) < cutoff;
245
+ });
246
+ }
247
+
248
+ /**
249
+ * Append an event to the event log
250
+ */
251
+ private appendEvent(type: string, data?: Record<string, unknown>): void {
252
+ if (!this.record) return;
253
+ const event: RuntimeEvent = {
254
+ ts: new Date().toISOString(),
255
+ jobId: this.record.jobId,
256
+ type,
257
+ message: `Blackboard event: ${type}`,
258
+ data,
259
+ };
260
+ const path = join(this.jobDir, "events.jsonl");
261
+ ensureUsageDir();
262
+ appendJsonl(path, event);
263
+ }
264
+
265
+ /**
266
+ * Export full blackboard state
267
+ */
268
+ export(): string {
269
+ return JSON.stringify(this.record, null, 2);
270
+ }
271
+
272
+ /**
273
+ * Get blackboard directory path
274
+ */
275
+ getPath(): string {
276
+ return this.jobDir;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Create and initialize a blackboard for a job
282
+ */
283
+ export function createBlackboard(
284
+ jobId: string,
285
+ rootDir: string,
286
+ taskGraph: TaskGraph,
287
+ ): SharedBlackboard {
288
+ const blackboard = new SharedBlackboard(jobId, rootDir);
289
+ blackboard.init(jobId, taskGraph);
290
+ return blackboard;
291
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Context Window Manager — RFC-0010
3
+ *
4
+ * Manages context window usage across providers.
5
+ * Tracks utilization and suggests strategies when approaching limits.
6
+ */
7
+
8
+ import type {
9
+ ContextWindowStats,
10
+ ContextWindowConfig,
11
+ ProviderMessage,
12
+ } from "../packages/types/src/runtime-types.ts";
13
+
14
+ export interface ContextWindowUpdate {
15
+ provider: string;
16
+ model: string;
17
+ maxTokens: number;
18
+ usedTokens: number;
19
+ }
20
+
21
+ export class ContextWindowManager {
22
+ private stats: Map<string, ContextWindowStats> = new Map();
23
+ private configs: Map<string, ContextWindowConfig> = new Map();
24
+
25
+ constructor(defaultConfigs?: Record<string, ContextWindowConfig>) {
26
+ // Initialize with defaults
27
+ const defaults: Record<string, ContextWindowConfig> = {
28
+ minimax: {
29
+ warningThreshold: 0.8,
30
+ criticalThreshold: 0.95,
31
+ strategy: "truncate",
32
+ },
33
+ anthropic: {
34
+ warningThreshold: 0.85,
35
+ criticalThreshold: 0.97,
36
+ strategy: "truncate",
37
+ },
38
+ openai: {
39
+ warningThreshold: 0.8,
40
+ criticalThreshold: 0.95,
41
+ strategy: "truncate",
42
+ },
43
+ ...defaultConfigs,
44
+ };
45
+
46
+ for (const [provider, config] of Object.entries(defaults)) {
47
+ this.configs.set(provider, config);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Update context window stats after a request
53
+ */
54
+ updateStats(update: ContextWindowUpdate): ContextWindowStats {
55
+ const { provider, model, maxTokens, usedTokens } = update;
56
+ const stats: ContextWindowStats = {
57
+ provider,
58
+ model,
59
+ maxTokens,
60
+ usedTokens,
61
+ availableTokens: maxTokens - usedTokens,
62
+ utilizationPct: usedTokens / maxTokens,
63
+ };
64
+ this.stats.set(`${provider}:${model}`, stats);
65
+ return stats;
66
+ }
67
+
68
+ /**
69
+ * Get stats for a provider/model
70
+ */
71
+ getStats(provider: string, model: string): ContextWindowStats | null {
72
+ return this.stats.get(`${provider}:${model}`) ?? null;
73
+ }
74
+
75
+ /**
76
+ * Get all stats
77
+ */
78
+ getAllStats(): ContextWindowStats[] {
79
+ return Array.from(this.stats.values());
80
+ }
81
+
82
+ /**
83
+ * Check utilization status
84
+ */
85
+ getUtilizationStatus(
86
+ provider: string,
87
+ model: string,
88
+ ): "ok" | "warning" | "critical" | "unknown" {
89
+ const stats = this.getStats(provider, model);
90
+ if (!stats) return "unknown";
91
+
92
+ const config = this.configs.get(provider);
93
+ if (!config) return "ok";
94
+
95
+ const pct = stats.utilizationPct;
96
+ if (pct >= config.criticalThreshold) return "critical";
97
+ if (pct >= config.warningThreshold) return "warning";
98
+ return "ok";
99
+ }
100
+
101
+ /**
102
+ * Estimate remaining capacity
103
+ */
104
+ estimateRemainingRequests(
105
+ provider: string,
106
+ model: string,
107
+ avgTokensPerRequest: number,
108
+ ): number | null {
109
+ const stats = this.getStats(provider, model);
110
+ if (!stats) return null;
111
+ return Math.floor(stats.availableTokens / avgTokensPerRequest);
112
+ }
113
+
114
+ /**
115
+ * Prepare messages for a request (truncation/summarization strategy)
116
+ */
117
+ prepareMessages(
118
+ messages: ProviderMessage[],
119
+ maxTokens: number,
120
+ strategy: "truncate" | "summarize" | "split" = "truncate",
121
+ ): ProviderMessage[] {
122
+ const estimateTokens = (text: string) => Math.ceil(text.length / 4);
123
+ let totalTokens = messages.reduce(
124
+ (sum, m) => sum + estimateTokens(m.content),
125
+ 0,
126
+ );
127
+
128
+ if (totalTokens <= maxTokens) {
129
+ return messages;
130
+ }
131
+
132
+ switch (strategy) {
133
+ case "truncate": {
134
+ // Remove oldest messages first
135
+ const truncated = [...messages];
136
+ while (totalTokens > maxTokens && truncated.length > 1) {
137
+ const removed = truncated.shift();
138
+ if (removed) {
139
+ totalTokens -= estimateTokens(removed.content);
140
+ }
141
+ }
142
+ return truncated;
143
+ }
144
+
145
+ case "summarize":
146
+ // In a real implementation, this would use a summarization LLM
147
+ // For now, fall back to truncate
148
+ return this.prepareMessages(messages, maxTokens, "truncate");
149
+
150
+ case "split":
151
+ // Split into multiple requests if possible
152
+ // Return first portion
153
+ return this.prepareMessages(messages, maxTokens, "truncate");
154
+
155
+ default:
156
+ return messages;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Set config for a provider
162
+ */
163
+ setConfig(provider: string, config: ContextWindowConfig): void {
164
+ this.configs.set(provider, config);
165
+ }
166
+
167
+ /**
168
+ * Get config for a provider
169
+ */
170
+ getConfig(provider: string): ContextWindowConfig | undefined {
171
+ return this.configs.get(provider);
172
+ }
173
+
174
+ /**
175
+ * Generate utilization report
176
+ */
177
+ generateReport(): string {
178
+ const lines = ["Context Window Utilization Report", "=".repeat(40), ""];
179
+
180
+ for (const [key, stats] of this.stats.entries()) {
181
+ const config = this.configs.get(stats.provider);
182
+ const bar = this.renderBar(stats.utilizationPct);
183
+ const status = this.getUtilizationStatus(stats.provider, stats.model);
184
+
185
+ lines.push(`${stats.provider}/${stats.model}`);
186
+ lines.push(` ${bar} ${(stats.utilizationPct * 100).toFixed(1)}%`);
187
+ lines.push(
188
+ ` Used: ${stats.usedTokens.toLocaleString()} / ${stats.maxTokens.toLocaleString()} tokens`,
189
+ );
190
+ lines.push(
191
+ ` Available: ${stats.availableTokens.toLocaleString()} tokens`,
192
+ );
193
+ lines.push(` Status: ${status.toUpperCase()}`);
194
+ if (config) {
195
+ lines.push(
196
+ ` Thresholds: warning=${(config.warningThreshold * 100).toFixed(0)}%, critical=${(config.criticalThreshold * 100).toFixed(0)}%`,
197
+ );
198
+ }
199
+ lines.push("");
200
+ }
201
+
202
+ return lines.join("\n");
203
+ }
204
+
205
+ private renderBar(pct: number, width: number = 20): string {
206
+ const filled = Math.round(pct * width);
207
+ const empty = width - filled;
208
+ return "[" + "█".repeat(filled) + "░".repeat(empty) + "]";
209
+ }
210
+ }