Skip to content
Merged
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,9 +301,11 @@ All configuration is controlled via environment variables in the `docker run` co

**Optional — Agent:**

| Variable | Default | Description |
| ----------------------- | ------- | ---------------------------------- |
| `AGENT_RECURSION_LIMIT` | `1000` | Max graph execution steps per call |
| Variable | Default | Description |
| ------------------------------- | -------- | ---------------------------------------------- |
| `AGENT_RECURSION_LIMIT` | `1000` | Max graph execution steps per call |
| `AGENT_AUTO_CONTINUE_LIMIT` | `1000` | Max consecutive auto-continue attempts before circuit breaker triggers |
| `AGENT_NODE_TIMEOUT` | `600000` | Superstep timeout in milliseconds (default 10 minutes) |

**Optional — Persistence:**

Expand Down Expand Up @@ -489,7 +491,7 @@ Graceful shutdown flushes all buffered log entries to disk before process exit.
| | `openai.base_url` | `https://api.openai.com/v1` | API endpoint URL |
| | `openai.model` | `gpt-4o` | Model name |
| | `openai.credentials.apiKey` | _(empty)_ | API key for authentication |
| | `openai.temperature` | `0.7` | Sampling temperature (0–2) |
| | `openai.temperature` | `0.4` | Sampling temperature (0–2) |
| | `openai.maxTokens` | `4096` | Max output tokens |
| | `openai.rateLimit.requestsPerMinute` | `60` | Rate limit for API calls |
| `sandbox` | `paths` | `["memory/", "skills/", "src/", "/tmp"]` | Allowed filesystem paths |
Expand Down Expand Up @@ -526,6 +528,8 @@ Graceful shutdown flushes all buffered log entries to disk before process exit.
| `tui` | `name` | `madz` | TUI identifier in banner |
| | `cursorChar` | `█` | Cursor character |
| `agent` | `recursionLimit` | `1000` | Max graph execution steps per agent call |
| | `autoContinueLimit` | `1000` | Max consecutive auto-continue attempts before circuit breaker triggers |
| | `nodeTimeout` | `600000` | Superstep timeout in milliseconds (default 10 minutes) |
| `persistence` | `mode` | `memory` | Storage backend (`memory`, `sqlite`) |
| | `sqlite_path` | `memory/checkpoints.db` | SQLite checkpointer file path |

Expand Down
4 changes: 3 additions & 1 deletion config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ providers:
encoding: cl100k_base
credentials:
apiKey:
temperature: 0.7
temperature: 0.4
maxTokens: 4096
rateLimit:
requestsPerMinute: 120
Expand Down Expand Up @@ -70,6 +70,8 @@ tui:
cursorChar: "█"
agent:
recursionLimit: 1000
autoContinueLimit: 1000
nodeTimeout: 600000
persistence:
mode: memory
sqlite_path: memory/checkpoints.db
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ const agent = createReactAgent(
tools,
checkpointer,
config.agent?.recursionLimit ?? undefined,
config.agent?.nodeTimeout ?? 600000,
);

