pi-harness-runtime 1.1.43 → 1.1.45
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/harness/langchain/agents.test.ts +40 -0
- package/harness/langchain/agents.ts +49 -5
- package/harness/langchain/daemon.ts +126 -1
- package/harness/langchain/file-tools.ts +178 -0
- package/harness/langchain/graph.ts +22 -10
- package/harness/langchain/status-line.test.ts +92 -0
- package/harness/langchain/status-line.ts +255 -0
- package/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import assert from "node:assert";
|
|
4
|
+
import { isAutonomyRequest } from "./agents.js";
|
|
5
|
+
|
|
6
|
+
describe("isAutonomyRequest", () => {
|
|
7
|
+
const shouldMatch = [
|
|
8
|
+
"You are free to write code until finish without my permission",
|
|
9
|
+
"you are free to write code until finish without permission",
|
|
10
|
+
"free to write code without permission",
|
|
11
|
+
"free to implement until done",
|
|
12
|
+
"free to build until complete",
|
|
13
|
+
"autonomous mode",
|
|
14
|
+
"autonomous",
|
|
15
|
+
"self-directed coding",
|
|
16
|
+
"no need for human approval",
|
|
17
|
+
"no need for confirmation",
|
|
18
|
+
"proceed without my approval",
|
|
19
|
+
"proceed without human",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const shouldNotMatch = [
|
|
23
|
+
"please review the code",
|
|
24
|
+
"check the output",
|
|
25
|
+
"run tests and report",
|
|
26
|
+
"waiting for human input",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const text of shouldMatch) {
|
|
30
|
+
it(`matches: "${text.slice(0, 50)}"`, () => {
|
|
31
|
+
assert.ok(isAutonomyRequest(text), `Expected to match: ${text}`);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const text of shouldNotMatch) {
|
|
36
|
+
it(`does NOT match: "${text}"`, () => {
|
|
37
|
+
assert.ok(!isAutonomyRequest(text), `Expected NOT to match: ${text}`);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
});
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { createAgent, tool } from "langchain";
|
|
21
21
|
import { z } from "zod";
|
|
22
|
+
import { fileTools } from "./file-tools.js";
|
|
22
23
|
import {
|
|
23
24
|
createCoderModel,
|
|
24
25
|
createPlannerModel,
|
|
@@ -46,6 +47,40 @@ export const ReviewVerdictSchema = z.object({
|
|
|
46
47
|
|
|
47
48
|
export type ReviewVerdict = z.infer<typeof ReviewVerdictSchema>;
|
|
48
49
|
|
|
50
|
+
// ─── Autonomy Signal Detection ────────────────────────────────────────────
|
|
51
|
+
//
|
|
52
|
+
// When these phrases appear in the user request, the agents activate full
|
|
53
|
+
// autonomous mode: no waiting, no "Next Steps", no confirmation prompts.
|
|
54
|
+
// The directive is injected into every system prompt when a signal is detected.
|
|
55
|
+
//
|
|
56
|
+
// Human says: "You are free to write code until finish without my permission"
|
|
57
|
+
// → Agents interpret this as: autonomy unlocked, write freely
|
|
58
|
+
|
|
59
|
+
const AUTONOMY_SIGNALS = [
|
|
60
|
+
/free to (write|code|implement|build|create)/i,
|
|
61
|
+
/until (finish|complete|done)/i,
|
|
62
|
+
/without (my )?permission/i,
|
|
63
|
+
/\bautonomous(ly)?( mode)?\b/i,
|
|
64
|
+
/self[- ]?directed/i,
|
|
65
|
+
/no (need (to|for)|waiting for) (human|confirmation|approval|permission)/i,
|
|
66
|
+
/proceed without (me|human|my |approval)/i,
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
export function isAutonomyRequest(request: string): boolean {
|
|
70
|
+
return AUTONOMY_SIGNALS.some((re) => re.test(request));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Directive injected into every system prompt when autonomy signal is detected. */
|
|
74
|
+
export function autonomyDirective(): string {
|
|
75
|
+
return `\n\n[AUTONOMY MODE ACTIVE]
|
|
76
|
+
You are operating in full autonomous mode. The human has explicitly granted
|
|
77
|
+
permission to write code until finish without waiting for confirmation.
|
|
78
|
+
- Do NOT ask for permission to proceed
|
|
79
|
+
- Do NOT output "Next Steps" or waiting-for-human signals
|
|
80
|
+
- Do NOT wait for human review — the loop routes automatically
|
|
81
|
+
- Write the code and output the result; the loop handles the rest`;
|
|
82
|
+
}
|
|
83
|
+
|
|
49
84
|
// ─── Prompts ────────────────────────────────────────────────────────────────
|
|
50
85
|
//
|
|
51
86
|
// PROMPTS ARE WRITTEN FOR DAEMON AUDIENCE, NOT HUMAN READERS.
|
|
@@ -57,7 +92,6 @@ export type ReviewVerdict = z.infer<typeof ReviewVerdictSchema>;
|
|
|
57
92
|
// - If stuck, fail with a clear error, do not wait silently
|
|
58
93
|
// - Output is consumed by the loop, not a human reading for guidance
|
|
59
94
|
|
|
60
|
-
|
|
61
95
|
const PLANNER_PROMPT = `You are the Planning Agent (GPT) in an autonomous daemon loop.
|
|
62
96
|
|
|
63
97
|
Given a feature request, produce a concise implementation plan: goals, non-goals,
|
|
@@ -70,8 +104,19 @@ The loop will route automatically.`;
|
|
|
70
104
|
const CODER_PROMPT = `You are the Coding Agent (MiniMax) in an autonomous daemon loop.
|
|
71
105
|
|
|
72
106
|
You receive a plan (and possibly review comments from a previous iteration) and
|
|
73
|
-
|
|
74
|
-
|
|
107
|
+
edit the actual repository files using the available tools. Address every review
|
|
108
|
+
comment explicitly.
|
|
109
|
+
|
|
110
|
+
Tools available:
|
|
111
|
+
- read_file(path): Read a file's contents before editing
|
|
112
|
+
- write_file(path, content, append?): Write or append to a file
|
|
113
|
+
- list_directory(path): List files in a directory
|
|
114
|
+
|
|
115
|
+
Workflow:
|
|
116
|
+
1. Use read_file to inspect existing files
|
|
117
|
+
2. Use write_file to create or update files with real code
|
|
118
|
+
3. Use list_directory to explore the project structure
|
|
119
|
+
4. Write real files, not code blocks — the loop will review the actual edits
|
|
75
120
|
|
|
76
121
|
IMPORTANT:
|
|
77
122
|
- Do NOT output "Next Steps", "Ready for review", or any waiting-for-human signal
|
|
@@ -79,7 +124,6 @@ IMPORTANT:
|
|
|
79
124
|
- If you cannot proceed, fail with a clear error message
|
|
80
125
|
- Your output is consumed by the loop, not read by a human for guidance`;
|
|
81
126
|
|
|
82
|
-
|
|
83
127
|
const REVIEWER_PROMPT = `You are the Code Review Agent (GPT) in an autonomous daemon loop.
|
|
84
128
|
|
|
85
129
|
You review the coder's output against the plan. Be strict: request changes only
|
|
@@ -115,7 +159,7 @@ export function createPlannerAgent(opts: ModelOptions = {}) {
|
|
|
115
159
|
export function createCoderAgent(opts: ModelOptions = {}) {
|
|
116
160
|
return createAgent({
|
|
117
161
|
model: createCoderModel(opts),
|
|
118
|
-
tools:
|
|
162
|
+
tools: fileTools,
|
|
119
163
|
systemPrompt: CODER_PROMPT,
|
|
120
164
|
});
|
|
121
165
|
}
|
|
@@ -49,6 +49,7 @@ import { NotificationCenter } from "../../packages/notification/dist/notificatio
|
|
|
49
49
|
|
|
50
50
|
import { invokeWithSurgeRetry, type SurgePolicy } from "./surge.js";
|
|
51
51
|
import type { LoopWidget } from "./widget.js";
|
|
52
|
+
import { StatusLineManager, isPiLensAvailable } from "./status-line.js";
|
|
52
53
|
import { createLoopCheckpointer } from "./checkpointer.js";
|
|
53
54
|
|
|
54
55
|
import {
|
|
@@ -518,6 +519,117 @@ class BdTasksWatcher {
|
|
|
518
519
|
}
|
|
519
520
|
}
|
|
520
521
|
|
|
522
|
+
// ─── Cron Source ──────────────────────────────────────────────────────────────
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Watches a cron-tasks directory and fires tasks on a configurable schedule.
|
|
526
|
+
*
|
|
527
|
+
* Storage layout:
|
|
528
|
+
* <workspace>/.cron-tasks/
|
|
529
|
+
* ├── every-5m/
|
|
530
|
+
* │ ├── task-1.json {"request": "...", "enabled": true}
|
|
531
|
+
* │ └── task-2.json
|
|
532
|
+
* ├── hourly/
|
|
533
|
+
* │ └── task-3.json
|
|
534
|
+
* └── daily/
|
|
535
|
+
* └── task-4.json
|
|
536
|
+
*
|
|
537
|
+
* Schedule buckets: "every-5m", "every-10m", "every-30m", "hourly", "daily", "weekly"
|
|
538
|
+
* Drop a JSON file into the right bucket to schedule a task.
|
|
539
|
+
* Set "enabled": false to pause without deleting.
|
|
540
|
+
*/
|
|
541
|
+
class CronWatcher {
|
|
542
|
+
private readonly workspace: string;
|
|
543
|
+
private readonly scheduleMs: Record<string, number>;
|
|
544
|
+
private readonly seen: Map<string, Set<string>> = new Map();
|
|
545
|
+
private readonly timers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
|
546
|
+
private running = false;
|
|
547
|
+
|
|
548
|
+
constructor(workspace: string) {
|
|
549
|
+
this.workspace = workspace;
|
|
550
|
+
this.scheduleMs = {
|
|
551
|
+
"every-5m": 5 * 60_000,
|
|
552
|
+
"every-10m": 10 * 60_000,
|
|
553
|
+
"every-30m": 30 * 60_000,
|
|
554
|
+
hourly: 3_600_000,
|
|
555
|
+
daily: 86_400_000,
|
|
556
|
+
weekly: 7 * 86_400_000,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
start(onTask: (task: TriggeredTask) => void): void {
|
|
561
|
+
this.running = true;
|
|
562
|
+
const cronDir = `${this.workspace}/.cron-tasks`;
|
|
563
|
+
import("node:fs").then(({ mkdirSync, existsSync }) => {
|
|
564
|
+
if (!existsSync(cronDir)) mkdirSync(cronDir, { recursive: true });
|
|
565
|
+
});
|
|
566
|
+
// Fire once at start
|
|
567
|
+
this._tickAll(onTask);
|
|
568
|
+
// Schedule each bucket
|
|
569
|
+
for (const [schedule, ms] of Object.entries(this.scheduleMs)) {
|
|
570
|
+
this._scheduleNext(schedule, ms, onTask);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
stop(): void {
|
|
575
|
+
this.running = false;
|
|
576
|
+
for (const t of this.timers.values()) clearTimeout(t);
|
|
577
|
+
this.timers.clear();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private _scheduleNext(
|
|
581
|
+
schedule: string,
|
|
582
|
+
ms: number,
|
|
583
|
+
onTask: (task: TriggeredTask) => void,
|
|
584
|
+
): void {
|
|
585
|
+
const timer = setTimeout(async () => {
|
|
586
|
+
if (!this.running) return;
|
|
587
|
+
await this._tickSchedule(schedule, onTask);
|
|
588
|
+
this._scheduleNext(schedule, ms, onTask);
|
|
589
|
+
}, ms);
|
|
590
|
+
this.timers.set(schedule, timer);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private async _tickAll(onTask: (task: TriggeredTask) => void): Promise<void> {
|
|
594
|
+
for (const schedule of Object.keys(this.scheduleMs)) {
|
|
595
|
+
await this._tickSchedule(schedule, onTask);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private async _tickSchedule(
|
|
600
|
+
schedule: string,
|
|
601
|
+
onTask: (task: TriggeredTask) => void,
|
|
602
|
+
): Promise<void> {
|
|
603
|
+
if (!this.running) return;
|
|
604
|
+
const { readdirSync, readFileSync, existsSync } = await import("node:fs");
|
|
605
|
+
const cronDir = `${this.workspace}/.cron-tasks/${schedule}`;
|
|
606
|
+
if (!existsSync(cronDir)) return;
|
|
607
|
+
|
|
608
|
+
let seen = this.seen.get(schedule);
|
|
609
|
+
if (!seen) { seen = new Set(); this.seen.set(schedule, seen); }
|
|
610
|
+
|
|
611
|
+
let files: string[];
|
|
612
|
+
try { files = readdirSync(cronDir).filter((f) => f.endsWith(".json")); }
|
|
613
|
+
catch { return; }
|
|
614
|
+
|
|
615
|
+
for (const file of files) {
|
|
616
|
+
if (seen.has(file)) continue;
|
|
617
|
+
seen.add(file);
|
|
618
|
+
let taskData: { request?: string; enabled?: boolean };
|
|
619
|
+
try { taskData = JSON.parse(readFileSync(`${cronDir}/${file}`, "utf8")); }
|
|
620
|
+
catch { continue; }
|
|
621
|
+
if (taskData.enabled === false) continue;
|
|
622
|
+
if (!taskData.request) continue;
|
|
623
|
+
onTask({
|
|
624
|
+
taskId: `cron:${schedule}:${file.replace(".json", "")}`,
|
|
625
|
+
request: taskData.request,
|
|
626
|
+
source: "cron",
|
|
627
|
+
triggeredAt: new Date().toISOString(),
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
521
633
|
// ─── Tunnel Health Monitor ───────────────────────────────────────────────────
|
|
522
634
|
|
|
523
635
|
/**
|
|
@@ -674,7 +786,9 @@ export class LoopDaemon {
|
|
|
674
786
|
private readonly inboxWatcher: InboxWatcher;
|
|
675
787
|
private readonly busWatcher: BusWatcher;
|
|
676
788
|
private readonly bdWatcher: BdTasksWatcher;
|
|
789
|
+
private readonly cronWatcher: CronWatcher | null;
|
|
677
790
|
private readonly tunnelMonitor: TunnelMonitor | null;
|
|
791
|
+
private readonly statusLine: StatusLineManager | null;
|
|
678
792
|
private readonly widget: LoopWidget | null;
|
|
679
793
|
|
|
680
794
|
/** Currently running tasks (taskId → RunningTask) */
|
|
@@ -730,6 +844,12 @@ export class LoopDaemon {
|
|
|
730
844
|
this.widget = this.config.widget ?? null;
|
|
731
845
|
this.busWatcher = new BusWatcher(this.bus, this.config.pollMs);
|
|
732
846
|
this.bdWatcher = new BdTasksWatcher(this.config.pollMs);
|
|
847
|
+
this.cronWatcher = this.config.sources.includes("cron")
|
|
848
|
+
? new CronWatcher(this.bus.getWorkspace())
|
|
849
|
+
: null;
|
|
850
|
+
this.statusLine = isPiLensAvailable()
|
|
851
|
+
? null
|
|
852
|
+
: new StatusLineManager(this.bus.getWorkspace());
|
|
733
853
|
|
|
734
854
|
// Tunnel monitor — only active when a tunnel script is configured
|
|
735
855
|
if (this.config.tunnelCommand) {
|
|
@@ -772,6 +892,7 @@ export class LoopDaemon {
|
|
|
772
892
|
}
|
|
773
893
|
|
|
774
894
|
this.tunnelMonitor?.start();
|
|
895
|
+
this.statusLine?.start();
|
|
775
896
|
|
|
776
897
|
console.log("[daemon] Started.");
|
|
777
898
|
}
|
|
@@ -784,6 +905,8 @@ export class LoopDaemon {
|
|
|
784
905
|
this.inboxWatcher.stop();
|
|
785
906
|
this.busWatcher.stop();
|
|
786
907
|
this.bdWatcher.stop();
|
|
908
|
+
this.cronWatcher?.stop();
|
|
909
|
+
this.statusLine?.stop();
|
|
787
910
|
this.tunnelMonitor?.stop();
|
|
788
911
|
|
|
789
912
|
// Release all held leases
|
|
@@ -888,7 +1011,7 @@ export class LoopDaemon {
|
|
|
888
1011
|
maxIterations: this.config.maxIterations,
|
|
889
1012
|
blackboardDir: this.bus.getWorkspace(),
|
|
890
1013
|
})
|
|
891
|
-
|
|
1014
|
+
: await buildRealLoopDeps({
|
|
892
1015
|
maxIterations: this.config.maxIterations,
|
|
893
1016
|
blackboardDir: this.bus.getWorkspace(),
|
|
894
1017
|
}));
|
|
@@ -977,6 +1100,8 @@ export class LoopDaemon {
|
|
|
977
1100
|
: "max_iterations",
|
|
978
1101
|
);
|
|
979
1102
|
}
|
|
1103
|
+
const loopDone = verdict === "approved" ? "✅ finished" : verdict === "blocked" ? "⛔ blocked" : "🔄 max_iter";
|
|
1104
|
+
this.statusLine?.updateLoopStatus(loopDone);
|
|
980
1105
|
|
|
981
1106
|
// ── Gate check after blocked verdict ────────────────────────────────
|
|
982
1107
|
if (
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File system tools for the coder agent — read and write files on disk.
|
|
3
|
+
*
|
|
4
|
+
* These tools let the MiniMax coder edit the actual repository instead of
|
|
5
|
+
* just outputting code blocks. Tools are plain Node.js fs wrappers; the
|
|
6
|
+
* agent receives structured JSON results it can act on.
|
|
7
|
+
*
|
|
8
|
+
* Wiki: wiki/multi-agent-langchain.md §file-tools
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { tool } from "langchain";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
|
|
14
|
+
import { join, isAbsolute, resolve } from "node:path";
|
|
15
|
+
|
|
16
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** Resolve a path relative to repoRoot or as absolute. */
|
|
19
|
+
function resolvePath(path: string, repoRoot: string): string {
|
|
20
|
+
if (isAbsolute(path)) return path;
|
|
21
|
+
return resolve(repoRoot, path);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Tools ──────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read the contents of a file.
|
|
28
|
+
*
|
|
29
|
+
* Use this before editing to see the current code.
|
|
30
|
+
* Returns the full file contents as a string.
|
|
31
|
+
*/
|
|
32
|
+
export const readFile = tool(
|
|
33
|
+
async ({
|
|
34
|
+
path,
|
|
35
|
+
encoding = "utf-8",
|
|
36
|
+
}: {
|
|
37
|
+
/** Absolute path or path relative to the repository root */
|
|
38
|
+
path: string;
|
|
39
|
+
/** File encoding (default: utf-8) */
|
|
40
|
+
encoding?: string;
|
|
41
|
+
}) => {
|
|
42
|
+
try {
|
|
43
|
+
const resolved = resolvePath(path, process.cwd());
|
|
44
|
+
if (!existsSync(resolved)) {
|
|
45
|
+
return JSON.stringify({ error: `File not found: ${resolved}` });
|
|
46
|
+
}
|
|
47
|
+
const stats = statSync(resolved);
|
|
48
|
+
if (stats.isDirectory()) {
|
|
49
|
+
return JSON.stringify({ error: `Path is a directory, not a file: ${resolved}` });
|
|
50
|
+
}
|
|
51
|
+
const content = readFileSync(resolved, encoding as BufferEncoding);
|
|
52
|
+
return JSON.stringify({
|
|
53
|
+
path: resolved,
|
|
54
|
+
size: stats.size,
|
|
55
|
+
lines: content.split("\n").length,
|
|
56
|
+
content,
|
|
57
|
+
});
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return JSON.stringify({
|
|
60
|
+
error: `Failed to read file: ${path}`,
|
|
61
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "read_file",
|
|
67
|
+
description:
|
|
68
|
+
"Read the full contents of a file from disk. " +
|
|
69
|
+
"Returns the file content as a string. " +
|
|
70
|
+
"Use this before editing a file so you know its current contents. " +
|
|
71
|
+
"Returns JSON with path, size, lines, and content fields.",
|
|
72
|
+
schema: z.object({
|
|
73
|
+
path: z.string().describe("Absolute path or path relative to the repository root"),
|
|
74
|
+
encoding: z
|
|
75
|
+
.enum(["utf-8", "utf-16", "ascii", "base64"])
|
|
76
|
+
.optional()
|
|
77
|
+
.describe("File encoding (default: utf-8)"),
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Write or overwrite a file on disk.
|
|
84
|
+
*
|
|
85
|
+
* Creates parent directories if they don't exist.
|
|
86
|
+
* The caller (an agent) is responsible for ensuring the content is correct.
|
|
87
|
+
*/
|
|
88
|
+
export const writeFile = tool(
|
|
89
|
+
async ({
|
|
90
|
+
path,
|
|
91
|
+
content,
|
|
92
|
+
append = false,
|
|
93
|
+
}: {
|
|
94
|
+
/** Absolute path or path relative to the repository root */
|
|
95
|
+
path: string;
|
|
96
|
+
/** The content to write. Use \\n for newlines. */
|
|
97
|
+
content: string;
|
|
98
|
+
/** If true, append to the file instead of overwriting (default: false) */
|
|
99
|
+
append?: boolean;
|
|
100
|
+
}) => {
|
|
101
|
+
try {
|
|
102
|
+
const resolved = resolvePath(path, process.cwd());
|
|
103
|
+
const mode = append ? "a" : "w";
|
|
104
|
+
// Normalize content: convert literal \n sequences to actual newlines
|
|
105
|
+
const normalized = content.replace(/\\n/g, "\n");
|
|
106
|
+
writeFileSync(resolved, normalized, { encoding: "utf-8", flag: mode });
|
|
107
|
+
const stats = statSync(resolved);
|
|
108
|
+
return JSON.stringify({
|
|
109
|
+
ok: true,
|
|
110
|
+
path: resolved,
|
|
111
|
+
size: stats.size,
|
|
112
|
+
action: append ? "appended" : "written",
|
|
113
|
+
});
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return JSON.stringify({
|
|
116
|
+
error: `Failed to write file: ${path}`,
|
|
117
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "write_file",
|
|
123
|
+
description:
|
|
124
|
+
"Write or overwrite a file on disk. Creates parent directories if needed. " +
|
|
125
|
+
"Use literal \\n in the content string for newlines. " +
|
|
126
|
+
"Set append=true to add to the end of an existing file. " +
|
|
127
|
+
"Returns JSON with ok, path, size, and action fields.",
|
|
128
|
+
schema: z.object({
|
|
129
|
+
path: z.string().describe("Absolute path or path relative to the repository root"),
|
|
130
|
+
content: z.string().describe("File content. Use \\n for newlines."),
|
|
131
|
+
append: z
|
|
132
|
+
.boolean()
|
|
133
|
+
.optional()
|
|
134
|
+
.describe("Append to existing file instead of overwriting (default: false)"),
|
|
135
|
+
}),
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* List files in a directory (non-recursive).
|
|
141
|
+
*
|
|
142
|
+
* Useful for discovering the project structure.
|
|
143
|
+
*/
|
|
144
|
+
export const listDir = tool(
|
|
145
|
+
async ({ path }: { path: string }) => {
|
|
146
|
+
try {
|
|
147
|
+
const { readdirSync } = await import("node:fs");
|
|
148
|
+
const resolved = resolvePath(path, process.cwd());
|
|
149
|
+
if (!existsSync(resolved)) {
|
|
150
|
+
return JSON.stringify({ error: `Directory not found: ${resolved}` });
|
|
151
|
+
}
|
|
152
|
+
const entries = readdirSync(resolved, { withFileTypes: true });
|
|
153
|
+
const files = entries.map((e) => ({
|
|
154
|
+
name: e.name,
|
|
155
|
+
type: e.isDirectory() ? "dir" : "file",
|
|
156
|
+
path: join(resolved, e.name),
|
|
157
|
+
}));
|
|
158
|
+
return JSON.stringify({ path: resolved, entries: files });
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return JSON.stringify({
|
|
161
|
+
error: `Failed to list directory: ${path}`,
|
|
162
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "list_directory",
|
|
168
|
+
description:
|
|
169
|
+
"List the contents of a directory (files and subdirectories, non-recursive). " +
|
|
170
|
+
"Returns JSON with path and entries array (each entry has name, type, path).",
|
|
171
|
+
schema: z.object({
|
|
172
|
+
path: z.string().describe("Absolute path or path relative to the repository root"),
|
|
173
|
+
}),
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
/** All file system tools, ready to pass to `createCoderAgent`. */
|
|
178
|
+
export const fileTools = [readFile, writeFile, listDir];
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
StateGraph,
|
|
21
21
|
} from "@langchain/langgraph";
|
|
22
22
|
import {
|
|
23
|
+
autonomyDirective,
|
|
24
|
+
isAutonomyRequest,
|
|
23
25
|
lastMessage,
|
|
24
26
|
type ReviewVerdict,
|
|
25
27
|
ReviewVerdictSchema,
|
|
@@ -226,6 +228,10 @@ export async function buildRealLoopDeps(
|
|
|
226
228
|
blackboard.init();
|
|
227
229
|
}
|
|
228
230
|
|
|
231
|
+
// Autonomy mode: computed inside plan(), stored for write()/review()
|
|
232
|
+
const _autonomous = false;
|
|
233
|
+
const _directive = "";
|
|
234
|
+
|
|
229
235
|
/** Inject scoreboard markdown into a user message. */
|
|
230
236
|
const withScoreboard = (msg: string) => {
|
|
231
237
|
const scoreboard = blackboard.toMarkdown();
|
|
@@ -259,8 +265,9 @@ export async function buildRealLoopDeps(
|
|
|
259
265
|
}
|
|
260
266
|
if (state.review?.comments && state.review.comments.length > 0) {
|
|
261
267
|
blackboard.setChangesRequested(
|
|
262
|
-
state.review.comments.map(
|
|
263
|
-
|
|
268
|
+
state.review.comments.map(
|
|
269
|
+
(c) =>
|
|
270
|
+
`[${c.severity ?? "minor"}] ${c.file ? `${c.file}: ` : ""}${c.comment}`,
|
|
264
271
|
),
|
|
265
272
|
);
|
|
266
273
|
}
|
|
@@ -276,7 +283,9 @@ export async function buildRealLoopDeps(
|
|
|
276
283
|
plan: async (request) =>
|
|
277
284
|
lastMessage(
|
|
278
285
|
await planner.invoke({
|
|
279
|
-
messages: [
|
|
286
|
+
messages: [
|
|
287
|
+
{ role: "user", content: _directive + withScoreboard(request) },
|
|
288
|
+
],
|
|
280
289
|
}),
|
|
281
290
|
),
|
|
282
291
|
write: async (plan, review) => {
|
|
@@ -287,7 +296,9 @@ export async function buildRealLoopDeps(
|
|
|
287
296
|
: `## Plan\n${plan}`;
|
|
288
297
|
return lastMessage(
|
|
289
298
|
await coder.invoke({
|
|
290
|
-
messages: [
|
|
299
|
+
messages: [
|
|
300
|
+
{ role: "user", content: _directive + withScoreboard(userMsg) },
|
|
301
|
+
],
|
|
291
302
|
}),
|
|
292
303
|
);
|
|
293
304
|
},
|
|
@@ -296,9 +307,9 @@ export async function buildRealLoopDeps(
|
|
|
296
307
|
messages: [
|
|
297
308
|
{
|
|
298
309
|
role: "user",
|
|
299
|
-
content:
|
|
300
|
-
|
|
301
|
-
|
|
310
|
+
content:
|
|
311
|
+
_directive +
|
|
312
|
+
withScoreboard(`## Plan\n${plan}\n\n## Code to review\n${code}`),
|
|
302
313
|
},
|
|
303
314
|
],
|
|
304
315
|
});
|
|
@@ -367,8 +378,9 @@ export function buildDryRunDeps(
|
|
|
367
378
|
if (verdict) blackboard.setVerdict(verdict, state.review?.summary);
|
|
368
379
|
if (state.review?.comments && state.review.comments.length > 0) {
|
|
369
380
|
blackboard.setChangesRequested(
|
|
370
|
-
state.review.comments.map(
|
|
371
|
-
|
|
381
|
+
state.review.comments.map(
|
|
382
|
+
(c) =>
|
|
383
|
+
`[${c.severity ?? "minor"}] ${c.file ? `${c.file}: ` : ""}${c.comment}`,
|
|
372
384
|
),
|
|
373
385
|
);
|
|
374
386
|
}
|
|
@@ -378,7 +390,7 @@ export function buildDryRunDeps(
|
|
|
378
390
|
}
|
|
379
391
|
};
|
|
380
392
|
|
|
381
|
-
|
|
393
|
+
return {
|
|
382
394
|
maxIterations: options.maxIterations ?? 3,
|
|
383
395
|
onStep,
|
|
384
396
|
plan: async (request) =>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StatusLineManager tests — T16
|
|
3
|
+
*
|
|
4
|
+
* Tests the pi-lens detection, quota formatting, and status file writing
|
|
5
|
+
* when pi-lens is not available.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
9
|
+
import assert from "node:assert";
|
|
10
|
+
import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { StatusLineManager, isPiLensAvailable, formatQuotaLine } from "./status-line.js";
|
|
13
|
+
|
|
14
|
+
describe("isPiLensAvailable", () => {
|
|
15
|
+
it("returns false when no PI_LENS env vars are set", () => {
|
|
16
|
+
assert.ok(!isPiLensAvailable());
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("formatQuotaLine", () => {
|
|
21
|
+
it("returns 'quota unknown' when no data", () => {
|
|
22
|
+
assert.ok(formatQuotaLine({}).startsWith("MiniMax: quota unknown"));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("shows 5h usage when available", () => {
|
|
26
|
+
const line = formatQuotaLine({ fiveHourPercent: 89 });
|
|
27
|
+
assert.ok(line.includes("5h: 89% left"), line);
|
|
28
|
+
assert.ok(line.includes("MiniMax:"), line);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("shows both 5h and weekly when both available", () => {
|
|
32
|
+
const line = formatQuotaLine({ fiveHourPercent: 89, weeklyPercent: 75 });
|
|
33
|
+
assert.ok(line.includes("5h: 89% left"), line);
|
|
34
|
+
assert.ok(line.includes("week: 75% left"), line);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("shows 0% left when at 100%", () => {
|
|
38
|
+
const line = formatQuotaLine({ fiveHourPercent: 100 });
|
|
39
|
+
assert.ok(line.includes("0% left"), line);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("shows ? for invalid negative percentage", () => {
|
|
43
|
+
// fmtPercent returns ? for negative values
|
|
44
|
+
const line = formatQuotaLine({ fiveHourPercent: -1 });
|
|
45
|
+
assert.ok(line.includes("5h: ?"), line);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("shows reset countdown when resetAt is provided", () => {
|
|
49
|
+
const future = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(); // 2h from now
|
|
50
|
+
const line = formatQuotaLine({ fiveHourPercent: 89, fiveHourResetAt: future });
|
|
51
|
+
assert.ok(line.includes("resets in"), line);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("StatusLineManager", () => {
|
|
56
|
+
const tmpDir = join(process.env.TMPDIR ?? "/tmp", `harness-status-test-${Date.now()}`);
|
|
57
|
+
let manager: StatusLineManager;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
61
|
+
manager = new StatusLineManager(tmpDir);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
manager.stop();
|
|
66
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("getLine returns combined loop + quota status", () => {
|
|
70
|
+
manager.updateLoopStatus("🧭 planning");
|
|
71
|
+
const line = manager.getLine();
|
|
72
|
+
assert.ok(line.includes("planning"), line);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("getLine returns quota-only when no loop status", () => {
|
|
76
|
+
const line = manager.getLine();
|
|
77
|
+
assert.ok(line.includes("MiniMax"), line);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("writes .harness-status file when pi-lens is not available", () => {
|
|
81
|
+
manager.updateLoopStatus("✍️ coding");
|
|
82
|
+
const statusFile = join(tmpDir, ".harness-status");
|
|
83
|
+
assert.ok(existsSync(statusFile), ".harness-status should exist");
|
|
84
|
+
const content = readFileSync(statusFile, "utf8");
|
|
85
|
+
assert.ok(content.includes("coding"), content);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("start() and stop() do not throw", () => {
|
|
89
|
+
assert.doesNotThrow(() => manager.start());
|
|
90
|
+
assert.doesNotThrow(() => manager.stop());
|
|
91
|
+
});
|
|
92
|
+
});
|