omp-auto-loop 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/index.ts +116 -0
- package/package.json +17 -0
- package/state.ts +50 -0
- package/tool.ts +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# 🔄 Agent Loop
|
|
2
|
+
|
|
3
|
+
General-purpose loop extension for pi. Repeats agent turns automatically in three modes: until a goal is met, for a fixed number of passes, or through a sequence of named pipeline stages.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:pi-agent-loop
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
Registers a `/loop` command and a `loop_control` tool. On each iteration, the agent does its work then calls `loop_control` to either advance to the next iteration (`next`) or declare the goal complete (`done`). The loop context is injected into the system prompt automatically so the agent always knows where it is.
|
|
14
|
+
|
|
15
|
+
## Commands
|
|
16
|
+
|
|
17
|
+
| Command | Description |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `/loop goal <description>` | Repeat until the LLM declares the goal met (open-ended) |
|
|
20
|
+
| `/loop passes <N> <task>` | Run exactly N passes |
|
|
21
|
+
| `/loop pipeline <s1\|s2\|s3> <goal>` | Run named stages sequentially, stop after the last |
|
|
22
|
+
| `/loop-stop` | Stop the active loop immediately |
|
|
23
|
+
|
|
24
|
+
## Shortcut
|
|
25
|
+
|
|
26
|
+
| Shortcut | Description |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `Ctrl+Shift+X` | Emergency abort — stops the loop and cancels the current turn |
|
|
29
|
+
|
|
30
|
+
## Tool: `loop_control`
|
|
31
|
+
|
|
32
|
+
The LLM calls this tool to signal progress. It is only active during a loop.
|
|
33
|
+
|
|
34
|
+
| Parameter | Type | Description |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `status` | `"next" \| "done"` | Advance to next iteration or declare completion |
|
|
37
|
+
| `summary` | `string` | Brief summary of what was accomplished this iteration |
|
|
38
|
+
| `reason` | `string?` | Why the goal is met (used with `"done"`) |
|
|
39
|
+
|
|
40
|
+
## Examples
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
/loop goal Refactor all test files to use the new assertion API
|
|
44
|
+
/loop passes 3 Review and improve the README
|
|
45
|
+
/loop pipeline analyze|implement|test Write and test the new auth module
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## TUI widget
|
|
49
|
+
|
|
50
|
+
While a loop is active, a status bar entry and widget show the current mode, goal, and iteration count.
|
package/index.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
2
|
+
import { Key } from "@oh-my-pi/pi-tui";
|
|
3
|
+
import { buildPrompt, emptyState, getSystemPromptAddition, type LoopState, updateWidget } from "./state.js";
|
|
4
|
+
import { getLoopControlToolDefinition, handleLoopControlTool, renderLoopControlCall, renderLoopControlResult } from "./tool.js";
|
|
5
|
+
|
|
6
|
+
export default function (pi: ExtensionAPI) {
|
|
7
|
+
let state = emptyState();
|
|
8
|
+
|
|
9
|
+
const reconstruct = (ctx: ExtensionContext) => {
|
|
10
|
+
state = emptyState();
|
|
11
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
12
|
+
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "loop_control") {
|
|
13
|
+
const d = entry.message.details as LoopState | undefined;
|
|
14
|
+
if (d) state = { ...d };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
pi.on("session_start", async (_e, ctx) => reconstruct(ctx));
|
|
20
|
+
pi.on("session_switch", async (_e, ctx) => reconstruct(ctx));
|
|
21
|
+
pi.on("session_fork", async (_e, ctx) => reconstruct(ctx));
|
|
22
|
+
pi.on("session_tree", async (_e, ctx) => reconstruct(ctx));
|
|
23
|
+
|
|
24
|
+
pi.on("input", async (event, ctx) => {
|
|
25
|
+
const text = event.text.trim();
|
|
26
|
+
// Handle commands natively
|
|
27
|
+
if (text.startsWith("/")) {
|
|
28
|
+
if (text.startsWith("/once ") || text === "/once") {
|
|
29
|
+
return { text: text.slice(5).trim() };
|
|
30
|
+
}
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Normal message: start loop
|
|
35
|
+
state = { active: true, currentStep: 0, goal: text, done: false, reasonDone: "" };
|
|
36
|
+
updateWidget(state, ctx);
|
|
37
|
+
|
|
38
|
+
// Delay the prompt steer delivery to avoid conflicting with the pending user message
|
|
39
|
+
setTimeout(() => {
|
|
40
|
+
pi.sendMessage({ customType: "loop-iteration", content: buildPrompt(state), display: false }, { triggerTurn: false, deliverAs: "steer" });
|
|
41
|
+
}, 50);
|
|
42
|
+
|
|
43
|
+
return {}; // let original text through
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
pi.on("before_agent_start", async (event) => {
|
|
47
|
+
if (!state.active) return;
|
|
48
|
+
return { systemPrompt: event.systemPrompt + getSystemPromptAddition(state) };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
pi.on("turn_end", async (_e, ctx) => {
|
|
52
|
+
if (!state.active) return;
|
|
53
|
+
|
|
54
|
+
if (state.confirmingDone) {
|
|
55
|
+
state.active = false;
|
|
56
|
+
state.done = true;
|
|
57
|
+
state.reasonDone = "Confirmed complete by skipping loop_control";
|
|
58
|
+
state.confirmingDone = false;
|
|
59
|
+
updateWidget(state, ctx);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (state.nextScheduled) {
|
|
64
|
+
state.nextScheduled = false;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Fallback: LLM stopped without calling loop_control or confirming done
|
|
69
|
+
state.currentStep++;
|
|
70
|
+
updateWidget(state, ctx);
|
|
71
|
+
setTimeout(() => {
|
|
72
|
+
pi.sendMessage({
|
|
73
|
+
customType: "loop-fallback",
|
|
74
|
+
content: "You stopped without calling `loop_control`. If the task is incomplete, continue working. If done, call `loop_control` with status 'done'.",
|
|
75
|
+
display: false
|
|
76
|
+
}, { triggerTurn: true, deliverAs: "steer" });
|
|
77
|
+
}, 100);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
pi.registerTool({
|
|
81
|
+
...getLoopControlToolDefinition(),
|
|
82
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
83
|
+
const result = handleLoopControlTool({ params, state, pi, ctx });
|
|
84
|
+
state = result.newState;
|
|
85
|
+
updateWidget(state, ctx);
|
|
86
|
+
return { content: result.content, details: result.details };
|
|
87
|
+
},
|
|
88
|
+
renderCall: renderLoopControlCall as any,
|
|
89
|
+
renderResult: renderLoopControlResult as any,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const stopLoop = (ctx: ExtensionContext, reason: string) => {
|
|
93
|
+
if (!state.active) {
|
|
94
|
+
ctx.ui.notify("No active loop", "info");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
state.active = false;
|
|
98
|
+
state.done = true;
|
|
99
|
+
state.reasonDone = reason;
|
|
100
|
+
updateWidget(state, ctx);
|
|
101
|
+
ctx.ui.notify(`Loop stopped after ${state.currentStep + 1} iteration(s)`, "warning");
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
pi.registerCommand("loop-stop", {
|
|
105
|
+
description: "Stop the active loop",
|
|
106
|
+
handler: async (_args, ctx) => stopLoop(ctx, "Stopped by user"),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
pi.registerShortcut(Key.ctrlShift("x"), {
|
|
110
|
+
description: "Stop the active loop",
|
|
111
|
+
handler: async (ctx) => {
|
|
112
|
+
stopLoop(ctx, "Stopped by shortcut");
|
|
113
|
+
ctx.abort();
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "omp-auto-loop",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "General-purpose agent loop extension for pi. Supports goal loops (repeat until done), fixed-pass loops, and multi-stage pipelines via /loop goal, /loop passes, and /loop pipeline commands.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package"
|
|
7
|
+
],
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"pi": {
|
|
10
|
+
"extensions": [
|
|
11
|
+
"."
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"@oh-my-pi/pi-coding-agent": "*"
|
|
16
|
+
}
|
|
17
|
+
}
|
package/state.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export interface LoopState {
|
|
4
|
+
active: boolean;
|
|
5
|
+
currentStep: number;
|
|
6
|
+
goal: string;
|
|
7
|
+
done: boolean;
|
|
8
|
+
reasonDone: string;
|
|
9
|
+
confirmingDone?: boolean;
|
|
10
|
+
nextScheduled?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function emptyState(): LoopState {
|
|
14
|
+
return { active: false, currentStep: 0, goal: "", done: false, reasonDone: "", confirmingDone: false, nextScheduled: false };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildPrompt(state: LoopState): string {
|
|
18
|
+
return [
|
|
19
|
+
`## Loop — Iteration ${state.currentStep + 1}`,
|
|
20
|
+
`Goal: ${state.goal}`,
|
|
21
|
+
`Work toward the goal. When the goal is fully met, call loop_control with status "done" and explain why.`,
|
|
22
|
+
`If more work is needed, call loop_control with status "next" describing what's left.`,
|
|
23
|
+
].join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function updateWidget(state: LoopState, ctx: ExtensionContext) {
|
|
27
|
+
if (!state.active) {
|
|
28
|
+
ctx.ui.setStatus("loop", undefined);
|
|
29
|
+
ctx.ui.setWidget("loop", undefined);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const label = `iteration ${state.currentStep + 1}`;
|
|
33
|
+
ctx.ui.setStatus("loop", `🔄 ${label}`);
|
|
34
|
+
ctx.ui.setWidget("loop", [
|
|
35
|
+
`┌─ Loop ──────────`,
|
|
36
|
+
`│ ${label}`,
|
|
37
|
+
`└─ Ctrl+Shift+X to stop ─`,
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getSystemPromptAddition(state: LoopState): string {
|
|
42
|
+
return [
|
|
43
|
+
"",
|
|
44
|
+
"## Active Loop",
|
|
45
|
+
`Step: ${state.currentStep + 1}`,
|
|
46
|
+
`Goal: ${state.goal}`,
|
|
47
|
+
"You MUST call `loop_control` when you finish your work for this iteration.",
|
|
48
|
+
'Use status "next" to advance or "done" when the goal is fully met.',
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
package/tool.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { StringEnum } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
3
|
+
import { Text } from "@oh-my-pi/pi-tui";
|
|
4
|
+
import { Type } from "@sinclair/typebox";
|
|
5
|
+
import { buildPrompt, type LoopState } from "./state.js";
|
|
6
|
+
|
|
7
|
+
export function handleLoopControlTool(opts: {
|
|
8
|
+
params: { status: "next" | "done"; summary: string; reason?: string };
|
|
9
|
+
state: LoopState;
|
|
10
|
+
pi: ExtensionAPI;
|
|
11
|
+
ctx: ExtensionContext;
|
|
12
|
+
}): { content: any[]; details?: LoopState; newState: LoopState } {
|
|
13
|
+
const { params, state, pi } = opts;
|
|
14
|
+
if (!state.active) {
|
|
15
|
+
return { content: [{ type: "text", text: "No active loop." }], newState: state };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (params.status === "done") {
|
|
19
|
+
if (!state.confirmingDone) {
|
|
20
|
+
const newState = { ...state, confirmingDone: true };
|
|
21
|
+
return {
|
|
22
|
+
content: [{ type: "text", text: "Please confirm that the work is completely done. If there are still pending tasks, call loop_control with status 'next'. If you are truly done, just finish your response without calling loop_control again." }],
|
|
23
|
+
details: { ...newState },
|
|
24
|
+
newState,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const newState = { ...state, done: true, reasonDone: params.reason ?? params.summary, active: false, confirmingDone: false };
|
|
28
|
+
return {
|
|
29
|
+
content: [{ type: "text", text: `✓ Loop complete after ${state.currentStep + 1} iteration(s). Reason: ${newState.reasonDone}` }],
|
|
30
|
+
details: { ...newState },
|
|
31
|
+
newState,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const newState = { ...state, currentStep: state.currentStep + 1, confirmingDone: false, nextScheduled: true };
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
pi.sendMessage({ customType: "loop-iteration", content: buildPrompt(newState), display: false }, { triggerTurn: true, deliverAs: "steer" });
|
|
38
|
+
}, 100);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: "text", text: `→ Advancing to step ${newState.currentStep + 1}. Summary: ${params.summary}` }],
|
|
42
|
+
details: { ...newState },
|
|
43
|
+
newState,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getLoopControlToolDefinition() {
|
|
48
|
+
return {
|
|
49
|
+
name: "loop_control",
|
|
50
|
+
label: "Loop Control",
|
|
51
|
+
description: "Signal loop progress. Call this when you finish a loop iteration. status 'next' to advance, 'done' to finish.",
|
|
52
|
+
parameters: Type.Object({
|
|
53
|
+
status: StringEnum(["next", "done"] as const),
|
|
54
|
+
summary: Type.String({ description: "Brief summary of what was accomplished this iteration" }),
|
|
55
|
+
reason: Type.Optional(Type.String({ description: "Why the goal is met (for 'done')" })),
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function renderLoopControlCall(args: { status: string }, theme: any) {
|
|
61
|
+
return new Text(theme.fg("toolTitle", theme.bold("loop_control ")) + theme.fg(args.status === "done" ? "success" : "accent", args.status), 0, 0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderLoopControlResult(result: { details?: LoopState }, _opts: unknown, theme: any) {
|
|
65
|
+
const d = result.details;
|
|
66
|
+
if (!d) return new Text("", 0, 0);
|
|
67
|
+
return new Text(theme.fg(d.done ? "success" : "accent", `${d.done ? "✓" : "→"} step ${d.currentStep + 1}`), 0, 0);
|
|
68
|
+
}
|