Skip to content

Commit c341981

Browse files
committed
Remove max turns cap, add TUI session header, fix write_file truncation
- Remove maxTurns entirely from Config, director, renderer, and all tests; agent now runs until submit_output is called - TUI header shows session title (truncated from initial task) and live latest-user-prompt row that updates as the agent runs - Detect malformed (_raw) tool call arguments in path-escape plugin and return an actionable error instead of a cryptic type failure - Add system prompt rule discouraging large single-file writes - Raise max_tokens to 16384 on both headless and TUI agent sources to prevent JSON argument truncation on large write_file payloads
1 parent 1d24988 commit c341981

15 files changed

Lines changed: 76 additions & 160 deletions

‎e2e/agent-loop.test.ts‎

Lines changed: 0 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ describe("agent loop", () => {
1313
const director = createCodingDirector(
1414
buildSystemPrompt(),
1515
[submitOutputDefinition],
16-
10,
1716
{
1817
turnsUsed: 0,
1918
submitCalled: false,
@@ -124,88 +123,12 @@ describe("agent loop", () => {
124123
}
125124
});
126125

127-
test("director aborts at max turns", async () => {
128-
const harness = setupHarness();
129-
try {
130-
const director = createCodingDirector(
131-
buildSystemPrompt(),
132-
[submitOutputDefinition],
133-
2,
134-
);
135-
136-
const capabilities = createCapabilities();
137-
138-
// Turn 1: empty text (no tools)
139-
const actions1 = await director.decide(
140-
{
141-
type: "inference.done",
142-
turn: {
143-
role: "assistant",
144-
model: "test",
145-
timestamp: 0,
146-
content: [{ type: "text", text: "Thinking..." }],
147-
},
148-
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, thinking: 0 },
149-
source: { id: "xai", model: "test" },
150-
},
151-
{
152-
turns: [],
153-
activeForks: [],
154-
pendingOperations: [],
155-
activeGates: [],
156-
tokenUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 },
157-
lastCycleUsage: null,
158-
lastCycleSource: null,
159-
sessionId: "test",
160-
},
161-
capabilities,
162-
);
163-
164-
// Default director replies and waits
165-
const arr1 = Array.isArray(actions1) ? actions1 : [actions1];
166-
expect(arr1.some((a) => a.type === "reply")).toBe(true);
167-
168-
// Turn 2: empty text again (reaches max turns)
169-
const actions2 = await director.decide(
170-
{
171-
type: "inference.done",
172-
turn: {
173-
role: "assistant",
174-
model: "test",
175-
timestamp: 0,
176-
content: [{ type: "text", text: "Still thinking..." }],
177-
},
178-
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, thinking: 0 },
179-
source: { id: "xai", model: "test" },
180-
},
181-
{
182-
turns: [],
183-
activeForks: [],
184-
pendingOperations: [],
185-
activeGates: [],
186-
tokenUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 },
187-
lastCycleUsage: null,
188-
lastCycleSource: null,
189-
sessionId: "test",
190-
},
191-
capabilities,
192-
);
193-
194-
// Should reply because max turns reached
195-
const arr2 = Array.isArray(actions2) ? actions2 : [actions2];
196-
expect(arr2.some((a) => a.type === "reply")).toBe(true);
197-
} finally {
198-
harness.dispose();
199-
}
200-
});
201-
202126
test("director aborts after 3 idle cycles", async () => {
203127
const harness = setupHarness();
204128
try {
205129
const director = createCodingDirector(
206130
buildSystemPrompt(),
207131
[submitOutputDefinition],
208-
10,
209132
);
210133

211134
const capabilities = createCapabilities();
@@ -271,7 +194,6 @@ describe("agent loop", () => {
271194
const director = createCodingDirector(
272195
buildSystemPrompt(),
273196
[submitPlanDefinition, submitOutputDefinition],
274-
10,
275197
);
276198

277199
const capabilities = createCapabilities();
@@ -335,7 +257,6 @@ describe("agent loop", () => {
335257
const director = createCodingDirector(
336258
buildSystemPrompt(),
337259
[submitPlanDefinition, submitOutputDefinition],
338-
10,
339260
);
340261

341262
const capabilities = createCapabilities();

‎src/config.test.ts‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,27 +60,19 @@ describe("loadConfig", () => {
6060
expect(config.baseURL).toBe("https://api.fireworks.ai/inference");
6161
expect(config.model).toBe("accounts/fireworks/routers/kimi-k2p6-turbo");
6262
expect(config.providerName).toBe("fireworks");
63-
expect(config.maxTurns).toBe(30);
6463
expect(config.cwd).toBe(process.cwd());
6564
expect(config.force).toBe(false);
6665
} finally {
6766
restoreEnv(stash);
6867
}
6968
});
7069

71-
test("parses --cwd and --max-turns", () => {
70+
test("parses --cwd", () => {
7271
const stash = stashEnv();
7372
try {
7473
setRequiredEnv();
75-
const config = loadConfig([
76-
"--cwd",
77-
"/tmp/test",
78-
"--max-turns",
79-
"15",
80-
"fix bug",
81-
]);
74+
const config = loadConfig(["--cwd", "/tmp/test", "fix bug"]);
8275
expect(config.cwd).toBe("/tmp/test");
83-
expect(config.maxTurns).toBe(15);
8476
expect(config.task).toBe("fix bug");
8577
expect(config.force).toBe(false);
8678
} finally {

‎src/config.ts‎

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,11 @@ export type Config = {
66
model: string;
77
providerName: string;
88
cwd: string;
9-
maxTurns: number;
109
task: string;
1110
force: boolean;
1211
headless: boolean;
1312
};
1413

15-
const DEFAULT_MAX_TURNS = 30;
16-
1714
function requireEnv(name: string): string {
1815
const value = process.env[name];
1916
if (value === undefined || value.length === 0) {
@@ -26,7 +23,6 @@ export function loadConfig(argv: readonly string[]): Config {
2623
const args = [...argv];
2724

2825
let cwd = process.cwd();
29-
let maxTurns = DEFAULT_MAX_TURNS;
3026
let force = false;
3127
let headless = false;
3228
const positional: string[] = [];
@@ -42,18 +38,6 @@ export function loadConfig(argv: readonly string[]): Config {
4238
cwd = resolve(next);
4339
continue;
4440
}
45-
if (arg === "--max-turns") {
46-
const next = args[++i];
47-
if (next === undefined) {
48-
throw new Error("--max-turns requires a number");
49-
}
50-
const parsed = Number(next);
51-
if (!Number.isFinite(parsed) || parsed < 1) {
52-
throw new Error(`invalid --max-turns: ${next}`);
53-
}
54-
maxTurns = parsed;
55-
continue;
56-
}
5741
if (arg === "--force") {
5842
force = true;
5943
continue;
@@ -81,7 +65,6 @@ export function loadConfig(argv: readonly string[]): Config {
8165
model,
8266
providerName,
8367
cwd,
84-
maxTurns,
8568
task,
8669
force,
8770
headless,

‎src/director.test.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,28 +49,28 @@ function actionsArray(result: ReactorAction | ReactorAction[]): ReactorAction[]
4949

5050
describe("CL-820: filesRead tracking", () => {
5151
test("filesRead starts empty on new director", () => {
52-
const director = createCodingDirector("", [], 30);
52+
const director = createCodingDirector("", []);
5353
expect(director.getState().filesRead).toEqual([]);
5454
});
5555

5656
test("reading a file adds its path to filesRead", async () => {
57-
const director = createCodingDirector("", [], 30);
57+
const director = createCodingDirector("", []);
5858
const callId = "call-r1";
5959
await director.decide(makeInferenceDoneEvent([{ id: callId, name: "read_file", args: { path: "src/foo.ts" } }]), mockState, mockCapabilities);
6060
await director.decide(makeToolDoneEvent(callId), mockState, mockCapabilities);
6161
expect(director.getState().filesRead!.some((e) => e.path === "src/foo.ts")).toBe(true);
6262
});
6363

6464
test("list_dir does not add to filesRead", async () => {
65-
const director = createCodingDirector("", [], 30);
65+
const director = createCodingDirector("", []);
6666
const callId = "call-ld";
6767
await director.decide(makeInferenceDoneEvent([{ id: callId, name: "list_dir", args: { path: "src/" } }]), mockState, mockCapabilities);
6868
await director.decide(makeToolDoneEvent(callId), mockState, mockCapabilities);
6969
expect(director.getState().filesRead).toEqual([]);
7070
});
7171

7272
test("filesRead is restored on setState (simulates resume)", () => {
73-
const director = createCodingDirector("", [], 30);
73+
const director = createCodingDirector("", []);
7474
director.setState({
7575
turnsUsed: 2,
7676
submitCalled: false,
@@ -84,12 +84,12 @@ describe("CL-820: filesRead tracking", () => {
8484
});
8585

8686
test("filesRead is empty on fresh director (not restored from prior state)", () => {
87-
const director = createCodingDirector("", [], 30);
87+
const director = createCodingDirector("", []);
8888
expect(director.getState().filesRead).toEqual([]);
8989
});
9090

9191
test("getFilesReadAtTurn exposes path-to-turn map", async () => {
92-
const director = createCodingDirector("", [], 30);
92+
const director = createCodingDirector("", []);
9393
const callId = "call-r2";
9494
await director.decide(makeInferenceDoneEvent([{ id: callId, name: "read_file", args: { path: "src/bar.ts" } }]), mockState, mockCapabilities);
9595
await director.decide(makeToolDoneEvent(callId), mockState, mockCapabilities);
@@ -99,13 +99,13 @@ describe("CL-820: filesRead tracking", () => {
9999

100100
describe("CL-822: consecutive-reads cap removed", () => {
101101
test("consecutiveReads field is absent from persisted state", () => {
102-
const director = createCodingDirector("", [], 30);
102+
const director = createCodingDirector("", []);
103103
const state = director.getState();
104104
expect("consecutiveReads" in state).toBe(false);
105105
});
106106

107107
test("director does not abort after 8 consecutive read_file calls", async () => {
108-
const director = createCodingDirector("", [], 30);
108+
const director = createCodingDirector("", []);
109109

110110
for (let i = 1; i <= 8; i++) {
111111
const callId = `call-${i}`;

‎src/director.ts‎

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ function isValidPlanArgs(args: unknown): args is { steps: PlanStep[] } {
8181
class CodingDirectorImpl extends DefaultDirector implements CodingDirector {
8282
private submitCalled = false;
8383
private _turnsUsed = 0;
84-
private readonly maxTurns: number;
8584
private readonly callIdToName = new Map<string, string>();
8685
private readonly callIdToArgs = new Map<string, unknown>();
8786
private readonly filesReadAtTurn = new Map<string, number>();
@@ -92,11 +91,9 @@ class CodingDirectorImpl extends DefaultDirector implements CodingDirector {
9291
constructor(
9392
systemPrompt: string,
9493
toolDefinitions: ToolDefinition[],
95-
maxTurns: number,
9694
initialState?: DirectorPersistedState,
9795
) {
9896
super(systemPrompt, toolDefinitions, {});
99-
this.maxTurns = maxTurns;
10097
if (initialState !== undefined) {
10198
this.setState(initialState);
10299
}
@@ -149,12 +146,6 @@ class CodingDirectorImpl extends DefaultDirector implements CodingDirector {
149146
];
150147
}
151148

152-
if (this._turnsUsed >= this.maxTurns) {
153-
return [
154-
capabilities.checkpoint("max-turns"),
155-
capabilities.reply(`Max turns (${this.maxTurns}) reached.`),
156-
];
157-
}
158149
}
159150

160151
if (event.type === "tool.done") {
@@ -220,10 +211,9 @@ class CodingDirectorImpl extends DefaultDirector implements CodingDirector {
220211
export function createCodingDirector(
221212
systemPrompt: string,
222213
toolDefinitions: ToolDefinition[],
223-
maxTurns: number,
224214
initialState?: DirectorPersistedState,
225215
): CodingDirector {
226-
return new CodingDirectorImpl(systemPrompt, toolDefinitions, maxTurns, initialState);
216+
return new CodingDirectorImpl(systemPrompt, toolDefinitions, initialState);
227217
}
228218

229219
export function createChatDirector(

‎src/index.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ function printHelp(): void {
3737
console.log("Options:");
3838
console.log(" --headless, -h Run in headless CLI mode (default: TUI)");
3939
console.log(" --cwd <dir> Working directory (default: current directory)");
40-
console.log(" --max-turns <n> Maximum agent turns (default: 30)");
4140
console.log(" --force Override an existing run state");
4241
console.log(" --help Show this help message");
4342
console.log("");

‎src/plugins/path-escape-plugin.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ import type { ToolCall, ToolResult } from "@intx/types/runtime";
55
export function pathEscapePlugin(cwd: string): ToolPlugin {
66
return {
77
middleware: (next) => async (call, signal) => {
8+
if ("_raw" in call.arguments) {
9+
return {
10+
callId: call.id,
11+
content: "Tool call arguments were malformed JSON (likely truncated). Retry with a smaller payload.",
12+
isError: true,
13+
};
14+
}
815
let escaped: Record<string, unknown>;
916
try {
1017
escaped = escapeArgs(call.arguments, cwd);

‎src/prompts.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export function buildSystemPrompt(): string {
88
"4. You MUST call submit_output when the task is fully complete. No other action signals completion.",
99
"5. If tests are failing, you MUST NOT submit. Fix the tests first.",
1010
"6. Do not re-read a file you already read. The tool will return an error if you try.",
11+
"7. Never write large files in a single write_file call. If a file exceeds ~200 lines, write it in sections using run_shell (printf or cat heredoc) or break the work into edit_file calls on an existing scaffold.",
1112
"",
1213
"Available tools: read_file, write_file, edit_file, run_shell, search_files, grep, list_dir, submit_plan, submit_output.",
1314
"",

0 commit comments

Comments
 (0)