@hank-warren/pi-loop 0.8.0 → 1.0.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.
- package/CHANGELOG.md +32 -0
- package/README.md +52 -32
- package/package.json +4 -1
- package/skills/pi-loop/SKILL.md +73 -45
- package/src/command.ts +33 -187
- package/src/complete-tool.ts +1 -1
- package/src/fresh-launch.ts +128 -0
- package/src/index.ts +70 -79
- package/src/ledger.ts +2 -2
- package/src/loop-action-menus.ts +130 -0
- package/src/loop-env.ts +50 -0
- package/src/loop-launch-menu.ts +158 -0
- package/src/loop-manager-menu.ts +191 -0
- package/src/loop.ts +156 -29
- package/src/manager.ts +213 -147
- package/src/messages.ts +1 -1
- package/src/objective.ts +38 -1
- package/src/planning.ts +78 -24
- package/src/presentation.ts +50 -0
- package/src/progress-tool.ts +1 -1
- package/src/propose-tool.ts +42 -15
- package/src/settings.ts +27 -22
- package/src/state.ts +43 -0
- package/src/wait-tool.ts +1 -1
- package/src/widget.ts +7 -3
- package/src/inline-command.ts +0 -159
- package/src/inline-invocation.ts +0 -109
- package/src/start-tool.ts +0 -199
package/src/inline-command.ts
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Inline slash-command detection.
|
|
3
|
-
*
|
|
4
|
-
* Pi dispatches extension commands only when a message *starts* with the
|
|
5
|
-
* command token; a command mentioned mid-prompt arrives as plain text. This
|
|
6
|
-
* helper finds inline occurrences of a `/cmd` token (and `cmd:` prefix lines)
|
|
7
|
-
* so the caller can react — here, by appending a reminder that tells the model
|
|
8
|
-
* to invoke the corresponding tool. It never rewrites or re-sends the user's
|
|
9
|
-
* message.
|
|
10
|
-
*
|
|
11
|
-
* Ported from @hank-warren/pi-goal, which proved the mechanism before pi-loop
|
|
12
|
-
* absorbed it; the helper was always generic over the command name.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export interface InlineCommandSplit {
|
|
16
|
-
/** Text before the command token, trailing whitespace removed. */
|
|
17
|
-
prose: string;
|
|
18
|
-
/** Everything after the command token to end of message, trimmed. */
|
|
19
|
-
commandArgs: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Find the first inline `/<commandName>` occurrence in `text`.
|
|
24
|
-
*
|
|
25
|
-
* Detection rules:
|
|
26
|
-
* - The token must be preceded by start-of-line or whitespace and followed by
|
|
27
|
-
* whitespace plus a non-empty remainder. A bare trailing `/cmd` mention or a
|
|
28
|
-
* path-like `foo/cmd` never matches.
|
|
29
|
-
* - A token at position 0 is ignored: that is Pi's native dispatch position.
|
|
30
|
-
* - Occurrences inside backtick code (inline spans and fenced blocks) or
|
|
31
|
-
* inside a single/double-quoted span are ignored. Code regions are
|
|
32
|
-
* approximated by the CommonMark backtick-run rule: a run of N backticks
|
|
33
|
-
* opens a region closed by the next run of exactly N backticks.
|
|
34
|
-
*
|
|
35
|
-
* Returns undefined when no qualifying occurrence exists.
|
|
36
|
-
*/
|
|
37
|
-
export function extractInlineCommand(
|
|
38
|
-
text: string,
|
|
39
|
-
commandName: string,
|
|
40
|
-
): InlineCommandSplit | undefined {
|
|
41
|
-
const token = `/${commandName}`;
|
|
42
|
-
const ignored = ignoredRegions(text);
|
|
43
|
-
let searchFrom = 0;
|
|
44
|
-
while (searchFrom < text.length) {
|
|
45
|
-
const index = text.indexOf(token, searchFrom);
|
|
46
|
-
if (index === -1) return undefined;
|
|
47
|
-
searchFrom = index + 1;
|
|
48
|
-
if (index === 0) continue;
|
|
49
|
-
const before = text[index - 1];
|
|
50
|
-
if (before !== undefined && !/\s/.test(before)) continue;
|
|
51
|
-
const afterToken = text[index + token.length];
|
|
52
|
-
if (afterToken === undefined || !/\s/.test(afterToken)) continue;
|
|
53
|
-
if (insideRegion(ignored, index)) continue;
|
|
54
|
-
const commandArgs = text.slice(index + token.length).trim();
|
|
55
|
-
if (!commandArgs) continue;
|
|
56
|
-
return {
|
|
57
|
-
prose: text.slice(0, index).trimEnd(),
|
|
58
|
-
commandArgs,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* True when the message inline-invokes the command: a mid-message `/cmd`
|
|
66
|
-
* token with a remainder, or a `cmd:` prefix at the start of any line — both
|
|
67
|
-
* outside backtick code and quoted spans.
|
|
68
|
-
*/
|
|
69
|
-
export function detectsInlineInvocation(text: string, commandName: string): boolean {
|
|
70
|
-
if (extractInlineCommand(text, commandName) !== undefined) return true;
|
|
71
|
-
const ignored = ignoredRegions(text);
|
|
72
|
-
const prefixPattern = new RegExp(`^[ \\t]*${escapeRegExpText(commandName)}:[ \\t]+\\S`, "gim");
|
|
73
|
-
for (
|
|
74
|
-
let match = prefixPattern.exec(text);
|
|
75
|
-
match;
|
|
76
|
-
match = prefixPattern.exec(text)
|
|
77
|
-
) {
|
|
78
|
-
if (!insideRegion(ignored, match.index)) return true;
|
|
79
|
-
}
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
type Region = readonly [start: number, end: number];
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Backtick code regions plus single/double-quoted spans. Quoting a command is
|
|
87
|
-
* how people discuss one (`use "/loop 10m ship it" to start`), so a quoted
|
|
88
|
-
* token is a mention, not an invocation.
|
|
89
|
-
*/
|
|
90
|
-
function ignoredRegions(text: string): Region[] {
|
|
91
|
-
return [...codeRegions(text), ...quoteRegions(text)];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function codeRegions(text: string): Region[] {
|
|
95
|
-
const regions: Region[] = [];
|
|
96
|
-
const runs: Array<{ index: number; length: number }> = [];
|
|
97
|
-
const runPattern = /`+/g;
|
|
98
|
-
for (let match = runPattern.exec(text); match; match = runPattern.exec(text)) {
|
|
99
|
-
runs.push({ index: match.index, length: match[0].length });
|
|
100
|
-
}
|
|
101
|
-
for (let open = 0; open < runs.length; open += 1) {
|
|
102
|
-
const opener = runs[open];
|
|
103
|
-
if (opener === undefined) continue;
|
|
104
|
-
for (let close = open + 1; close < runs.length; close += 1) {
|
|
105
|
-
const closer = runs[close];
|
|
106
|
-
if (closer === undefined || closer.length !== opener.length) continue;
|
|
107
|
-
regions.push([opener.index, closer.index + closer.length]);
|
|
108
|
-
open = close;
|
|
109
|
-
break;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return regions;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Single- and double-quoted spans, matched conservatively so an apostrophe
|
|
117
|
-
* inside a word (`don't`) never opens one: an opening quote follows nothing,
|
|
118
|
-
* whitespace, or an opening bracket and precedes a non-space; its closing
|
|
119
|
-
* quote is on the same line, follows a non-space, and precedes end-of-line,
|
|
120
|
-
* whitespace, or closing punctuation.
|
|
121
|
-
*/
|
|
122
|
-
function quoteRegions(text: string): Region[] {
|
|
123
|
-
const regions: Region[] = [];
|
|
124
|
-
for (let index = 0; index < text.length; index += 1) {
|
|
125
|
-
const quote = text[index];
|
|
126
|
-
if (quote !== '"' && quote !== "'") continue;
|
|
127
|
-
const before = text[index - 1];
|
|
128
|
-
const after = text[index + 1];
|
|
129
|
-
if (before !== undefined && !/[\s([{<]/.test(before)) continue;
|
|
130
|
-
if (after === undefined || /\s/.test(after)) continue;
|
|
131
|
-
const close = closingQuoteIndex(text, index, quote);
|
|
132
|
-
if (close === undefined) continue;
|
|
133
|
-
regions.push([index, close + 1]);
|
|
134
|
-
index = close;
|
|
135
|
-
}
|
|
136
|
-
return regions;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function closingQuoteIndex(text: string, openIndex: number, quote: string): number | undefined {
|
|
140
|
-
for (let index = openIndex + 1; index < text.length; index += 1) {
|
|
141
|
-
const char = text[index];
|
|
142
|
-
if (char === "\n") return undefined;
|
|
143
|
-
if (char !== quote) continue;
|
|
144
|
-
const before = text[index - 1];
|
|
145
|
-
const after = text[index + 1];
|
|
146
|
-
if (before === undefined || /\s/.test(before)) continue;
|
|
147
|
-
if (after !== undefined && !/[\s.,;:!?)\]}>]/.test(after)) continue;
|
|
148
|
-
return index;
|
|
149
|
-
}
|
|
150
|
-
return undefined;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function insideRegion(regions: readonly Region[], index: number): boolean {
|
|
154
|
-
return regions.some(([start, end]) => index >= start && index < end);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function escapeRegExpText(value: string) {
|
|
158
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
159
|
-
}
|
package/src/inline-invocation.ts
DELETED
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Inline `/loop` invocation, tool-mediated.
|
|
3
|
-
*
|
|
4
|
-
* Pi only dispatches `/loop` when it starts the message. When the user writes
|
|
5
|
-
* `quick check /loop 10m get CI green` or a `loop:` prefixed line mid-prompt,
|
|
6
|
-
* this module appends a one-turn reminder to the system prompt so the model
|
|
7
|
-
* reliably calls the `loop_start` tool with the objective. The user's message
|
|
8
|
-
* itself is never touched — no cutting, splitting, re-sending, or visible
|
|
9
|
-
* annotation — so there are no delivery races, no message loss, and no
|
|
10
|
-
* transcript noise: guidance is injected at agent-start time, not by
|
|
11
|
-
* rewriting input.
|
|
12
|
-
*
|
|
13
|
-
* Two hooks cooperate because neither alone is safe:
|
|
14
|
-
* - `input` carries a `source` and so can tell user-typed text from
|
|
15
|
-
* extension-sent prompts, but any transform it returns rewrites the stored,
|
|
16
|
-
* visible user message. It only records the armed text here.
|
|
17
|
-
* - `before_agent_start` can extend the system prompt, but fires for
|
|
18
|
-
* extension-sent prompts too, and pi-loop's own kickoff and continuation
|
|
19
|
-
* prompts contain phrases like "the active /loop objective" that the
|
|
20
|
-
* detector would match. It injects only when the starting prompt *is* the
|
|
21
|
-
* armed user message, and disarms on every start, matched or not.
|
|
22
|
-
*
|
|
23
|
-
* Two deliberate limits keep the armed window one turn wide:
|
|
24
|
-
* - Streaming-typed input (`streamingBehavior` set, i.e. steered or queued)
|
|
25
|
-
* never arms. Pi returns from `prompt()` before `before_agent_start` for
|
|
26
|
-
* those, so an armed flag would survive to a later, unrelated turn — the
|
|
27
|
-
* window through which pi-loop's own continuation prompts could be matched.
|
|
28
|
-
* - The hint is a per-turn `systemPrompt` append, not a stored message, so
|
|
29
|
-
* "call loop_start now" cannot linger in the conversation and fire on a
|
|
30
|
-
* later turn.
|
|
31
|
-
*
|
|
32
|
-
* The armed flag is also the `loop_start` tool's gate: unlike pi-goal, which
|
|
33
|
-
* relied on prompt guidelines alone, a loop is self-continuing, so this
|
|
34
|
-
* extension *enforces* that the tool only runs on a turn the user explicitly
|
|
35
|
-
* invoked. See `InlineInvocationState.invokedThisTurn`.
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
39
|
-
import { detectsInlineInvocation } from "./inline-command.js";
|
|
40
|
-
import type { LoopController } from "./loop.js";
|
|
41
|
-
|
|
42
|
-
export const INLINE_LOOP_COMMAND = "loop";
|
|
43
|
-
|
|
44
|
-
export const INLINE_LOOP_HINT =
|
|
45
|
-
"<system-reminder>The user's message this turn inline-invoked a loop (/loop or loop:). Call the loop_start tool now with the objective text that follows the token in that message, then begin working toward it. Do not answer the objective as prose without starting the loop. If the message is discussing, quoting, or documenting the /loop command rather than invoking it, do not call loop_start. This reminder applies only to the user's message this turn.</system-reminder>";
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* The armed state, shared between the hooks and the `loop_start` tool.
|
|
49
|
-
*
|
|
50
|
-
* `invokedThisTurn` is the hard gate: set when `before_agent_start` matched
|
|
51
|
-
* the armed user message, cleared at `agent_end` and at every session
|
|
52
|
-
* boundary. `loop_start` refuses whenever it is false, so no amount of prompt
|
|
53
|
-
* drift, transcript replay, or model initiative can start a self-continuing
|
|
54
|
-
* loop the user did not ask for.
|
|
55
|
-
*/
|
|
56
|
-
export class InlineInvocationState {
|
|
57
|
-
invokedThisTurn = false;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function registerInlineInvocation(
|
|
61
|
-
pi: ExtensionAPI,
|
|
62
|
-
controller: LoopController,
|
|
63
|
-
state: InlineInvocationState,
|
|
64
|
-
) {
|
|
65
|
-
let armedText: string | undefined;
|
|
66
|
-
|
|
67
|
-
pi.on("input", (event) => {
|
|
68
|
-
// Extension-sourced messages (loop kickoffs, continuations, pokes, other
|
|
69
|
-
// extensions' injections) never arm the hint.
|
|
70
|
-
if (event.source === "extension") return;
|
|
71
|
-
// Steered or queued input returns from prompt() without ever reaching
|
|
72
|
-
// before_agent_start, so arming it would leave stale text armed.
|
|
73
|
-
if (event.streamingBehavior !== undefined) return;
|
|
74
|
-
if (!controller.settings.inlineInvocation) return;
|
|
75
|
-
if (!detectsInlineInvocation(event.text, INLINE_LOOP_COMMAND)) return;
|
|
76
|
-
armedText = event.text;
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
pi.on("before_agent_start", (event) => {
|
|
80
|
-
const armed = armedText;
|
|
81
|
-
armedText = undefined;
|
|
82
|
-
// Every start closes the previous turn's window, so a turn that ends
|
|
83
|
-
// without an agent_end still cannot leave the tool unlocked.
|
|
84
|
-
state.invokedThisTurn = false;
|
|
85
|
-
if (armed === undefined) return;
|
|
86
|
-
if (!controller.settings.inlineInvocation) return;
|
|
87
|
-
if (!promptCarriesArmedMessage(event.prompt, armed)) return;
|
|
88
|
-
state.invokedThisTurn = true;
|
|
89
|
-
return { systemPrompt: `${event.systemPrompt}\n\n${INLINE_LOOP_HINT}` };
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
const disarm = () => {
|
|
93
|
-
armedText = undefined;
|
|
94
|
-
state.invokedThisTurn = false;
|
|
95
|
-
};
|
|
96
|
-
pi.on("agent_end", disarm);
|
|
97
|
-
pi.on("session_start", disarm);
|
|
98
|
-
pi.on("session_shutdown", disarm);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* True when the starting prompt is the armed user message. Pi may wrap the
|
|
103
|
-
* text with expanded prefixes or suffixes, so the armed text has to be the
|
|
104
|
-
* whole prompt or one of its ends — mirroring how upstream recognises its own
|
|
105
|
-
* owned prompts at a terminal boundary.
|
|
106
|
-
*/
|
|
107
|
-
function promptCarriesArmedMessage(prompt: string, armed: string) {
|
|
108
|
-
return prompt === armed || prompt.startsWith(armed) || prompt.endsWith(armed);
|
|
109
|
-
}
|
package/src/start-tool.ts
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `loop_start`: the model-invoked way into a loop.
|
|
3
|
-
*
|
|
4
|
-
* Pi only dispatches `/loop` when it starts the message, so a mid-prompt
|
|
5
|
-
* `quick check /loop 10m get CI green` arrives as ordinary prose. The
|
|
6
|
-
* inline-invocation hooks append a one-turn reminder to the system prompt for
|
|
7
|
-
* exactly that message, and this tool is what the reminder points at. It
|
|
8
|
-
* reuses `LoopController.startLoop`, the same path the `/loop` command takes,
|
|
9
|
-
* so replacement rules, the loop_complete availability guard, the ledger, the
|
|
10
|
-
* kickoff anchor, and persistence all behave identically.
|
|
11
|
-
*
|
|
12
|
-
* The hard armed-gate is the deliberate divergence from pi-goal's equivalent
|
|
13
|
-
* tool, which relied on prompt guidelines alone. A loop is *self-continuing*:
|
|
14
|
-
* a spurious start does not produce one unwanted answer, it produces turns
|
|
15
|
-
* until a cap. So the tool refuses outright unless the inline hint armed for
|
|
16
|
-
* the turn that is calling it.
|
|
17
|
-
*
|
|
18
|
-
* The one thing this path may decide that the `/loop` command cannot is the
|
|
19
|
-
* loop's completion criteria. They are otherwise a deterministic split of the
|
|
20
|
-
* objective's grammar, which turns a context sentence into a gate criterion;
|
|
21
|
-
* a model that read the objective can do better. It is accepted only *here*,
|
|
22
|
-
* at start, before any work exists to grade and with the user seeing the
|
|
23
|
-
* criteria echoed back — the point where the incentive to write an easy gate
|
|
24
|
-
* is weakest. After start they are immutable, exactly as a derived set is.
|
|
25
|
-
*
|
|
26
|
-
* Registered unconditionally, like the other loop tools: the tool set is part
|
|
27
|
-
* of the cached request prefix, so it never changes with loop state.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
31
|
-
import { Type } from "typebox";
|
|
32
|
-
import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
|
|
33
|
-
import { formatDuration, parseDuration, parseInterval } from "./interval.js";
|
|
34
|
-
import { MAX_CRITERIA, MAX_DESCRIPTION_LENGTH } from "./ledger.js";
|
|
35
|
-
import type { InlineInvocationState } from "./inline-invocation.js";
|
|
36
|
-
import type { LoopController } from "./loop.js";
|
|
37
|
-
|
|
38
|
-
export const LOOP_START_TOOL = "loop_start";
|
|
39
|
-
|
|
40
|
-
/** Long enough for a real objective, short enough to reject a pasted file. */
|
|
41
|
-
const MAX_OBJECTIVE_LENGTH = 4_000;
|
|
42
|
-
|
|
43
|
-
export function registerLoopStartTool(
|
|
44
|
-
pi: ExtensionAPI,
|
|
45
|
-
controller: LoopController,
|
|
46
|
-
invocation: InlineInvocationState,
|
|
47
|
-
) {
|
|
48
|
-
pi.registerTool(
|
|
49
|
-
defineTool({
|
|
50
|
-
name: LOOP_START_TOOL,
|
|
51
|
-
label: "Loop Start",
|
|
52
|
-
description:
|
|
53
|
-
"Start a /loop for an objective the user explicitly invoked with an inline /loop or loop: token in their message. Only for explicit invocations: never start a loop from general conversation, your own initiative, or an instruction that merely sounds loop-like. The objective is the text following the token.",
|
|
54
|
-
promptSnippet:
|
|
55
|
-
"Start a /loop when the user's message contains an explicit inline /loop or loop: invocation",
|
|
56
|
-
promptGuidelines: [
|
|
57
|
-
"Call loop_start only when the user's message contains an explicit `/loop <objective>` or `loop: <objective>` token. Never start a loop without that token, no matter how loop-like the request sounds; the tool refuses when the turn carries no inline invocation.",
|
|
58
|
-
"If the user is discussing, quoting, or documenting the /loop command rather than invoking it — asking how it works, pasting a transcript, or editing text that mentions it — do not call loop_start.",
|
|
59
|
-
"Pass the objective text that follows the token, without the token itself. A leading interval (`10m`, `2h`) and flags like `--max 5` or `--expires 3d` become the interval, max, and expires parameters, not part of the objective.",
|
|
60
|
-
"Call loop_start before doing any of the objective's work, then continue working toward it in the same turn.",
|
|
61
|
-
"Leave the criteria parameter out by default: the extension splits the objective into completion criteria on its own (bullets, else sentences, else the whole objective). Propose criteria only when that split would misfire — when the objective mixes requirements with context sentences (`fix CI. it has been red since Tuesday.`), or packs several requirements into one sentence.",
|
|
62
|
-
"Every criterion you propose must be a faithful restatement of something the user asked for: never fewer, weaker, or easier than the objective as typed, and never a requirement they did not state. They are echoed back to the user at start and frozen afterwards — you may only ever flip a criterion's passes field.",
|
|
63
|
-
"When in doubt, omit criteria and let the deterministic split stand.",
|
|
64
|
-
"Never call loop_complete in the same turn as loop_start: the starting turn has not done the work, and completion needs cited evidence per criterion.",
|
|
65
|
-
"Before your first loop_start this session, read the pi-loop skill: the objective is split into the completion criteria this loop will be gated on, so its wording is the leverage point.",
|
|
66
|
-
],
|
|
67
|
-
parameters: Type.Object({
|
|
68
|
-
objective: Type.String({
|
|
69
|
-
minLength: 1,
|
|
70
|
-
maxLength: MAX_OBJECTIVE_LENGTH,
|
|
71
|
-
description:
|
|
72
|
-
"The loop objective, including how the loop knows it is done: the user's text following the /loop or loop: token, verbatim, without the token, the interval, or flags.",
|
|
73
|
-
}),
|
|
74
|
-
interval: Type.Optional(
|
|
75
|
-
Type.String({
|
|
76
|
-
description:
|
|
77
|
-
"Fallback wake interval from the invocation, e.g. '10m' or '2h'. Omit when the user named none.",
|
|
78
|
-
}),
|
|
79
|
-
),
|
|
80
|
-
max: Type.Optional(
|
|
81
|
-
Type.Integer({
|
|
82
|
-
minimum: 1,
|
|
83
|
-
description:
|
|
84
|
-
"Cap on the turns the loop causes (continuations and pokes), from a --max flag in the invocation.",
|
|
85
|
-
}),
|
|
86
|
-
),
|
|
87
|
-
expires: Type.Optional(
|
|
88
|
-
Type.String({
|
|
89
|
-
description: "Loop lifetime from an --expires flag in the invocation, e.g. '3d'.",
|
|
90
|
-
}),
|
|
91
|
-
),
|
|
92
|
-
criteria: Type.Optional(
|
|
93
|
-
Type.Array(
|
|
94
|
-
Type.String({ minLength: 1, maxLength: MAX_DESCRIPTION_LENGTH }),
|
|
95
|
-
{
|
|
96
|
-
minItems: 1,
|
|
97
|
-
maxItems: MAX_CRITERIA,
|
|
98
|
-
description:
|
|
99
|
-
"Optional completion criteria for this loop, each one checkable requirement restated faithfully from the user's objective. Replaces the deterministic split of the objective, so omit it unless that split would misfire.",
|
|
100
|
-
},
|
|
101
|
-
),
|
|
102
|
-
),
|
|
103
|
-
}),
|
|
104
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
105
|
-
// The gate. Everything below is ordinary validation; this is the
|
|
106
|
-
// one check that makes a self-continuing tool safe to expose.
|
|
107
|
-
if (!invocation.invokedThisTurn) {
|
|
108
|
-
return refusal(
|
|
109
|
-
`${LOOP_START_TOOL} is only available on a turn whose user message contains an explicit inline /loop or loop: invocation. This turn has none, so no loop was started. If the user wants one, they can type /loop <interval> <objective>.`,
|
|
110
|
-
{},
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
const objective = params.objective.trim();
|
|
114
|
-
if (!objective) {
|
|
115
|
-
return refusal("Loop not started: the objective is empty.", {});
|
|
116
|
-
}
|
|
117
|
-
const criteria = params.criteria?.map((description) => description.trim());
|
|
118
|
-
const badCriteria = criteria && describeBadCriteria(criteria);
|
|
119
|
-
if (badCriteria) {
|
|
120
|
-
return refusal(
|
|
121
|
-
`Loop not started: ${badCriteria}. Pass one short checkable requirement per entry, or omit criteria to split the objective deterministically.`,
|
|
122
|
-
{ objective },
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
const existing = controller.state;
|
|
126
|
-
if (existing && existing.status !== "stopped") {
|
|
127
|
-
return refusal(
|
|
128
|
-
`Loop not started: a loop already exists in this session (${existing.status}). The user can replace it with /loop, or stop it with /loop stop.`,
|
|
129
|
-
{ existingLoopId: existing.id },
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
const intervalToken = params.interval?.trim() || controller.settings.defaultInterval;
|
|
133
|
-
const interval = parseInterval(intervalToken);
|
|
134
|
-
if (!interval) {
|
|
135
|
-
return refusal(
|
|
136
|
-
`Loop not started: invalid interval ${intervalToken}. Use <number><unit> with unit s, m, h, or d, e.g. 10m.`,
|
|
137
|
-
{ objective },
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
let expiresInMs: number | undefined;
|
|
141
|
-
if (params.expires !== undefined) {
|
|
142
|
-
expiresInMs = parseDuration(params.expires.trim());
|
|
143
|
-
if (expiresInMs === undefined) {
|
|
144
|
-
return refusal(
|
|
145
|
-
`Loop not started: invalid expiry ${params.expires}. Use <number><unit> with unit s, m, h, or d, e.g. 3d.`,
|
|
146
|
-
{ objective },
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
const result = controller.startLoop(ctx, {
|
|
151
|
-
kind: "start",
|
|
152
|
-
requestedMs: interval.requestedMs,
|
|
153
|
-
intervalMs: interval.effectiveMs,
|
|
154
|
-
clamped: interval.clamped,
|
|
155
|
-
...(params.max === undefined ? {} : { maxTurns: params.max }),
|
|
156
|
-
...(expiresInMs === undefined ? {} : { expiresInMs }),
|
|
157
|
-
...(criteria === undefined ? {} : { criteria }),
|
|
158
|
-
prompt: objective,
|
|
159
|
-
});
|
|
160
|
-
if (!result.ok) return refusal(`Loop not started: ${result.message}`, { objective });
|
|
161
|
-
const loop = result.loop;
|
|
162
|
-
return {
|
|
163
|
-
content: toolContent(
|
|
164
|
-
`Loop started (loop_id ${loop.id}): ${objective}. Fallback wake every ${formatDuration(loop.intervalMs)}. Keep working the objective this turn; the loop continues at every idle boundary until you call ${LOOP_COMPLETE_TOOL} with this loop_id and cited evidence for every criterion, a cap is reached, or the user stops it. Do not call ${LOOP_COMPLETE_TOOL} in this turn.`,
|
|
165
|
-
),
|
|
166
|
-
details: { loopId: loop.id, objective, intervalMs: loop.intervalMs },
|
|
167
|
-
};
|
|
168
|
-
},
|
|
169
|
-
}),
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Why a proposed criteria list cannot be used, or undefined when it can.
|
|
175
|
-
*
|
|
176
|
-
* A list that says nothing is worse than no list: it would replace the
|
|
177
|
-
* deterministic split with a gate the model wrote and can pass by saying
|
|
178
|
-
* anything. So a malformed list refuses the start rather than falling back
|
|
179
|
-
* silently, which would leave the model believing its criteria were accepted.
|
|
180
|
-
*/
|
|
181
|
-
function describeBadCriteria(criteria: readonly string[]): string | undefined {
|
|
182
|
-
if (criteria.length === 0) return "the criteria list is empty";
|
|
183
|
-
if (criteria.length > MAX_CRITERIA) {
|
|
184
|
-
return `a loop takes at most ${MAX_CRITERIA} criteria and ${criteria.length} were given`;
|
|
185
|
-
}
|
|
186
|
-
if (criteria.some((description) => !description)) return "one of the criteria is blank";
|
|
187
|
-
if (criteria.some((description) => description.length > MAX_DESCRIPTION_LENGTH)) {
|
|
188
|
-
return `a criterion may be at most ${MAX_DESCRIPTION_LENGTH} characters`;
|
|
189
|
-
}
|
|
190
|
-
return undefined;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function refusal(text: string, details: Record<string, unknown>) {
|
|
194
|
-
return { content: toolContent(text), details, isError: true };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function toolContent(text: string) {
|
|
198
|
-
return [{ type: "text" as const, text }];
|
|
199
|
-
}
|