const sessionConfig = { configurable: { thread_id: sessionState.getThreadId() } };
Expand Down
8 changes: 6 additions & 2 deletions src/agent/react.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,20 @@ const CONTEXT_TOO_LONG_MESSAGE = "The conversation is too long. Please start a n
* @param {unknown[]} [tools=[]] - Optional array of LangChain tool definitions
* @param {import("@langchain/langgraph").BaseCheckpointSaver | null} [checkpointer=null] - Optional LangGraph checkpointer for persistent conversation memory
* @param {number} [recursionLimit] - Optional LangGraph recursion limit for the agent graph
* @param {number} [timeout] - Optional timeout in milliseconds for superstep execution (default: 600000 / 10 minutes)
* @returns {ReturnType<typeof createReactAgentGraph>} A compiled ReAct agent
*/
/* node:coverage ignore next */
export function createReactAgent(model, tools = [], checkpointer = null, recursionLimit = null) {
return createReactAgentGraph({
export function createReactAgent(model, tools = [], checkpointer = null, recursionLimit = null, timeout = 600000) {
const agent = createReactAgentGraph({
llm: model,
tools,
...(checkpointer && { checkpointer }),
...(recursionLimit !== null && { recursionLimit }),
});
// Set superstep timeout on the compiled graph to prevent nodes from hanging
agent.stepTimeout = timeout;
return agent;
}

/**
Expand Down
6 changes: 4 additions & 2 deletions src/config/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const _OpenaiProviderConfigSchema = z.object({
model: z.string().min(1),
encoding: z.string().optional(),
credentials: OpenAICredentialsSchema,
temperature: z.number().min(0).max(2).default(0.7),
temperature: z.number().min(0).max(2).default(0.4),
maxTokens: z.number().int().positive().default(4096),
rateLimit: RateLimitSchema.default({ requestsPerMinute: 60 }),
});
Expand Down Expand Up @@ -187,6 +187,8 @@ export const TuiSchema = z.object({

export const AgentSchema = z.object({
recursionLimit: z.number().int().positive().default(1000),
autoContinueLimit: z.number().int().positive().default(1000),
nodeTimeout: z.number().int().positive().default(600000),
});

// --- Persistence schemas ---
Expand Down Expand Up @@ -263,7 +265,7 @@ export const DEFAULT_CONFIG = {
redact: { paths: ["credentials.apiKey"] },
},
schedules: { maxConcurrent: 1, mode: "inprocess", syncOnInit: true, entries: [] },
agent: { recursionLimit: 1000 },
agent: { recursionLimit: 1000, autoContinueLimit: 1000, nodeTimeout: 600000 },
tui: { name: "madz", cursorChar: "\u2588" },
persistence: { mode: "memory", sqlite_path: "memory/checkpoints.db" },
};
66 changes: 62 additions & 4 deletions src/tui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export default function App({
const [isCompacting, setIsCompacting] = useState(false);
const scrollRef = useRef(null);
const isQuittingRef = useRef(false);
const autoContinueCountRef = useRef(0);
const isAutoContinuingRef = useRef(false);
const { exit } = useApp();
const exitRef = useRef(exit);
exitRef.current = exit;
Expand Down Expand Up @@ -335,6 +337,9 @@ export default function App({
let responseContent = committedContent;

// Auto-continue if the agent stalled with zero text output
// Circuit breaker: configurable limit (default 1000) of consecutive
// empty responses to prevent infinite loops when the model generates
// thinking but no text
if (!responseContent.trim() && !isQuittingRef.current) {
// Show tool results so the user knows work happened
if (lastToolCallDisplay) {
Expand All @@ -348,8 +353,28 @@ export default function App({
});
}

if (autoContinueCountRef.current >= (config?.agent?.autoContinueLimit ?? 1000)) {
// Circuit breaker: model is stuck in thinking-only loop
setStatusMessage("Model appears stuck — starting fresh.");
setMessages((prev) => {
const cloned = [...prev];
const last = cloned[cloned.length - 1];
if (last.role === "assistant" && last.streaming) {
last.streaming = false;
}
return cloned;
});
autoContinueCountRef.current = 0;
addMessage({
role: "system",
content: `I've tried to continue ${config?.agent?.autoContinueLimit ?? 1000} times with no text output. The model may be stuck in a reasoning loop. Please try a new conversation or rephrase your request.`,
});
return;
}

// Send a quiet continuation signal to the agent
setStatusMessage("Continuing...");
isAutoContinuingRef.current = true;
try {
await dispatchProvider(
"Please continue.",
Expand All @@ -358,15 +383,17 @@ export default function App({
if (isQuittingRef.current) return;
try {
if (event.type === "text") {
responseContent += event.text;
committedContent = (committedContent || "") + event.text;
setMessages((prev) => {
const cloned = [...prev];
const last = cloned[cloned.length - 1];
if (last.role === "assistant" && last.streaming) {
last.content = responseContent + "\u2588";
last.content = committedContent + "\u2588";
}
return cloned;
});
// Reset flag — text arrived, not stuck anymore
isAutoContinuingRef.current = false;
} else if (event.type === "tool_start") {
setMessages((prev) => {
const cloned = [...prev];
Expand Down Expand Up @@ -416,6 +443,9 @@ export default function App({
setStatusMessage("Done");
} catch (contErr) {
setStatusMessage(`Error continuing: ${contErr.message}`);
} finally {
isAutoContinuingRef.current = false;
autoContinueCountRef.current++;
}
}

Expand Down Expand Up @@ -630,6 +660,9 @@ export default function App({
let responseContent = committedContent;

// Auto-continue if the agent stalled with zero text output
// Circuit breaker: configurable limit (default 1000) of consecutive
// empty responses to prevent infinite loops when the model generates
// thinking but no text
if (!responseContent.trim() && !isQuittingRef.current) {
// Show tool results so the user knows work happened
if (lastToolCallDisplay) {
Expand All @@ -643,8 +676,28 @@ export default function App({
});
}

if (autoContinueCountRef.current >= (config?.agent?.autoContinueLimit ?? 1000)) {
// Circuit breaker: model is stuck in thinking-only loop
setStatusMessage("Model appears stuck — starting fresh.");
setMessages((prev) => {
const cloned = [...prev];
const last = cloned[cloned.length - 1];
if (last.role === "assistant" && last.streaming) {
last.streaming = false;
}
return cloned;
});
autoContinueCountRef.current = 0;
addMessage({
role: "system",
content: `I've tried to continue ${config?.agent?.autoContinueLimit ?? 1000} times with no text output. The model may be stuck in a reasoning loop. Please try a new conversation or rephrase your request.`,
});
return;
}

// Send a quiet continuation signal to the agent
setStatusMessage("Continuing...");
isAutoContinuingRef.current = true;
try {
await dispatchProvider(
"Please continue.",
Expand All @@ -653,15 +706,17 @@ export default function App({
if (isQuittingRef.current) return;
try {
if (event.type === "text") {
responseContent += event.text;
committedContent = (committedContent || "") + event.text;
setMessages((prev) => {
const cloned = [...prev];
const last = cloned[cloned.length - 1];
if (last.role === "assistant" && last.streaming) {
last.content = responseContent + "\u2588";
last.content = committedContent + "\u2588";
}
return cloned;
});
// Reset flag — text arrived, not stuck anymore
isAutoContinuingRef.current = false;
} else if (event.type === "tool_start") {
setMessages((prev) => {
const cloned = [...prev];
Expand Down Expand Up @@ -715,6 +770,9 @@ export default function App({
setStatusMessage("Received response");
} catch (contErr) {
setStatusMessage(`Error continuing: ${contErr.message}`);
} finally {
isAutoContinuingRef.current = false;
autoContinueCountRef.current++;
}
}

Expand Down