Skip to content

Commit 1870f65

Browse files
authored
Merge pull request #39 from asynkron/codex/ensure-.openagent-folder-and-manage-plans
Persist merged plan snapshot for agent runtime
2 parents 98af175 + 135855e commit 1870f65

File tree

7 files changed

+370
-4
lines changed

7 files changed

+370
-4
lines changed

.openagent/todo.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Active Plan
2+
3+
_No active plan._

src/agent/context.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
2. `approvalManager.js` determines whether a proposed command can run automatically or needs a human decision.
2626
3. `commandExecution.js` executes built-ins before shell commands and returns structured execution metadata.
2727
- After every pass, `observationBuilder.js` converts command output into both CLI previews and history observations so the next model call has the right context.
28+
- `loop.js` maintains an active plan manager that merges partial LLM plan updates, emits the merged outline to UIs, and writes a snapshot to `.openagent/todo.md` at repo root so humans can inspect the current plan.
2829
- Integration suites mock `openaiRequest.js` to enqueue deterministic completions, reflecting the module boundaries introduced by this architecture.
2930

3031
## Positive Signals

src/agent/loop.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
* writing directly to the CLI.
44
*/
55

6+
import { mkdir, writeFile } from 'node:fs/promises';
7+
import { resolve } from 'node:path';
8+
69
import { SYSTEM_PROMPT } from '../config/systemPrompt.js';
710
import { getOpenAIClient, MODEL } from '../openai/client.js';
811
import {
@@ -29,6 +32,7 @@ import { createEscState } from './escState.js';
2932
import { AsyncQueue, QUEUE_DONE } from '../utils/asyncQueue.js';
3033
import { cancel as cancelActive } from '../utils/cancellation.js';
3134
import { PromptCoordinator } from './promptCoordinator.js';
35+
import { mergePlanTrees, planToMarkdown } from '../utils/plan.js';
3236

3337
const NO_HUMAN_AUTO_MESSAGE = "continue or say 'done'";
3438
const PLAN_PENDING_REMINDER =
@@ -60,6 +64,47 @@ export function createAgentRuntime({
6064
const outputs = new AsyncQueue();
6165
const inputs = new AsyncQueue();
6266

67+
const planDirectoryPath = resolve(process.cwd(), '.openagent');
68+
const planFilePath = resolve(planDirectoryPath, 'todo.md');
69+
let activePlan = [];
70+
71+
const persistPlanSnapshot = async () => {
72+
try {
73+
await mkdir(planDirectoryPath, { recursive: true });
74+
const snapshot = planToMarkdown(activePlan);
75+
await writeFile(planFilePath, snapshot, 'utf8');
76+
} catch (error) {
77+
outputs.push({
78+
type: 'status',
79+
level: 'warn',
80+
message: 'Failed to persist plan snapshot to .openagent/todo.md.',
81+
details: error instanceof Error ? error.message : String(error),
82+
});
83+
}
84+
};
85+
86+
const planManager = {
87+
get() {
88+
return mergePlanTrees([], activePlan);
89+
},
90+
async update(nextPlan) {
91+
if (!Array.isArray(nextPlan) || nextPlan.length === 0) {
92+
activePlan = [];
93+
} else if (activePlan.length === 0) {
94+
activePlan = mergePlanTrees([], nextPlan);
95+
} else {
96+
activePlan = mergePlanTrees(activePlan, nextPlan);
97+
}
98+
99+
await persistPlanSnapshot();
100+
return mergePlanTrees([], activePlan);
101+
},
102+
async initialize() {
103+
await persistPlanSnapshot();
104+
return mergePlanTrees([], activePlan);
105+
},
106+
};
107+
63108
const { state: escState, trigger: triggerEsc, detach: detachEscListener } = createEscState();
64109
const promptCoordinator = new PromptCoordinator({
65110
emitEvent: (event) => outputs.push(event),
@@ -142,6 +187,8 @@ export function createAgentRuntime({
142187
running = true;
143188
inputProcessorPromise = processInputEvents();
144189

190+
await planManager.initialize();
191+
145192
emit({ type: 'banner', title: 'OpenAgent - AI Agent with JSON Protocol' });
146193
emit({ type: 'status', level: 'info', message: 'Submit prompts to drive the conversation.' });
147194
if (getAutoApproveFlag()) {
@@ -214,6 +261,7 @@ export function createAgentRuntime({
214261
escState,
215262
approvalManager,
216263
historyCompactor,
264+
planManager,
217265
});
218266

219267
continueLoop = shouldContinue;

src/agent/passExecutor.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export async function executeAgentPass({
2929
escState,
3030
approvalManager,
3131
historyCompactor,
32+
planManager,
3233
}) {
3334
const observationBuilder = new ObservationBuilder({
3435
combineStdStreams,
@@ -123,7 +124,38 @@ export async function executeAgentPass({
123124
}
124125

125126
emitEvent({ type: 'assistant-message', message: parsed.message ?? '' });
126-
emitEvent({ type: 'plan', plan: Array.isArray(parsed.plan) ? parsed.plan : [] });
127+
128+
const incomingPlan = Array.isArray(parsed.plan) ? parsed.plan : null;
129+
let activePlan = incomingPlan ?? [];
130+
131+
if (planManager) {
132+
try {
133+
if (incomingPlan && typeof planManager.update === 'function') {
134+
const merged = await planManager.update(incomingPlan);
135+
if (Array.isArray(merged)) {
136+
activePlan = merged;
137+
}
138+
} else if (!incomingPlan && typeof planManager.get === 'function') {
139+
const snapshot = planManager.get();
140+
if (Array.isArray(snapshot)) {
141+
activePlan = snapshot;
142+
}
143+
}
144+
} catch (error) {
145+
emitEvent({
146+
type: 'status',
147+
level: 'warn',
148+
message: 'Failed to update persistent plan state.',
149+
details: error instanceof Error ? error.message : String(error),
150+
});
151+
}
152+
}
153+
154+
if (!Array.isArray(activePlan)) {
155+
activePlan = incomingPlan ?? [];
156+
}
157+
158+
emitEvent({ type: 'plan', plan: activePlan });
127159

128160
if (!parsed.command) {
129161
if (
@@ -139,7 +171,7 @@ export async function executeAgentPass({
139171
}
140172
}
141173

142-
if (Array.isArray(parsed.plan) && planHasOpenSteps(parsed.plan)) {
174+
if (Array.isArray(activePlan) && planHasOpenSteps(activePlan)) {
143175
emitEvent({
144176
type: 'status',
145177
level: 'warn',

src/utils/context.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
- `asyncQueue.js`: exposes the `AsyncQueue` class (with a compatibility factory) used to shuttle events between the agent loop and UIs.
1010
- `cancellation.js`: stack-based cancellation manager enabling ESC-triggered aborts and nested operations.
1111
- `output.js`: merges stdout/stderr and provides preview builders used when rendering command results.
12-
- `plan.js`: supplies plan inspection helpers (e.g., `planHasOpenSteps`).
12+
- `plan.js`: supplies plan inspection helpers (e.g., `planHasOpenSteps`) plus merge/serialization utilities that keep the active plan snapshot in sync with `.openagent/todo.md`.
1313
- `text.js`: regex filtering, tailing, truncation, and lightweight shell argument splitting.
1414
- `contextUsage.js`: estimates token usage/remaining context for the current conversation history.
1515
- `jsonAssetValidator.js`: shared helpers for JSON schema validation and prompt copy synchronization checks.

src/utils/plan.js

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,139 @@
22
* Plan utilities extracted from the agent loop.
33
*/
44

5+
const PLAN_CHILD_KEYS = ['substeps', 'children', 'steps'];
6+
7+
function normalizeStepLabel(stepValue) {
8+
if (stepValue === null || stepValue === undefined) {
9+
return '';
10+
}
11+
12+
const raw = String(stepValue).trim();
13+
if (!raw) {
14+
return '';
15+
}
16+
17+
return raw.replace(/\.+$/, '');
18+
}
19+
20+
function createPlanKey(item, fallbackIndex) {
21+
if (!item || typeof item !== 'object') {
22+
return `index:${fallbackIndex}`;
23+
}
24+
25+
const label = normalizeStepLabel(item.step);
26+
if (label) {
27+
return `step:${label.toLowerCase()}`;
28+
}
29+
30+
if (typeof item.title === 'string' && item.title.trim().length > 0) {
31+
return `title:${item.title.trim().toLowerCase()}`;
32+
}
33+
34+
return `index:${fallbackIndex}`;
35+
}
36+
37+
function clonePlanItem(item) {
38+
if (!item || typeof item !== 'object') {
39+
return {};
40+
}
41+
42+
const cloned = { ...item };
43+
44+
for (const key of PLAN_CHILD_KEYS) {
45+
if (Array.isArray(item[key])) {
46+
cloned[key] = item[key].map((child) => clonePlanItem(child));
47+
} else if (cloned[key] && !Array.isArray(cloned[key])) {
48+
delete cloned[key];
49+
}
50+
}
51+
52+
return cloned;
53+
}
54+
55+
function selectChildKey(existingItem, incomingItem) {
56+
for (const key of PLAN_CHILD_KEYS) {
57+
if (Array.isArray(incomingItem?.[key])) {
58+
return key;
59+
}
60+
}
61+
62+
for (const key of PLAN_CHILD_KEYS) {
63+
if (Array.isArray(existingItem?.[key])) {
64+
return key;
65+
}
66+
}
67+
68+
return null;
69+
}
70+
71+
function mergePlanItems(existingItem, incomingItem) {
72+
if (!incomingItem || typeof incomingItem !== 'object') {
73+
return clonePlanItem(existingItem);
74+
}
75+
76+
if (!existingItem || typeof existingItem !== 'object') {
77+
return clonePlanItem(incomingItem);
78+
}
79+
80+
const base = clonePlanItem(existingItem);
81+
const incoming = clonePlanItem(incomingItem);
82+
const merged = { ...base, ...incoming };
83+
84+
const childKey = selectChildKey(existingItem, incomingItem);
85+
if (childKey) {
86+
const existingChildren = Array.isArray(existingItem[childKey]) ? existingItem[childKey] : [];
87+
const incomingChildren = Array.isArray(incomingItem[childKey]) ? incomingItem[childKey] : [];
88+
merged[childKey] = mergePlanTrees(existingChildren, incomingChildren);
89+
90+
for (const key of PLAN_CHILD_KEYS) {
91+
if (key !== childKey && key in merged) {
92+
delete merged[key];
93+
}
94+
}
95+
}
96+
97+
return merged;
98+
}
99+
100+
export function mergePlanTrees(existingPlan = [], incomingPlan = []) {
101+
const existing = Array.isArray(existingPlan) ? existingPlan : [];
102+
const incoming = Array.isArray(incomingPlan) ? incomingPlan : [];
103+
104+
if (incoming.length === 0) {
105+
return [];
106+
}
107+
108+
const existingIndex = new Map();
109+
existing.forEach((item, index) => {
110+
existingIndex.set(createPlanKey(item, index), { item, index });
111+
});
112+
113+
const usedKeys = new Set();
114+
const result = [];
115+
116+
incoming.forEach((item, index) => {
117+
const key = createPlanKey(item, index);
118+
const existingMatch = existingIndex.get(key);
119+
120+
if (existingMatch) {
121+
result.push(mergePlanItems(existingMatch.item, item));
122+
usedKeys.add(key);
123+
} else {
124+
result.push(clonePlanItem(item));
125+
}
126+
});
127+
128+
existing.forEach((item, index) => {
129+
const key = createPlanKey(item, index);
130+
if (!usedKeys.has(key)) {
131+
result.push(clonePlanItem(item));
132+
}
133+
});
134+
135+
return result;
136+
}
137+
5138
export function planHasOpenSteps(plan) {
6139
const hasOpen = (items) => {
7140
if (!Array.isArray(items)) {
@@ -16,7 +149,8 @@ export function planHasOpenSteps(plan) {
16149
const normalizedStatus =
17150
typeof item.status === 'string' ? item.status.trim().toLowerCase() : '';
18151

19-
if (Array.isArray(item.substeps) && hasOpen(item.substeps)) {
152+
const childKey = PLAN_CHILD_KEYS.find((key) => Array.isArray(item[key]));
153+
if (childKey && hasOpen(item[childKey])) {
20154
return true;
21155
}
22156

@@ -31,6 +165,75 @@ export function planHasOpenSteps(plan) {
31165
return hasOpen(plan);
32166
}
33167

168+
function formatPlanLine(item, index, ancestors, depth, lines) {
169+
if (!item || typeof item !== 'object') {
170+
return;
171+
}
172+
173+
const sanitizedStep = normalizeStepLabel(item.step);
174+
const hasExplicitStep = sanitizedStep.length > 0;
175+
const labelParts = hasExplicitStep
176+
? sanitizedStep.split('.').filter((part) => part.length > 0)
177+
: [...ancestors, String(index + 1)];
178+
179+
const stepLabel = labelParts.join('.');
180+
const indent = ' '.repeat(depth);
181+
const title = typeof item.title === 'string' && item.title.trim().length > 0 ? item.title.trim() : '';
182+
const status = typeof item.status === 'string' && item.status.trim().length > 0 ? item.status.trim() : '';
183+
184+
const lineParts = [];
185+
if (stepLabel) {
186+
lineParts.push(`Step ${stepLabel}`);
187+
}
188+
if (title) {
189+
lineParts.push(`- ${title}`);
190+
}
191+
if (status) {
192+
lineParts.push(`[${status}]`);
193+
}
194+
195+
if (lineParts.length === 0) {
196+
return;
197+
}
198+
199+
lines.push(`${indent}${lineParts.join(' ')}`.trimEnd());
200+
201+
const childKey = PLAN_CHILD_KEYS.find((key) => Array.isArray(item[key]));
202+
if (childKey) {
203+
const nextAncestors = hasExplicitStep ? labelParts : [...ancestors, String(index + 1)];
204+
formatPlanSection(item[childKey], nextAncestors, depth + 1, lines);
205+
}
206+
}
207+
208+
function formatPlanSection(items, ancestors, depth, lines) {
209+
if (!Array.isArray(items) || items.length === 0) {
210+
return;
211+
}
212+
213+
items.forEach((item, index) => {
214+
formatPlanLine(item, index, ancestors, depth, lines);
215+
});
216+
}
217+
218+
export function planToMarkdown(plan) {
219+
const header = '# Active Plan\n\n';
220+
221+
if (!Array.isArray(plan) || plan.length === 0) {
222+
return `${header}_No active plan._\n`;
223+
}
224+
225+
const lines = [];
226+
formatPlanSection(plan, [], 0, lines);
227+
228+
if (lines.length === 0) {
229+
return `${header}_No active plan._\n`;
230+
}
231+
232+
return `${header}${lines.join('\n')}\n`;
233+
}
234+
34235
export default {
236+
mergePlanTrees,
35237
planHasOpenSteps,
238+
planToMarkdown,
36239
};

0 commit comments

Comments
 (0)