@alphacoder-v0/pi-loops 0.17.3
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 +1989 -0
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/README.zh-CN.md +606 -0
- package/docs/cli.md +252 -0
- package/docs/configuration.md +110 -0
- package/docs/design.md +155 -0
- package/docs/goal.md +59 -0
- package/docs/hooks.md +110 -0
- package/docs/loops.md +315 -0
- package/docs/mcp.md +90 -0
- package/docs/screenshot.png +0 -0
- package/docs/session-archive.md +64 -0
- package/docs/triggers.md +140 -0
- package/docs/troubleshooting.md +111 -0
- package/docs/web-ui-parity.md +305 -0
- package/examples/README.md +18 -0
- package/examples/mcp-notify-server.mjs +35 -0
- package/examples/mcp.toml +8 -0
- package/package.json +71 -0
- package/skills/pi-loops/SKILL.md +50 -0
- package/src/archive.ts +476 -0
- package/src/args.ts +217 -0
- package/src/cli-entry.mjs +23 -0
- package/src/cli.ts +610 -0
- package/src/config.ts +103 -0
- package/src/danger.ts +128 -0
- package/src/goal.ts +178 -0
- package/src/hooks.ts +436 -0
- package/src/host-control-channel.ts +243 -0
- package/src/host-control.ts +203 -0
- package/src/host-entry.mjs +14 -0
- package/src/host-runtime.ts +222 -0
- package/src/host.ts +329 -0
- package/src/inbox.ts +232 -0
- package/src/job-edit.ts +113 -0
- package/src/job-health.ts +29 -0
- package/src/lock.ts +193 -0
- package/src/log.ts +108 -0
- package/src/mcp-pool.ts +141 -0
- package/src/mcp.ts +885 -0
- package/src/paths.ts +33 -0
- package/src/pi-loops.ts +2292 -0
- package/src/pi-resolver.mjs +49 -0
- package/src/presence.ts +143 -0
- package/src/protocol.ts +198 -0
- package/src/redact.ts +61 -0
- package/src/register-pi.mjs +3 -0
- package/src/runner.ts +149 -0
- package/src/schedule.ts +431 -0
- package/src/scheduler.ts +981 -0
- package/src/sdk-runner.ts +536 -0
- package/src/share.ts +121 -0
- package/src/slots.ts +76 -0
- package/src/snapshot.ts +40 -0
- package/src/store.ts +599 -0
- package/src/subagent-guard.ts +47 -0
- package/src/thinking.ts +30 -0
- package/src/toml.ts +244 -0
- package/src/tools.ts +455 -0
- package/src/transcript.ts +84 -0
- package/src/trigger-runtime.ts +711 -0
- package/src/triggers.ts +558 -0
- package/src/trust.ts +47 -0
- package/src/ts-entry.mjs +67 -0
- package/src/ui-prefs.ts +84 -0
- package/src/version.ts +14 -0
- package/src/web.mjs +4484 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `~/.pi/agent/loops/config.toml`:
|
|
3
|
+
*
|
|
4
|
+
* allow_project_hooks = false
|
|
5
|
+
* [triggers]
|
|
6
|
+
* poll_interval_secs = 600
|
|
7
|
+
* run_timeout_secs = 900 # cap on a check/action sub-agent
|
|
8
|
+
* [cron]
|
|
9
|
+
* catch_up = true # fire the tick a loop missed while no pi was open (false wins over --catchup)
|
|
10
|
+
* max_concurrent_runs = 3 # sub-agents in flight at once — loop runs and trigger checks share it
|
|
11
|
+
* [hooks]
|
|
12
|
+
* mode = "sync" # sync = awaited inline; async = queued off the turn
|
|
13
|
+
* [host]
|
|
14
|
+
* auto = true # when the last pi quits, a headless host keeps loops and triggers running
|
|
15
|
+
* [limits]
|
|
16
|
+
* daily_budget_usd = 5.0 # stop dispatching once today's automation has cost this much (0 = no cap)
|
|
17
|
+
* [danger]
|
|
18
|
+
* allow = ["rm -rf /var/cache/x"] # command prefixes an unattended run may use anyway
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { parseToml } from "./toml.ts";
|
|
23
|
+
import { DEFAULT_TRIGGER_POLL_INTERVAL_SECS } from "./triggers.ts";
|
|
24
|
+
|
|
25
|
+
export interface LoopsConfig {
|
|
26
|
+
allowProjectHooks: boolean;
|
|
27
|
+
triggerPollIntervalSecs: number;
|
|
28
|
+
triggerRunTimeoutMs: number;
|
|
29
|
+
cronCatchUp: boolean;
|
|
30
|
+
maxConcurrentRuns: number;
|
|
31
|
+
hooksMode: "sync" | "async";
|
|
32
|
+
/** Start the headless host when the last interactive pi quits with automation configured. */
|
|
33
|
+
hostAuto: boolean;
|
|
34
|
+
/** Stop dispatching once automation has spent this much today (local time). 0 = no cap. */
|
|
35
|
+
dailyBudgetUsd: number;
|
|
36
|
+
/** Command prefixes an unattended run may use despite the dangerous-command gate. */
|
|
37
|
+
allowCommands: string[];
|
|
38
|
+
errors: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const DEFAULT_TRIGGER_RUN_TIMEOUT_SECS = 15 * 60;
|
|
42
|
+
export const DEFAULT_MAX_CONCURRENT_RUNS = 3;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* An on/off environment override, read the way a person would write one: `PI_<NAME>=1` or `=true`,
|
|
46
|
+
* either case. One reader for all of them, so a spelling that works in one place works everywhere.
|
|
47
|
+
* Anything that is not `1` or `true` is off, so a typo fails closed.
|
|
48
|
+
*/
|
|
49
|
+
export function envFlag(name: string): boolean {
|
|
50
|
+
const value = process.env[`PI_${name}`];
|
|
51
|
+
return value === "1" || value?.toLowerCase() === "true";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function loadConfig(dir: string): LoopsConfig {
|
|
55
|
+
const cfg: LoopsConfig = { allowProjectHooks: false, triggerPollIntervalSecs: DEFAULT_TRIGGER_POLL_INTERVAL_SECS, triggerRunTimeoutMs: DEFAULT_TRIGGER_RUN_TIMEOUT_SECS * 1000, cronCatchUp: true, maxConcurrentRuns: DEFAULT_MAX_CONCURRENT_RUNS, hooksMode: "sync", hostAuto: true, dailyBudgetUsd: 0, allowCommands: [], errors: [] };
|
|
56
|
+
const file = path.join(dir, "config.toml");
|
|
57
|
+
const positiveInt = (section: string, key: string, v: unknown, apply: (n: number) => void) => {
|
|
58
|
+
if (v === undefined) return;
|
|
59
|
+
if (typeof v === "number" && v >= 1) apply(Math.floor(v));
|
|
60
|
+
else cfg.errors.push(`${section}: ignoring invalid ${key} in ${file}: \`[${section}] ${key}\` must be at least 1`);
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
63
|
+
const doc = parseToml(fs.readFileSync(file, "utf8"));
|
|
64
|
+
if (doc.allow_project_hooks === true) cfg.allowProjectHooks = true;
|
|
65
|
+
const t = doc.triggers as any;
|
|
66
|
+
if (t && t.poll_interval_secs !== undefined) {
|
|
67
|
+
if (typeof t.poll_interval_secs === "number" && t.poll_interval_secs >= 1) cfg.triggerPollIntervalSecs = Math.floor(t.poll_interval_secs);
|
|
68
|
+
else cfg.errors.push(`triggers: ignoring invalid poll interval in ${file}: \`[triggers] poll_interval_secs\` must be at least 1`);
|
|
69
|
+
}
|
|
70
|
+
positiveInt("triggers", "run_timeout_secs", t?.run_timeout_secs, (n) => (cfg.triggerRunTimeoutMs = n * 1000));
|
|
71
|
+
const c = doc.cron as any;
|
|
72
|
+
if (c && c.catch_up !== undefined) {
|
|
73
|
+
if (typeof c.catch_up === "boolean") cfg.cronCatchUp = c.catch_up;
|
|
74
|
+
else cfg.errors.push(`cron: ignoring invalid catch_up in ${file}: must be true or false`);
|
|
75
|
+
}
|
|
76
|
+
positiveInt("cron", "max_concurrent_runs", c?.max_concurrent_runs, (n) => (cfg.maxConcurrentRuns = n));
|
|
77
|
+
const dg = doc.danger as any;
|
|
78
|
+
if (dg && dg.allow !== undefined) {
|
|
79
|
+
if (Array.isArray(dg.allow) && dg.allow.every((x: unknown) => typeof x === "string")) cfg.allowCommands = dg.allow as string[];
|
|
80
|
+
else cfg.errors.push(`danger: ignoring invalid allow in ${file}: [danger] allow must be a list of command prefixes`);
|
|
81
|
+
}
|
|
82
|
+
const lim = doc.limits as any;
|
|
83
|
+
if (lim && lim.daily_budget_usd !== undefined) {
|
|
84
|
+
const n = lim.daily_budget_usd;
|
|
85
|
+
if (typeof n === "number" && Number.isFinite(n) && n >= 0) cfg.dailyBudgetUsd = n;
|
|
86
|
+
else cfg.errors.push(`limits: ignoring invalid daily_budget_usd in ${file}: must be a number of dollars ≥ 0 (0 = no cap)`);
|
|
87
|
+
}
|
|
88
|
+
const ho = doc.host as any;
|
|
89
|
+
if (ho && ho.auto !== undefined) {
|
|
90
|
+
if (typeof ho.auto === "boolean") cfg.hostAuto = ho.auto;
|
|
91
|
+
else cfg.errors.push(`host: ignoring invalid auto in ${file}: must be true or false`);
|
|
92
|
+
}
|
|
93
|
+
const h = doc.hooks as any;
|
|
94
|
+
if (h && h.mode !== undefined) {
|
|
95
|
+
if (h.mode === "sync" || h.mode === "async") cfg.hooksMode = h.mode;
|
|
96
|
+
else cfg.errors.push(`hooks: ignoring invalid mode in ${file}: \`[hooks] mode\` must be "sync" or "async"`);
|
|
97
|
+
}
|
|
98
|
+
} catch (err: any) {
|
|
99
|
+
if (err?.code !== "ENOENT") cfg.errors.push(`${file}: ${err?.message ?? err}`);
|
|
100
|
+
}
|
|
101
|
+
if (envFlag("ALLOW_PROJECT_HOOKS")) cfg.allowProjectHooks = true;
|
|
102
|
+
return cfg;
|
|
103
|
+
}
|
package/src/danger.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dangerous-command policy for runs nobody is watching.
|
|
3
|
+
*
|
|
4
|
+
* pi has no built-in denylist — it asks the person at the keyboard instead, which is the right
|
|
5
|
+
* answer while there is one. A scheduled run has nobody to ask, so an unattended loop would
|
|
6
|
+
* otherwise be free to run `rm -rf /` or `git push --force main`. This module is the whole policy
|
|
7
|
+
* as one function; `src/subagent-guard.ts` is what applies it inside every sub-session.
|
|
8
|
+
*/
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The patterns, widened where the obvious ones are trivially evaded.
|
|
13
|
+
* They run against a *dequoted* copy of the command (see `dequote`), because `su''do` and
|
|
14
|
+
* `sh""utdown` are the same program to a shell and no word-boundary rule survives them.
|
|
15
|
+
*/
|
|
16
|
+
const PATTERNS: Array<[label: string, re: RegExp]> = [
|
|
17
|
+
["sudo invocation", /\b(sudo|doas|pkexec)\b/],
|
|
18
|
+
// Stopping at the first pipe is not enough; a command can reach a shell through any number of them.
|
|
19
|
+
["curl/wget piped into a shell", /\b(curl|wget)\b[\s\S]*\|\s*\S*\b(bash|sh|zsh|fish|dash|ksh)\b/],
|
|
20
|
+
["dd writing to a block device", /\bdd\b[^\n]*\bof=\/dev\/(disk|sd[a-z]|nvme|hd[a-z])/],
|
|
21
|
+
["mkfs / format command", /\bmkfs(\.|\s)/],
|
|
22
|
+
// Requiring 777 immediately after chmod misses any flag (-R) walks past it.
|
|
23
|
+
["chmod 777 on an absolute path", /\bchmod\b(\s+-\S+)*\s+0?777\s+\//],
|
|
24
|
+
["shutdown / reboot / halt", /\b(shutdown|reboot|halt|poweroff)\b/],
|
|
25
|
+
// Matching only `--force`/`-f` misses `+main`, which is the same thing through a refspec.
|
|
26
|
+
["git push --force on main/master", /\bgit\s+push\b[^\n]*(--force|--force-with-lease|\s-f\b|\s\+)[^\n]*\b(main|master)\b/],
|
|
27
|
+
["piping or substituting into eval", /(\|\s*eval\b|\beval\s+["'`]?\$\()/],
|
|
28
|
+
[":(){:|:&};: forkbomb", /:\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:/],
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** Shell syntax that starts a second command; an allowlist entry must not carry one along. */
|
|
32
|
+
const CHAINS = /[;&|\n\r`$(){}<>]/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Does allowlist entry `entry` cover `command`? An entry means that command and nothing else.
|
|
36
|
+
* "Arguments may follow" cannot be made safe: `rm -rf /var/cache/x /` needs no metacharacter at
|
|
37
|
+
* all, and an allowed wrapper (`ssh host`, `docker run`, `timeout 5`) would carry a whole second
|
|
38
|
+
* program as its arguments. The only latitude is a path strictly inside the one the entry ends
|
|
39
|
+
* with, which is narrower than what the entry already permits.
|
|
40
|
+
*/
|
|
41
|
+
function allows(entry: string, command: string): boolean {
|
|
42
|
+
const a = entry.trim();
|
|
43
|
+
if (!a) return false;
|
|
44
|
+
if (command === a) return true;
|
|
45
|
+
if (!command.startsWith(`${a}/`)) return false;
|
|
46
|
+
const rest = command.slice(a.length);
|
|
47
|
+
// `/var/cache/mybuild/../..` starts inside the allowed path and ends somewhere else; whitespace
|
|
48
|
+
// would mean a second operand rather than a deeper path.
|
|
49
|
+
return !rest.includes("..") && !/\s/.test(rest) && !CHAINS.test(rest);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Remove quoting that changes nothing for the shell but hides a word from a regex: `su''do`,
|
|
54
|
+
* `sh""utdown`, `r''m -rf /`.
|
|
55
|
+
*
|
|
56
|
+
* The dequoted copy is what both halves of the policy see — the pattern scan and the `rm` walk — and
|
|
57
|
+
* that is deliberate. It removes only empty quote pairs and backslashes before letters, neither of
|
|
58
|
+
* which a shell would have kept, so `rm` is still found under `r""m` and `$HOME` under `\\$HOME`.
|
|
59
|
+
* The quoting that decides an operand is still there for `normalizeOperand` to strip: `rm -rf "/"`
|
|
60
|
+
* arrives here unchanged and is refused for the target it names. Where the two copies could differ
|
|
61
|
+
* at all (`rm -rf "''/"`, a directory literally called `''`) the dequoted one refuses and the
|
|
62
|
+
* original allows — and in an unattended run, refusing is the direction to be wrong in.
|
|
63
|
+
*/
|
|
64
|
+
function dequote(command: string): string {
|
|
65
|
+
return command.replace(/''|""/g, "").replace(/\\(?=[a-zA-Z])/g, "");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Strip one layer of quoting and expand $HOME / ${HOME} / ~. */
|
|
69
|
+
function normalizeOperand(token: string, home: string | undefined): string {
|
|
70
|
+
let t = token.trim();
|
|
71
|
+
if (t.length >= 2 && ((t[0] === '"' && t.endsWith('"')) || (t[0] === "'" && t.endsWith("'")))) t = t.slice(1, -1);
|
|
72
|
+
if (!home) return t;
|
|
73
|
+
t = t.replace(/\$\{HOME\}|\$HOME/g, home);
|
|
74
|
+
if (t === "~") return home;
|
|
75
|
+
if (t.startsWith("~/")) return home + t.slice(1);
|
|
76
|
+
return t;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const RECURSIVE = /^(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)$/;
|
|
80
|
+
const FORCE = /^(-[a-zA-Z]*f[a-zA-Z]*|--force)$/;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* An `rm` bearing both a recursive and a force flag, aimed at `/`, any absolute path, or $HOME.
|
|
84
|
+
* Every command cluster reachable through `;`, `&&`, `||`, `|` is checked,
|
|
85
|
+
* plus the ones a substitution opens — `echo $(rm -rf /)` runs the `rm` just as surely.
|
|
86
|
+
*/
|
|
87
|
+
function rmRecursiveForceOnDangerousTarget(command: string, home: string | undefined): string | undefined {
|
|
88
|
+
for (const cluster of command.split(/;|&&|\|\||\||\n|`|\$\(|\)/)) {
|
|
89
|
+
const tokens = cluster.trim().split(/\s+/).filter(Boolean);
|
|
90
|
+
const rmAt = tokens.findIndex((t) => t === "rm" || t.endsWith("/rm"));
|
|
91
|
+
if (rmAt < 0) continue;
|
|
92
|
+
const args = tokens.slice(rmAt + 1);
|
|
93
|
+
let recursive = false;
|
|
94
|
+
let force = false;
|
|
95
|
+
for (const a of args) {
|
|
96
|
+
if (RECURSIVE.test(a)) recursive = true;
|
|
97
|
+
if (FORCE.test(a)) force = true;
|
|
98
|
+
}
|
|
99
|
+
if (!recursive || !force) continue;
|
|
100
|
+
for (const a of args) {
|
|
101
|
+
if (a.startsWith("-")) continue;
|
|
102
|
+
// With no known home the `~`/$HOME rules cannot fire, but `/` and absolute paths still do.
|
|
103
|
+
const target = normalizeOperand(a, home);
|
|
104
|
+
if (target === "/") return "rm recursive+force on /";
|
|
105
|
+
if (home && (target === home || target.startsWith(`${home}/`) || target === "~" || target.startsWith("~/"))) return "rm recursive+force on $HOME or ~";
|
|
106
|
+
if (target.startsWith("/")) return "rm recursive+force on absolute path";
|
|
107
|
+
// `X=/; rm -rf $X` reads as a relative path here, and the guard cannot know what the
|
|
108
|
+
// variable holds. An unattended `rm -r -f` at an unknown target is refused on that basis.
|
|
109
|
+
if (/[$`]/.test(target)) return "rm recursive+force on an unresolved target";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The reason this shell command is refused for an unattended run, or undefined when it is fine. */
|
|
116
|
+
export function dangerousCommandReason(command: string, home: string = process.env.HOME || os.homedir(), allow: readonly string[] = []): string | undefined {
|
|
117
|
+
if (!command) return undefined;
|
|
118
|
+
// An allowlist entry permits that command and arguments after it — but nothing chained onto it.
|
|
119
|
+
// A bare prefix match would make `allow = ["rm -rf /var/cache/mybuild"]` also mean
|
|
120
|
+
// `rm -rf /var/cache/mybuild; rm -rf /`, handing arbitrary shell to the one actor this gate
|
|
121
|
+
// exists to stop. Deliberately not a regex: this is a list a person writes in a config file,
|
|
122
|
+
// and a wrong regex there fails open.
|
|
123
|
+
const trimmed = command.trim();
|
|
124
|
+
if (allow.some((a) => allows(a, trimmed))) return undefined;
|
|
125
|
+
const scanned = dequote(command);
|
|
126
|
+
for (const [label, re] of PATTERNS) if (re.test(scanned)) return label;
|
|
127
|
+
return rmRecursiveForceOnDangerousTarget(scanned, home || undefined);
|
|
128
|
+
}
|
package/src/goal.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { stamp } from "./schedule.ts";
|
|
2
|
+
/**
|
|
3
|
+
* `/goal`: a stop condition the agent is held to.
|
|
4
|
+
*
|
|
5
|
+
* Everything pi-loops otherwise schedules is time- or event-driven. A goal is the one mechanism
|
|
6
|
+
* that asks "am I done yet" — after every settled turn an evaluator (a model call with no tools and
|
|
7
|
+
* only a bounded transcript) judges the user's condition. `{ok:false}` sends the agent back to work
|
|
8
|
+
* with what is missing, up to a continuation budget; `{ok:true}` stops; an evaluator that itself
|
|
9
|
+
* fails pauses rather than looping — an evaluator that cannot answer must not become a machine
|
|
10
|
+
* that keeps paying for turns.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Each state change is appended to the session, so `--resume` finds it. */
|
|
14
|
+
export const GOAL_ENTRY = "goal_state";
|
|
15
|
+
/** How many times the agent may be sent back to work before the goal pauses itself. */
|
|
16
|
+
export const MAX_CONTINUATIONS = 8;
|
|
17
|
+
/** How much of the transcript the evaluator is shown. */
|
|
18
|
+
export const TRANSCRIPT_CHAR_LIMIT = 40_000;
|
|
19
|
+
|
|
20
|
+
export type GoalStatus = "pursuing" | "paused" | "achieved" | "budget_limited" | "cleared";
|
|
21
|
+
|
|
22
|
+
export interface GoalState {
|
|
23
|
+
condition: string;
|
|
24
|
+
status: GoalStatus;
|
|
25
|
+
iterations: number;
|
|
26
|
+
lastReason?: string;
|
|
27
|
+
updatedAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A cleared or achieved goal no longer holds the session. */
|
|
31
|
+
export function goalActive(state: GoalState): boolean {
|
|
32
|
+
return state.status === "pursuing" || state.status === "paused" || state.status === "budget_limited";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface EvaluatorDecision {
|
|
36
|
+
ok: boolean;
|
|
37
|
+
reason: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function newGoal(condition: string, now = new Date()): GoalState {
|
|
41
|
+
return { condition, status: "pursuing", iterations: 0, updatedAt: stamp(now.getTime()) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The newest state in a session's entries, or undefined when the goal was cleared or never set. */
|
|
45
|
+
export function latestGoal(entries: Array<{ type?: string; customType?: string; data?: unknown }>): GoalState | undefined {
|
|
46
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
47
|
+
const e = entries[i] as any;
|
|
48
|
+
if (e?.customType !== GOAL_ENTRY) continue;
|
|
49
|
+
const data = e.data as GoalState | undefined;
|
|
50
|
+
if (!data || typeof data.condition !== "string" || typeof data.status !== "string") continue;
|
|
51
|
+
if (data.status === "cleared") return undefined;
|
|
52
|
+
// `iterations` is the continuation budget. A session entry is only as trustworthy as what
|
|
53
|
+
// wrote it (an imported archive carries these verbatim), and a non-number would make
|
|
54
|
+
// `iterations + 1 >= MAX_CONTINUATIONS` false forever — an unbounded self-prompting loop.
|
|
55
|
+
return { ...data, iterations: Number.isInteger(data.iterations) && data.iterations >= 0 ? data.iterations : 0 };
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The transcript the evaluator sees: `role: text` per message, truncated from the front so the most
|
|
62
|
+
* recent evidence always survives the cap.
|
|
63
|
+
*/
|
|
64
|
+
export function transcriptFromMessages(messages: Array<{ role?: string; content?: unknown }>, limit = TRANSCRIPT_CHAR_LIMIT): string {
|
|
65
|
+
const lines: string[] = [];
|
|
66
|
+
for (const m of messages) {
|
|
67
|
+
const role = typeof m?.role === "string" ? m.role : "unknown";
|
|
68
|
+
const text = renderContent(m?.content);
|
|
69
|
+
if (text) lines.push(`${role}: ${text}`);
|
|
70
|
+
}
|
|
71
|
+
const joined = lines.join("\n");
|
|
72
|
+
return joined.length <= limit ? joined : `…\n${joined.slice(joined.length - limit)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderContent(content: unknown): string {
|
|
76
|
+
if (typeof content === "string") return content;
|
|
77
|
+
if (!Array.isArray(content)) return "";
|
|
78
|
+
const parts: string[] = [];
|
|
79
|
+
for (const block of content) {
|
|
80
|
+
const b = block as any;
|
|
81
|
+
if (typeof b?.text === "string") parts.push(b.text);
|
|
82
|
+
else if (b?.type === "toolCall") parts.push(`<tool_call ${b.name ?? "?"}>`);
|
|
83
|
+
else if (b?.type === "thinking") parts.push("<thinking>");
|
|
84
|
+
}
|
|
85
|
+
return parts.join(" ").trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The evaluator's system and user prompts, joined: pi-loops has one prompt channel. */
|
|
89
|
+
export function evaluatorPrompt(condition: string, transcript: string): string {
|
|
90
|
+
return [
|
|
91
|
+
"You are evaluating a stop-condition hook in pi-loops.",
|
|
92
|
+
"Read the conversation transcript carefully, then judge whether the user-provided condition is satisfied.",
|
|
93
|
+
"You cannot call tools. Only use explicit evidence in the transcript.",
|
|
94
|
+
"Your response must be a JSON object with one of these shapes:",
|
|
95
|
+
'{"ok": true, "reason": "<quote evidence from the transcript that satisfies the condition>"}',
|
|
96
|
+
'{"ok": false, "reason": "<quote what is missing or what blocks the condition>"}',
|
|
97
|
+
"Always include a reason field, quoting specific text from the transcript whenever possible.",
|
|
98
|
+
'If the transcript does not contain clear evidence that the condition is satisfied, return {"ok": false, "reason": "insufficient evidence in transcript"}.',
|
|
99
|
+
"",
|
|
100
|
+
`Goal condition:\n${condition}`,
|
|
101
|
+
"",
|
|
102
|
+
`Conversation transcript:\n${transcript}`,
|
|
103
|
+
].join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Parsing a decision: bare JSON, or the first `{`…`}` in the reply. An empty reason is an error. */
|
|
107
|
+
export function parseDecision(text: string): EvaluatorDecision {
|
|
108
|
+
const trimmed = text.trim();
|
|
109
|
+
const candidates = [trimmed];
|
|
110
|
+
const start = trimmed.indexOf("{");
|
|
111
|
+
const end = trimmed.lastIndexOf("}");
|
|
112
|
+
if (start >= 0 && end > start) candidates.push(trimmed.slice(start, end + 1));
|
|
113
|
+
for (const candidate of candidates) {
|
|
114
|
+
let parsed: any;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(candidate);
|
|
117
|
+
} catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!parsed || typeof parsed.ok !== "boolean" || typeof parsed.reason !== "string") continue;
|
|
121
|
+
if (!parsed.reason.trim()) throw new Error("goal evaluator returned an empty reason");
|
|
122
|
+
return { ok: parsed.ok, reason: parsed.reason };
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`goal evaluator returned invalid JSON: ${tailChars(trimmed, 300)}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function tailChars(text: string, maxChars: number): string {
|
|
128
|
+
const chars = Array.from(text);
|
|
129
|
+
return chars.length <= maxChars ? text : chars.slice(chars.length - maxChars).join("");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What the agent is sent back to work with. */
|
|
133
|
+
export function continuationPrompt(condition: string, reason: string): string {
|
|
134
|
+
return `The current /goal is not satisfied yet.\n\nGoal condition:\n${condition}\n\nGoal evaluator says what is missing or blocking completion:\n${reason}\n\nContinue working toward the goal. Do not claim completion until the transcript contains explicit evidence that satisfies the condition.`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type GoalAction = { kind: "stop" } | { kind: "continue"; prompt: string } | { kind: "pause"; reason: string };
|
|
138
|
+
|
|
139
|
+
/** What a decision does to the state, and what happens next. */
|
|
140
|
+
export function applyDecision(state: GoalState, decision: EvaluatorDecision, now = new Date()): { state: GoalState; action: GoalAction } {
|
|
141
|
+
const next: GoalState = { ...state, iterations: state.iterations + 1, lastReason: decision.reason, updatedAt: stamp(now.getTime()) };
|
|
142
|
+
if (decision.ok) return { state: { ...next, status: "achieved" }, action: { kind: "stop" } };
|
|
143
|
+
if (next.iterations >= MAX_CONTINUATIONS) {
|
|
144
|
+
return { state: { ...next, status: "budget_limited" }, action: { kind: "pause", reason: `goal continuation limit reached (${MAX_CONTINUATIONS}); resume with /goal resume` } };
|
|
145
|
+
}
|
|
146
|
+
return { state: next, action: { kind: "continue", prompt: continuationPrompt(next.condition, decision.reason) } };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Whether the session has moved off the point the goal was judged at, so a continuation would be
|
|
151
|
+
* delivered as a follow-up on somebody else's turn. The evaluator reads for up to two minutes and
|
|
152
|
+
* the session takes input the whole time: a question typed meanwhile would be answered under the
|
|
153
|
+
* goal's continuation prompt instead of its own.
|
|
154
|
+
*
|
|
155
|
+
* `entries` is the current branch root-first (pi's `sessionManager.getBranch()`), `at` the leaf id
|
|
156
|
+
* captured before the evaluation started. A moved leaf is not enough on its own: run cards, panel
|
|
157
|
+
* snapshots and the goal's own state entries all append there and mean nothing here — only a user
|
|
158
|
+
* message the goal did not send. The goal's own previous continuation is never one of them; the
|
|
159
|
+
* turn that carried it had settled before this evaluation began, so it is already behind `at`.
|
|
160
|
+
*/
|
|
161
|
+
export function branchMovedSince(entries: Array<{ id: string; type: string; message?: { role?: string } }>, at: string | null): boolean {
|
|
162
|
+
if (at === null) return false; // nothing to compare against (an empty session): behave as before
|
|
163
|
+
const from = entries.findIndex((e) => e.id === at);
|
|
164
|
+
// Rewound, forked or edited: the branch the goal was judged on is not the one we are on.
|
|
165
|
+
if (from < 0) return true;
|
|
166
|
+
return entries.slice(from + 1).some((e) => e.type === "message" && e.message?.role === "user");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** An evaluator that could not decide never loops the agent: it pauses and says why. */
|
|
170
|
+
export function pauseFor(state: GoalState, reason: string, now = new Date()): { state: GoalState; action: GoalAction } {
|
|
171
|
+
return { state: { ...state, status: "paused", lastReason: reason, updatedAt: stamp(now.getTime()) }, action: { kind: "pause", reason } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** One line for `/goal` and the panel. */
|
|
175
|
+
export function goalLine(state: GoalState): string {
|
|
176
|
+
const iter = `${state.iterations}/${MAX_CONTINUATIONS}`;
|
|
177
|
+
return `${state.status} (${iter} continuations)${state.lastReason ? `: ${state.lastReason}` : ""}`;
|
|
178
|
+
}
|