@trevonistrevon/pi-loop 0.6.3 → 0.6.4
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 +14 -0
- package/README.md +3 -2
- package/dist/api.d.ts +2 -1
- package/dist/index.js +10 -0
- package/dist/loop-reducer.d.ts +24 -1
- package/dist/loop-reducer.js +31 -0
- package/dist/notification-reducer.d.ts +2 -1
- package/dist/runtime/notification-runtime.d.ts +2 -1
- package/dist/runtime/notification-runtime.js +18 -0
- package/dist/runtime/task-events.d.ts +2 -1
- package/dist/runtime/task-events.js +1 -0
- package/dist/runtime/task-rpc.d.ts +4 -0
- package/dist/runtime/task-rpc.js +62 -1
- package/dist/store.d.ts +10 -1
- package/dist/store.js +51 -0
- package/dist/task-reducer.d.ts +2 -1
- package/dist/task-reducer.js +1 -0
- package/dist/task-store.d.ts +2 -2
- package/dist/task-store.js +2 -2
- package/dist/task-types.d.ts +6 -0
- package/dist/tools/loop-tools.d.ts +17 -1
- package/dist/tools/loop-tools.js +320 -21
- package/dist/tools/monitor-tools.js +43 -9
- package/dist/tools/native-task-tools.js +56 -11
- package/dist/tools/tool-result.d.ts +12 -2
- package/dist/tools/tool-result.js +7 -3
- package/dist/types.d.ts +35 -0
- package/dist/ui/tool-renderer.d.ts +7 -0
- package/dist/ui/tool-renderer.js +34 -0
- package/dist/ui/widget.js +4 -4
- package/dist/workflow-reducer.d.ts +18 -0
- package/dist/workflow-reducer.js +82 -0
- package/docs/USAGE_GUIDE.md +41 -0
- package/package.json +3 -3
- package/src/api.ts +9 -1
- package/src/index.ts +10 -0
- package/src/loop-reducer.ts +53 -1
- package/src/notification-reducer.ts +2 -1
- package/src/runtime/notification-runtime.ts +21 -1
- package/src/runtime/task-events.ts +3 -1
- package/src/runtime/task-rpc.ts +66 -0
- package/src/store.ts +51 -2
- package/src/task-reducer.ts +3 -1
- package/src/task-store.ts +3 -3
- package/src/task-types.ts +7 -0
- package/src/tools/loop-tools.ts +365 -15
- package/src/tools/monitor-tools.ts +44 -7
- package/src/tools/native-task-tools.ts +56 -8
- package/src/tools/tool-result.ts +18 -2
- package/src/types.ts +41 -0
- package/src/ui/tool-renderer.ts +45 -0
- package/src/ui/widget.ts +4 -4
- package/src/workflow-reducer.ts +107 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { createTask, deleteTask, updateTask, } from "../runtime/task-mutations.js";
|
|
3
|
-
import {
|
|
3
|
+
import { renderToolCall, renderToolResult, toolArg } from "../ui/tool-renderer.js";
|
|
4
|
+
import { displayRows, textResult } from "./tool-result.js";
|
|
4
5
|
function backlogSuffix(backlog) {
|
|
5
6
|
return backlog.created && backlog.entry
|
|
6
7
|
? `\nBacklog worker loop #${backlog.entry.id} created`
|
|
@@ -12,6 +13,8 @@ export function registerNativeTaskTools(options) {
|
|
|
12
13
|
pi.registerTool({
|
|
13
14
|
name: "TaskCreate",
|
|
14
15
|
label: "TaskCreate",
|
|
16
|
+
renderCall: renderToolCall("Task", (args) => `create · ${String(toolArg(args, "subject") ?? "task").slice(0, 56)}`),
|
|
17
|
+
renderResult: renderToolResult,
|
|
15
18
|
description: `Create a task for tracking work across turns. Use when you need to track progress on complex multi-step tasks or turn a broad user goal into a concrete backlog.
|
|
16
19
|
|
|
17
20
|
Fields:
|
|
@@ -38,18 +41,32 @@ Fields:
|
|
|
38
41
|
subject: params.subject,
|
|
39
42
|
description: params.description,
|
|
40
43
|
});
|
|
41
|
-
return textResult(`Task #${entry.id} created: ${entry.subject}${backlogSuffix(backlog)}
|
|
44
|
+
return textResult(`Task #${entry.id} created: ${entry.subject}${backlogSuffix(backlog)}`, {
|
|
45
|
+
kind: "task",
|
|
46
|
+
action: "create",
|
|
47
|
+
tone: "success",
|
|
48
|
+
summary: `Task #${entry.id} pending · ${entry.subject.slice(0, 56)}`,
|
|
49
|
+
expanded: [
|
|
50
|
+
`Description: ${entry.description}`,
|
|
51
|
+
backlog.created && backlog.entry ? `Backlog worker: loop #${backlog.entry.id} created` : "Backlog worker: unchanged",
|
|
52
|
+
],
|
|
53
|
+
});
|
|
42
54
|
},
|
|
43
55
|
});
|
|
44
56
|
pi.registerTool({
|
|
45
57
|
name: "TaskList",
|
|
46
58
|
label: "TaskList",
|
|
59
|
+
renderCall: renderToolCall("Task", () => "status"),
|
|
60
|
+
renderResult: renderToolResult,
|
|
47
61
|
description: "List all tasks with status. Use to check progress and find available work.",
|
|
48
62
|
parameters: Type.Object({}),
|
|
49
63
|
execute() {
|
|
50
64
|
const tasks = taskStore.list();
|
|
51
|
-
if (tasks.length === 0)
|
|
52
|
-
return Promise.resolve(textResult("No tasks."
|
|
65
|
+
if (tasks.length === 0) {
|
|
66
|
+
return Promise.resolve(textResult("No tasks.", {
|
|
67
|
+
kind: "task", action: "list", tone: "info", summary: "No tasks", expanded: ["Use TaskCreate for work that spans turns."],
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
53
70
|
const lines = [];
|
|
54
71
|
const statuses = {
|
|
55
72
|
pending: 0,
|
|
@@ -62,12 +79,20 @@ Fields:
|
|
|
62
79
|
lines.push(`${icon} #${t.id} [${t.status}] ${t.subject.slice(0, 80)}`);
|
|
63
80
|
}
|
|
64
81
|
lines.unshift(`${tasks.length} tasks (${statuses.pending} pending, ${statuses.in_progress} in progress, ${statuses.completed} done)`);
|
|
65
|
-
return Promise.resolve(textResult(lines.join("\n")
|
|
82
|
+
return Promise.resolve(textResult(lines.join("\n"), {
|
|
83
|
+
kind: "task",
|
|
84
|
+
action: "list",
|
|
85
|
+
tone: "info",
|
|
86
|
+
summary: `${tasks.length} task${tasks.length === 1 ? "" : "s"} · ${statuses.pending} pending · ${statuses.in_progress} active`,
|
|
87
|
+
expanded: displayRows(lines.slice(1)),
|
|
88
|
+
}));
|
|
66
89
|
},
|
|
67
90
|
});
|
|
68
91
|
pi.registerTool({
|
|
69
92
|
name: "TaskUpdate",
|
|
70
93
|
label: "TaskUpdate",
|
|
94
|
+
renderCall: renderToolCall("Task", (args) => `update · #${String(toolArg(args, "id") ?? "?")}`),
|
|
95
|
+
renderResult: renderToolResult,
|
|
71
96
|
description: `Update task status or details. Set status to "in_progress" before starting work, "completed" when done.
|
|
72
97
|
|
|
73
98
|
Statuses: pending → in_progress → completed
|
|
@@ -91,24 +116,44 @@ Parameters: id (required), status, subject, description`,
|
|
|
91
116
|
subject,
|
|
92
117
|
description,
|
|
93
118
|
});
|
|
94
|
-
if (!result)
|
|
95
|
-
return textResult(`Task #${id} not found
|
|
119
|
+
if (!result) {
|
|
120
|
+
return textResult(`Task #${id} not found`, {
|
|
121
|
+
kind: "task", action: "update", tone: "error", summary: `Task #${id} not found`, expanded: ["Use TaskList to find valid task IDs."],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
96
124
|
const statusMsg = status ? ` → ${status}` : "";
|
|
97
|
-
return textResult(`Task #${id} updated${statusMsg}${backlogSuffix(result.backlog)}
|
|
125
|
+
return textResult(`Task #${id} updated${statusMsg}${backlogSuffix(result.backlog)}`, {
|
|
126
|
+
kind: "task",
|
|
127
|
+
action: "update",
|
|
128
|
+
tone: "success",
|
|
129
|
+
summary: `Task #${id}${status ? ` → ${status}` : " updated"}`,
|
|
130
|
+
expanded: [
|
|
131
|
+
`Subject: ${result.entry.subject}`,
|
|
132
|
+
`Status: ${result.entry.status}`,
|
|
133
|
+
result.backlog.created && result.backlog.entry ? `Backlog worker: loop #${result.backlog.entry.id} created` : "Backlog worker: unchanged",
|
|
134
|
+
],
|
|
135
|
+
});
|
|
98
136
|
},
|
|
99
137
|
});
|
|
100
138
|
pi.registerTool({
|
|
101
139
|
name: "TaskDelete",
|
|
102
140
|
label: "TaskDelete",
|
|
141
|
+
renderCall: renderToolCall("Task", (args) => `delete · #${String(toolArg(args, "id") ?? "?")}`),
|
|
142
|
+
renderResult: renderToolResult,
|
|
103
143
|
description: "Delete a task by ID. Use for cleaning up completed or irrelevant tasks.",
|
|
104
144
|
parameters: Type.Object({
|
|
105
145
|
id: Type.String({ description: "Task ID to delete" }),
|
|
106
146
|
}),
|
|
107
147
|
async execute(_toolCallId, params) {
|
|
108
148
|
const result = await deleteTask(mutationCtx, params.id);
|
|
109
|
-
if (!result)
|
|
110
|
-
return textResult(`Task #${params.id} not found
|
|
111
|
-
|
|
149
|
+
if (!result) {
|
|
150
|
+
return textResult(`Task #${params.id} not found`, {
|
|
151
|
+
kind: "task", action: "delete", tone: "error", summary: `Task #${params.id} not found`, expanded: ["Use TaskList to find valid task IDs."],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return textResult(`Task #${params.id} deleted`, {
|
|
155
|
+
kind: "task", action: "delete", tone: "success", summary: `Task #${params.id} deleted`, expanded: [],
|
|
156
|
+
});
|
|
112
157
|
},
|
|
113
158
|
});
|
|
114
159
|
}
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
/** Plain-text tool result in the shape pi's registerTool expects. */
|
|
2
|
-
export
|
|
2
|
+
export type ToolDisplayKind = "loop" | "workflow" | "task" | "monitor";
|
|
3
|
+
export type ToolDisplayTone = "success" | "warning" | "error" | "info";
|
|
4
|
+
export interface ToolDisplayDetails {
|
|
5
|
+
kind: ToolDisplayKind;
|
|
6
|
+
action: string;
|
|
7
|
+
tone: ToolDisplayTone;
|
|
8
|
+
summary: string;
|
|
9
|
+
expanded?: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare function displayRows(rows: string[], limit?: number): string[];
|
|
12
|
+
export declare function textResult(msg: string, details?: ToolDisplayDetails): {
|
|
3
13
|
content: {
|
|
4
14
|
type: "text";
|
|
5
15
|
text: string;
|
|
6
16
|
}[];
|
|
7
|
-
details: undefined;
|
|
17
|
+
details: ToolDisplayDetails | undefined;
|
|
8
18
|
};
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
export function displayRows(rows, limit = 8) {
|
|
2
|
+
if (rows.length <= limit)
|
|
3
|
+
return rows;
|
|
4
|
+
return [...rows.slice(0, limit), `… ${rows.length - limit} more`];
|
|
5
|
+
}
|
|
6
|
+
export function textResult(msg, details) {
|
|
7
|
+
return { content: [{ type: "text", text: msg }], details };
|
|
4
8
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -40,6 +40,40 @@ export interface DynamicLoopState {
|
|
|
40
40
|
awaitingUpdate?: boolean;
|
|
41
41
|
lastUpdatedAt?: number;
|
|
42
42
|
}
|
|
43
|
+
export type WorkflowTerminalStatus = "completed" | "paused";
|
|
44
|
+
export interface WorkflowTaskDefinition {
|
|
45
|
+
subject: string;
|
|
46
|
+
description: string;
|
|
47
|
+
}
|
|
48
|
+
export interface WorkflowStateDefinition {
|
|
49
|
+
prompt: string;
|
|
50
|
+
task?: WorkflowTaskDefinition;
|
|
51
|
+
on?: Record<string, string>;
|
|
52
|
+
terminal?: WorkflowTerminalStatus;
|
|
53
|
+
maxAttempts?: number;
|
|
54
|
+
}
|
|
55
|
+
export interface WorkflowDefinition {
|
|
56
|
+
version: 1;
|
|
57
|
+
initialState: string;
|
|
58
|
+
states: Record<string, WorkflowStateDefinition>;
|
|
59
|
+
}
|
|
60
|
+
export interface WorkflowTransitionRecord {
|
|
61
|
+
from: string;
|
|
62
|
+
to: string;
|
|
63
|
+
outcome: string;
|
|
64
|
+
evidence?: string;
|
|
65
|
+
at: number;
|
|
66
|
+
sequence: number;
|
|
67
|
+
}
|
|
68
|
+
export interface WorkflowRunState {
|
|
69
|
+
definition: WorkflowDefinition;
|
|
70
|
+
currentState: string;
|
|
71
|
+
transitionSeq: number;
|
|
72
|
+
stateEnteredAt: number;
|
|
73
|
+
attemptsByState: Record<string, number>;
|
|
74
|
+
activeTaskId?: string;
|
|
75
|
+
lastTransition?: WorkflowTransitionRecord;
|
|
76
|
+
}
|
|
43
77
|
export interface LoopEntry {
|
|
44
78
|
id: string;
|
|
45
79
|
prompt: string;
|
|
@@ -55,6 +89,7 @@ export interface LoopEntry {
|
|
|
55
89
|
maxFires?: number;
|
|
56
90
|
fireCount?: number;
|
|
57
91
|
dynamic?: DynamicLoopState;
|
|
92
|
+
workflow?: WorkflowRunState;
|
|
58
93
|
}
|
|
59
94
|
export interface LoopStoreData {
|
|
60
95
|
nextId: number;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
type ToolCallArgs = object;
|
|
4
|
+
export declare function renderToolCall(label: string, summarize: (args: ToolCallArgs) => string): (args: ToolCallArgs, theme: Theme) => Text;
|
|
5
|
+
export declare function toolArg(args: ToolCallArgs, name: string): unknown;
|
|
6
|
+
export declare function renderToolResult(result: AgentToolResult<unknown>, { expanded, isPartial }: ToolRenderResultOptions, theme: Theme): Text;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
export function renderToolCall(label, summarize) {
|
|
3
|
+
return (args, theme) => {
|
|
4
|
+
const summary = summarize(args);
|
|
5
|
+
const text = theme.fg("toolTitle", theme.bold(`${label} `)) + theme.fg("muted", summary);
|
|
6
|
+
return new Text(text, 0, 0);
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export function toolArg(args, name) {
|
|
10
|
+
return args[name];
|
|
11
|
+
}
|
|
12
|
+
export function renderToolResult(result, { expanded, isPartial }, theme) {
|
|
13
|
+
if (isPartial)
|
|
14
|
+
return new Text(theme.fg("warning", "Working…"), 0, 0);
|
|
15
|
+
const details = result.details;
|
|
16
|
+
if (!details) {
|
|
17
|
+
const content = result.content[0];
|
|
18
|
+
return new Text(content?.type === "text" ? content.text : "No result", 0, 0);
|
|
19
|
+
}
|
|
20
|
+
const color = details.tone === "success"
|
|
21
|
+
? "success"
|
|
22
|
+
: details.tone === "warning"
|
|
23
|
+
? "warning"
|
|
24
|
+
: details.tone === "error"
|
|
25
|
+
? "error"
|
|
26
|
+
: "muted";
|
|
27
|
+
const icon = details.tone === "success" ? "✓" : details.tone === "error" ? "✕" : details.tone === "warning" ? "!" : "•";
|
|
28
|
+
let text = theme.fg(color, `${icon} ${details.summary}`);
|
|
29
|
+
if (expanded && details.expanded?.length) {
|
|
30
|
+
for (const line of details.expanded)
|
|
31
|
+
text += `\n${theme.fg("dim", line)}`;
|
|
32
|
+
}
|
|
33
|
+
return new Text(text, 0, 0);
|
|
34
|
+
}
|
package/dist/ui/widget.js
CHANGED
|
@@ -23,18 +23,18 @@ export class LoopWidget {
|
|
|
23
23
|
}
|
|
24
24
|
computeStatus() {
|
|
25
25
|
const loops = this.store.list().filter(isStatusVisibleLoop);
|
|
26
|
-
const monitors = this.monitorManager.list();
|
|
26
|
+
const monitors = this.monitorManager.list().filter((monitor) => monitor.status === "running");
|
|
27
27
|
const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
|
|
28
28
|
if (loops.length === 0 && monitors.length === 0 && taskSummary.count === 0) {
|
|
29
29
|
return undefined;
|
|
30
30
|
}
|
|
31
31
|
const parts = [];
|
|
32
32
|
if (loops.length > 0)
|
|
33
|
-
parts.push(formatCount(loops.length, "loop"));
|
|
33
|
+
parts.push(`↻ ${formatCount(loops.length, "loop")}`);
|
|
34
34
|
if (monitors.length > 0)
|
|
35
|
-
parts.push(formatCount(monitors.length, "monitor"));
|
|
35
|
+
parts.push(`▶ ${formatCount(monitors.length, "monitor")}`);
|
|
36
36
|
if (taskSummary.count > 0)
|
|
37
|
-
parts.push(formatCount(taskSummary.count, "task"));
|
|
37
|
+
parts.push(`□ ${formatCount(taskSummary.count, "task")}`);
|
|
38
38
|
let line = parts.join(" · ");
|
|
39
39
|
if (taskSummary.focusText)
|
|
40
40
|
line += ` | ${taskSummary.focusText}`;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { WorkflowDefinition, WorkflowRunState, WorkflowTerminalStatus } from "./types.js";
|
|
2
|
+
export type { WorkflowDefinition, WorkflowRunState, WorkflowStateDefinition, WorkflowTerminalStatus, WorkflowTransitionRecord, } from "./types.js";
|
|
3
|
+
export interface WorkflowTransitionInput {
|
|
4
|
+
outcome: string;
|
|
5
|
+
evidence?: string;
|
|
6
|
+
activeTaskId?: string;
|
|
7
|
+
}
|
|
8
|
+
export type WorkflowTransitionResult = {
|
|
9
|
+
applied: true;
|
|
10
|
+
run: WorkflowRunState;
|
|
11
|
+
terminal?: WorkflowTerminalStatus;
|
|
12
|
+
} | {
|
|
13
|
+
applied: false;
|
|
14
|
+
error: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function validateWorkflowDefinition(definition: WorkflowDefinition): string | undefined;
|
|
17
|
+
export declare function createWorkflowRun(definition: WorkflowDefinition, at: number): WorkflowRunState;
|
|
18
|
+
export declare function transitionWorkflowRun(run: WorkflowRunState, input: WorkflowTransitionInput, at: number): WorkflowTransitionResult;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export function validateWorkflowDefinition(definition) {
|
|
2
|
+
if (!definition || definition.version !== 1)
|
|
3
|
+
return "Workflow version must be 1";
|
|
4
|
+
if (!definition.states || typeof definition.states !== "object")
|
|
5
|
+
return "Workflow states must be an object";
|
|
6
|
+
if (!definition.initialState || !definition.states[definition.initialState]) {
|
|
7
|
+
return `Initial state "${definition.initialState}" is not defined`;
|
|
8
|
+
}
|
|
9
|
+
for (const [stateId, state] of Object.entries(definition.states)) {
|
|
10
|
+
if (!stateId)
|
|
11
|
+
return "Workflow state IDs must be non-empty";
|
|
12
|
+
if (!state || typeof state !== "object")
|
|
13
|
+
return `State "${stateId}" must be an object`;
|
|
14
|
+
if (typeof state.prompt !== "string")
|
|
15
|
+
return `State "${stateId}" requires a prompt`;
|
|
16
|
+
if (!state.prompt.trim())
|
|
17
|
+
return `State "${stateId}" requires a prompt`;
|
|
18
|
+
if (state.on !== undefined && (typeof state.on !== "object" || Array.isArray(state.on))) {
|
|
19
|
+
return `State "${stateId}" transitions must be an object`;
|
|
20
|
+
}
|
|
21
|
+
if (state.terminal && state.on && Object.keys(state.on).length > 0) {
|
|
22
|
+
return `Terminal state "${stateId}" cannot declare transitions`;
|
|
23
|
+
}
|
|
24
|
+
if (state.maxAttempts !== undefined && (!Number.isInteger(state.maxAttempts) || state.maxAttempts < 1)) {
|
|
25
|
+
return `State "${stateId}" maxAttempts must be a positive integer`;
|
|
26
|
+
}
|
|
27
|
+
for (const [outcome, target] of Object.entries(state.on ?? {})) {
|
|
28
|
+
if (!outcome)
|
|
29
|
+
return `State "${stateId}" has an empty outcome name`;
|
|
30
|
+
if (typeof target !== "string")
|
|
31
|
+
return `Transition "${stateId}.${outcome}" target must be a state ID`;
|
|
32
|
+
if (!definition.states[target])
|
|
33
|
+
return `Transition "${stateId}.${outcome}" targets unknown state "${target}"`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
export function createWorkflowRun(definition, at) {
|
|
39
|
+
return {
|
|
40
|
+
definition,
|
|
41
|
+
currentState: definition.initialState,
|
|
42
|
+
transitionSeq: 0,
|
|
43
|
+
stateEnteredAt: at,
|
|
44
|
+
attemptsByState: { [definition.initialState]: 1 },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function transitionWorkflowRun(run, input, at) {
|
|
48
|
+
const current = run.definition.states[run.currentState];
|
|
49
|
+
if (!current)
|
|
50
|
+
return { applied: false, error: `Current state "${run.currentState}" is not defined` };
|
|
51
|
+
if (current.terminal)
|
|
52
|
+
return { applied: false, error: `Workflow is already ${current.terminal}` };
|
|
53
|
+
const target = current.on?.[input.outcome];
|
|
54
|
+
if (!target)
|
|
55
|
+
return { applied: false, error: `Outcome "${input.outcome}" is not allowed from state "${run.currentState}"` };
|
|
56
|
+
const targetState = run.definition.states[target];
|
|
57
|
+
if (!targetState)
|
|
58
|
+
return { applied: false, error: `Transition target "${target}" is not defined` };
|
|
59
|
+
const nextAttempt = (run.attemptsByState[target] ?? 0) + 1;
|
|
60
|
+
if (targetState.maxAttempts !== undefined && nextAttempt > targetState.maxAttempts) {
|
|
61
|
+
return { applied: false, error: `State "${target}" has exhausted its ${targetState.maxAttempts} attempt limit` };
|
|
62
|
+
}
|
|
63
|
+
const sequence = run.transitionSeq + 1;
|
|
64
|
+
const lastTransition = {
|
|
65
|
+
from: run.currentState,
|
|
66
|
+
to: target,
|
|
67
|
+
outcome: input.outcome,
|
|
68
|
+
evidence: input.evidence,
|
|
69
|
+
at,
|
|
70
|
+
sequence,
|
|
71
|
+
};
|
|
72
|
+
const nextRun = {
|
|
73
|
+
...run,
|
|
74
|
+
currentState: target,
|
|
75
|
+
transitionSeq: sequence,
|
|
76
|
+
stateEnteredAt: at,
|
|
77
|
+
attemptsByState: { ...run.attemptsByState, [target]: nextAttempt },
|
|
78
|
+
activeTaskId: input.activeTaskId,
|
|
79
|
+
lastTransition,
|
|
80
|
+
};
|
|
81
|
+
return { applied: true, run: nextRun, terminal: targetState.terminal };
|
|
82
|
+
}
|
package/docs/USAGE_GUIDE.md
CHANGED
|
@@ -60,6 +60,47 @@ LoopUpdate id="1" status="completed"
|
|
|
60
60
|
|
|
61
61
|
Paused dynamic loops can be resumed from the `/loop` menu. If an in-memory wake is lost during a restart or session switch, persisted dynamic state recovers it.
|
|
62
62
|
|
|
63
|
+
### Opt-in workflow loops
|
|
64
|
+
|
|
65
|
+
Use a workflow only when work has stable named phases and outcomes. Ordinary reminders, polling, event hooks, and flat task backlogs should continue to use `LoopCreate` or the task tools.
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
WorkflowCreate goal="Fix the regression" definition='{
|
|
69
|
+
"version": 1,
|
|
70
|
+
"initialState": "investigate",
|
|
71
|
+
"states": {
|
|
72
|
+
"investigate": {
|
|
73
|
+
"prompt": "Find and verify the root cause.",
|
|
74
|
+
"task": { "subject": "Investigate regression", "description": "Find the root cause and reproduce it." },
|
|
75
|
+
"on": { "root_cause_found": "fix", "blocked": "blocked" }
|
|
76
|
+
},
|
|
77
|
+
"fix": {
|
|
78
|
+
"prompt": "Implement and validate the fix.",
|
|
79
|
+
"task": { "subject": "Implement fix", "description": "Make the smallest fix and run targeted validation." },
|
|
80
|
+
"on": { "tests_pass": "done", "regression_found": "investigate" },
|
|
81
|
+
"maxAttempts": 2
|
|
82
|
+
},
|
|
83
|
+
"done": { "prompt": "Report completion.", "terminal": "completed" },
|
|
84
|
+
"blocked": { "prompt": "Report the blocker.", "terminal": "paused" }
|
|
85
|
+
}
|
|
86
|
+
}'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Each wake presents the current state, state instructions, active task, and allowed outcomes. The agent finishes the state by selecting one declared outcome:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
WorkflowTransition id="1" outcome="root_cause_found" evidence="A null config reaches the parser."
|
|
93
|
+
WorkflowTransition id="1" outcome="tests_pass" evidence="Targeted and full test suites pass."
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`WorkflowTransition` validates the branch, records evidence, creates the next state's optional task, and queues the next wake. Reaching a `completed` terminal state deletes the workflow loop; reaching a `paused` terminal state preserves it in paused state. Task completion does not guess an outcome—the model selects one explicitly.
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
WorkflowList
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Lists only workflow loops, including their active state, active task, and valid outcomes.
|
|
103
|
+
|
|
63
104
|
### Inspecting and stopping loops
|
|
64
105
|
|
|
65
106
|
```text
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trevonistrevon/pi-loop",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "A pi extension for cron/event-based agent re-wake loops and background process monitoring.",
|
|
5
5
|
"author": "trevonistrevon",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"./package.json": "./package.json"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
35
|
-
"@earendil-works/pi-tui": "^0.
|
|
34
|
+
"@earendil-works/pi-coding-agent": "^0.81.1",
|
|
35
|
+
"@earendil-works/pi-tui": "^0.81.1"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"typebox": "^1.1.34"
|
package/src/api.ts
CHANGED
|
@@ -33,4 +33,12 @@ export {
|
|
|
33
33
|
export { NATIVE_TASKS_PROVIDER } from "./runtime/native-task-rpc.js";
|
|
34
34
|
export { resolveLoopStorePath, resolveTaskStorePath } from "./runtime/scope.js";
|
|
35
35
|
export { TaskStore } from "./task-store.js";
|
|
36
|
-
export type { TaskEntry, TaskStatus, TaskStoreData } from "./task-types.js";
|
|
36
|
+
export type { TaskEntry, TaskStatus, TaskStoreData, TaskWorkflowLink } from "./task-types.js";
|
|
37
|
+
export type {
|
|
38
|
+
WorkflowDefinition,
|
|
39
|
+
WorkflowRunState,
|
|
40
|
+
WorkflowStateDefinition,
|
|
41
|
+
WorkflowTaskDefinition,
|
|
42
|
+
WorkflowTerminalStatus,
|
|
43
|
+
WorkflowTransitionRecord,
|
|
44
|
+
} from "./types.js";
|
package/src/index.ts
CHANGED
|
@@ -128,6 +128,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
128
128
|
onDetectionSettled: () => {
|
|
129
129
|
tasksDetectionSettled = true;
|
|
130
130
|
},
|
|
131
|
+
isDetectionSettled: () => tasksDetectionSettled,
|
|
132
|
+
onNativeTaskCompleted: () => {
|
|
133
|
+
widget.update();
|
|
134
|
+
},
|
|
131
135
|
debug,
|
|
132
136
|
});
|
|
133
137
|
|
|
@@ -282,6 +286,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
282
286
|
autoTask: firedEntry.autoTask,
|
|
283
287
|
taskBacklog: firedEntry.taskBacklog,
|
|
284
288
|
dynamic: firedEntry.dynamic,
|
|
289
|
+
workflow: firedEntry.workflow,
|
|
285
290
|
});
|
|
286
291
|
}
|
|
287
292
|
|
|
@@ -349,6 +354,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
349
354
|
},
|
|
350
355
|
maybeBootstrapTaskLoop,
|
|
351
356
|
isTaskSystemReady: () => tasksAvailable || nativeTasksRegistered,
|
|
357
|
+
onDynamicLoopActivated: (entry) => {
|
|
358
|
+
onLoopFire(entry);
|
|
359
|
+
},
|
|
360
|
+
createWorkflowTask: (entry) => taskRuntime.createWorkflowTask(entry),
|
|
361
|
+
completeWorkflowTask: (taskId) => taskRuntime.completeWorkflowTask(taskId),
|
|
352
362
|
});
|
|
353
363
|
|
|
354
364
|
function handleMonitorDoneLoop(doneLoop: LoopEntry, monitorId: string): void {
|
package/src/loop-reducer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { DynamicLoopState, LoopEntry, Trigger } from "./types.js";
|
|
1
|
+
import type { DynamicLoopState, LoopEntry, Trigger, WorkflowDefinition } from "./types.js";
|
|
2
|
+
import { createWorkflowRun, transitionWorkflowRun } from "./workflow-reducer.js";
|
|
2
3
|
|
|
3
4
|
export const MAX_LOOP_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
5
|
|
|
@@ -35,6 +36,7 @@ export type LoopReducerEvent =
|
|
|
35
36
|
readOnly?: boolean;
|
|
36
37
|
maxFires?: number;
|
|
37
38
|
dynamic?: Partial<DynamicLoopState>;
|
|
39
|
+
workflow?: WorkflowDefinition;
|
|
38
40
|
};
|
|
39
41
|
}
|
|
40
42
|
| {
|
|
@@ -73,6 +75,27 @@ export type LoopReducerEvent =
|
|
|
73
75
|
prompt?: string;
|
|
74
76
|
dynamic: Partial<DynamicLoopState>;
|
|
75
77
|
};
|
|
78
|
+
}
|
|
79
|
+
| {
|
|
80
|
+
type: "LOOP_WORKFLOW_TRANSITION";
|
|
81
|
+
at: number;
|
|
82
|
+
source: ReducerSource;
|
|
83
|
+
entityType?: "loop";
|
|
84
|
+
entityId?: string;
|
|
85
|
+
payload: {
|
|
86
|
+
id: string;
|
|
87
|
+
outcome: string;
|
|
88
|
+
evidence?: string;
|
|
89
|
+
activeTaskId?: string;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
| {
|
|
93
|
+
type: "LOOP_WORKFLOW_TASK_SET";
|
|
94
|
+
at: number;
|
|
95
|
+
source: ReducerSource;
|
|
96
|
+
entityType?: "loop";
|
|
97
|
+
entityId?: string;
|
|
98
|
+
payload: { id: string; taskId?: string };
|
|
76
99
|
};
|
|
77
100
|
|
|
78
101
|
export type LoopReducerEffect =
|
|
@@ -131,6 +154,7 @@ export function reduceLoopState(state: LoopReducerState, event: LoopReducerEvent
|
|
|
131
154
|
lastUpdatedAt: event.payload.dynamic?.lastUpdatedAt ?? event.at,
|
|
132
155
|
}
|
|
133
156
|
: undefined,
|
|
157
|
+
workflow: event.payload.workflow ? createWorkflowRun(event.payload.workflow, event.at) : undefined,
|
|
134
158
|
};
|
|
135
159
|
next.loopsById[id] = loop;
|
|
136
160
|
return {
|
|
@@ -190,6 +214,34 @@ export function reduceLoopState(state: LoopReducerState, event: LoopReducerEvent
|
|
|
190
214
|
loop.updatedAt = event.at;
|
|
191
215
|
}
|
|
192
216
|
|
|
217
|
+
if (event.type === "LOOP_WORKFLOW_TRANSITION") {
|
|
218
|
+
if (!loop.workflow) return { state, effects: [] };
|
|
219
|
+
const result = transitionWorkflowRun(loop.workflow, {
|
|
220
|
+
outcome: event.payload.outcome,
|
|
221
|
+
evidence: event.payload.evidence,
|
|
222
|
+
activeTaskId: event.payload.activeTaskId,
|
|
223
|
+
}, event.at);
|
|
224
|
+
if (!result.applied) return { state, effects: [] };
|
|
225
|
+
loop.workflow = result.run;
|
|
226
|
+
loop.dynamic = {
|
|
227
|
+
goal: loop.dynamic?.goal ?? loop.prompt,
|
|
228
|
+
state: result.run.currentState,
|
|
229
|
+
metrics: loop.dynamic?.metrics,
|
|
230
|
+
doneCriteria: loop.dynamic?.doneCriteria,
|
|
231
|
+
iteration: (loop.dynamic?.iteration ?? 0) + 1,
|
|
232
|
+
nextWakeAt: undefined,
|
|
233
|
+
awaitingUpdate: false,
|
|
234
|
+
lastUpdatedAt: event.at,
|
|
235
|
+
};
|
|
236
|
+
loop.updatedAt = event.at;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (event.type === "LOOP_WORKFLOW_TASK_SET") {
|
|
240
|
+
if (!loop.workflow) return { state, effects: [] };
|
|
241
|
+
loop.workflow = { ...loop.workflow, activeTaskId: event.payload.taskId };
|
|
242
|
+
loop.updatedAt = event.at;
|
|
243
|
+
}
|
|
244
|
+
|
|
193
245
|
next.loopsById[id] = loop;
|
|
194
246
|
return {
|
|
195
247
|
state: next,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DynamicLoopState } from "./types.js";
|
|
1
|
+
import type { DynamicLoopState, WorkflowRunState } from "./types.js";
|
|
2
2
|
|
|
3
3
|
type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
|
|
4
4
|
|
|
@@ -14,6 +14,7 @@ export interface ReducerNotification {
|
|
|
14
14
|
taskBacklog?: boolean;
|
|
15
15
|
readOnly?: boolean;
|
|
16
16
|
dynamic?: DynamicLoopState;
|
|
17
|
+
workflow?: WorkflowRunState;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export interface NotificationReducerState {
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type ReducerNotification,
|
|
13
13
|
reduceNotificationState,
|
|
14
14
|
} from "../notification-reducer.js";
|
|
15
|
-
import type { DynamicLoopState, Trigger } from "../types.js";
|
|
15
|
+
import type { DynamicLoopState, Trigger, WorkflowRunState } from "../types.js";
|
|
16
16
|
|
|
17
17
|
export interface LoopFireEvent {
|
|
18
18
|
loopId: string;
|
|
@@ -25,6 +25,7 @@ export interface LoopFireEvent {
|
|
|
25
25
|
autoTask?: boolean;
|
|
26
26
|
taskBacklog?: boolean;
|
|
27
27
|
dynamic?: DynamicLoopState;
|
|
28
|
+
workflow?: WorkflowRunState;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export interface PendingNotification extends LoopFireEvent {
|
|
@@ -106,6 +107,24 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
|
|
|
106
107
|
? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
|
|
107
108
|
: "";
|
|
108
109
|
|
|
110
|
+
if (data.workflow) {
|
|
111
|
+
const state = data.workflow.definition.states[data.workflow.currentState];
|
|
112
|
+
const outcomes = Object.keys(state?.on ?? {});
|
|
113
|
+
const lines = [
|
|
114
|
+
`[pi-loop] Loop #${loopId} fired (workflow).${constraint}`,
|
|
115
|
+
`Goal: ${data.prompt || data.workflow.definition.initialState}`,
|
|
116
|
+
`State: ${data.workflow.currentState}`,
|
|
117
|
+
];
|
|
118
|
+
if (state?.prompt) lines.push(`State instructions: ${state.prompt}`);
|
|
119
|
+
if (data.workflow.activeTaskId) lines.push(`Active task: #${data.workflow.activeTaskId}`);
|
|
120
|
+
if (outcomes.length > 0) lines.push(`Allowed outcomes: ${outcomes.join(", ")}`);
|
|
121
|
+
lines.push(
|
|
122
|
+
`Workflow lifecycle: Loop #${loopId} is an opt-in state controller. Do not call LoopDelete after this state.`,
|
|
123
|
+
"Before ending this turn, call WorkflowTransition exactly once with this workflow id and one allowed outcome. Include evidence for the branch decision. Terminal outcomes complete or pause the workflow automatically.",
|
|
124
|
+
);
|
|
125
|
+
return lines.join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
109
128
|
if (data.dynamic || (typeof data.trigger !== "string" && data.trigger?.type === "dynamic")) {
|
|
110
129
|
const dynamic = data.dynamic;
|
|
111
130
|
const lines = [
|
|
@@ -169,6 +188,7 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
|
|
|
169
188
|
autoTask: notification.autoTask,
|
|
170
189
|
taskBacklog: notification.taskBacklog,
|
|
171
190
|
dynamic: notification.dynamic,
|
|
191
|
+
workflow: notification.workflow,
|
|
172
192
|
timestamp: notification.timestamp,
|
|
173
193
|
},
|
|
174
194
|
}, {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { TaskEntry, TaskStatus } from "../task-types.js";
|
|
2
|
+
import type { TaskEntry, TaskStatus, TaskWorkflowLink } from "../task-types.js";
|
|
3
3
|
|
|
4
4
|
export type NativeTaskEventName =
|
|
5
5
|
| "tasks:created"
|
|
@@ -19,6 +19,7 @@ export interface NativeTaskEventPayload {
|
|
|
19
19
|
updatedAt: number;
|
|
20
20
|
completedAt?: number;
|
|
21
21
|
metadata?: Record<string, unknown>;
|
|
22
|
+
workflow?: TaskWorkflowLink;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export function emitNativeTaskEvent(
|
|
@@ -37,5 +38,6 @@ export function emitNativeTaskEvent(
|
|
|
37
38
|
updatedAt: entry.updatedAt,
|
|
38
39
|
completedAt: entry.completedAt,
|
|
39
40
|
metadata: entry.metadata,
|
|
41
|
+
workflow: entry.workflow,
|
|
40
42
|
} satisfies NativeTaskEventPayload);
|
|
41
43
|
}
|