Browse Source

fix: allow ACP agents to run without timeout

alvinreal 1 month ago
parent
commit
d14a619fda

+ 1 - 1
docs/configuration.md

@@ -114,7 +114,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `acpAgents.<name>.orchestratorPrompt` | string | generated routing block | Optional exact routing block injected into the orchestrator prompt |
 | `acpAgents.<name>.wrapperModel` | string | fixer default | Cheap OpenCode model used by the wrapper subagent that calls `acp_run` |
 | `acpAgents.<name>.permissionMode` | string | `ask` | How ACP permission requests are handled: `ask`, `allow`, or `reject` |
-| `acpAgents.<name>.timeoutMs` | integer | `300000` | Timeout for a single ACP run in milliseconds |
+| `acpAgents.<name>.timeoutMs` | integer | `0` | Timeout for a single ACP run in milliseconds. `0` disables the timeout so external agents can run indefinitely. Finite values can be up to `2147483647`ms (~24.8 days) |
 | `disabled_agents` | string[] | `["observer"]` | Agent names to disable globally. Set to `[]` to enable Observer; this is global, not per-preset |
 | `autoUpdate` | boolean | `true` | Automatically install plugin updates in the background; set to `false` for notification-only mode |
 | `multiplexer.type` | string | `"none"` | Multiplexer mode: `auto`, `tmux`, `zellij`, or `none` |

+ 4 - 3
oh-my-opencode-slim.schema.json

@@ -515,10 +515,11 @@
             "pattern": "^[^/\\s]+\\/[^\\s]+$"
           },
           "timeoutMs": {
-            "default": 300000,
+            "default": 0,
+            "description": "Timeout for a single ACP run in milliseconds. Set to 0 to disable the timeout.",
             "type": "integer",
-            "minimum": 1000,
-            "maximum": 900000
+            "minimum": 0,
+            "maximum": 2147483647
           },
           "permissionMode": {
             "default": "ask",

+ 4 - 4
src/agents/custom.test.ts

@@ -133,7 +133,7 @@ describe('custom-agent creation', () => {
           command: 'claude-code-acp',
           args: [],
           env: {},
-          timeoutMs: 300000,
+          timeoutMs: 0,
           permissionMode: 'ask',
           description: 'Claude Code research via ACP',
           wrapperModel: 'openai/gpt-5.4-mini',
@@ -169,7 +169,7 @@ describe('custom-agent creation', () => {
             command: 'bridge-acp',
             args: [],
             env: {},
-            timeoutMs: 300000,
+            timeoutMs: 0,
             permissionMode: 'ask',
           },
         },
@@ -196,7 +196,7 @@ describe('custom-agent creation', () => {
           command: 'bridge-acp',
           args: [],
           env: {},
-          timeoutMs: 300000,
+          timeoutMs: 0,
           permissionMode: 'ask',
         },
       },
@@ -214,7 +214,7 @@ describe('custom-agent creation', () => {
           command: 'fixer-acp',
           args: [],
           env: {},
-          timeoutMs: 300000,
+          timeoutMs: 0,
           permissionMode: 'ask',
         },
       },

+ 40 - 0
src/agents/index.test.ts

@@ -772,6 +772,46 @@ describe('PluginConfigSchema custom-agent-only prompt fields', () => {
 
     expect(result.success).toBe(true);
   });
+
+  test('defaults ACP agent timeout to disabled', () => {
+    const result = PluginConfigSchema.safeParse({
+      acpAgents: {
+        research: {
+          command: 'research-acp',
+        },
+      },
+    });
+
+    expect(result.success).toBe(true);
+    if (!result.success) return;
+    expect(result.data.acpAgents?.research.timeoutMs).toBe(0);
+  });
+
+  test('accepts long ACP agent timeouts', () => {
+    const result = PluginConfigSchema.safeParse({
+      acpAgents: {
+        research: {
+          command: 'research-acp',
+          timeoutMs: 3_600_000,
+        },
+      },
+    });
+
+    expect(result.success).toBe(true);
+  });
+
+  test('rejects ACP agent timeouts above timer-safe range', () => {
+    const result = PluginConfigSchema.safeParse({
+      acpAgents: {
+        research: {
+          command: 'research-acp',
+          timeoutMs: 2_147_483_648,
+        },
+      },
+    });
+
+    expect(result.success).toBe(false);
+  });
 });
 
 describe('disabled_agents', () => {

+ 11 - 1
src/config/schema.ts

@@ -224,6 +224,8 @@ export type CompanionConfig = z.infer<typeof CompanionConfigSchema>;
 
 export const AcpAgentPermissionModeSchema = z.enum(['ask', 'allow', 'reject']);
 
+export const MAX_ACP_TIMEOUT_MS = 2_147_483_647;
+
 export const AcpAgentConfigSchema = z
   .object({
     command: z.string().min(1),
@@ -234,7 +236,15 @@ export const AcpAgentConfigSchema = z
     prompt: z.string().min(1).optional(),
     orchestratorPrompt: z.string().min(1).optional(),
     wrapperModel: ProviderModelIdSchema.optional(),
-    timeoutMs: z.number().int().min(1000).max(900000).default(300000),
+    timeoutMs: z
+      .number()
+      .int()
+      .min(0)
+      .max(MAX_ACP_TIMEOUT_MS)
+      .default(0)
+      .describe(
+        'Timeout for a single ACP run in milliseconds. Set to 0 to disable the timeout.',
+      ),
     permissionMode: AcpAgentPermissionModeSchema.default('ask'),
   })
   .strict();

+ 27 - 17
src/tools/acp-run.ts

@@ -1,7 +1,11 @@
 import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
 import { createInterface } from 'node:readline';
 import { type ToolDefinition, tool } from '@opencode-ai/plugin';
-import type { AcpAgentConfig, AcpAgentsConfig } from '../config';
+import {
+  type AcpAgentConfig,
+  type AcpAgentsConfig,
+  MAX_ACP_TIMEOUT_MS,
+} from '../config';
 
 const z = tool.schema;
 
@@ -279,10 +283,12 @@ export function createAcpRunTool(agents: AcpAgentsConfig = {}): ToolDefinition {
       timeout_ms: z
         .number()
         .int()
-        .min(1000)
-        .max(900000)
+        .min(0)
+        .max(MAX_ACP_TIMEOUT_MS)
         .optional()
-        .describe('Optional timeout override in milliseconds'),
+        .describe(
+          'Optional timeout override in milliseconds. Set to 0 to disable the timeout.',
+        ),
     },
     async execute(args, ctx) {
       if (ctx.agent !== args.agent) {
@@ -327,22 +333,26 @@ export function createAcpRunTool(agents: AcpAgentsConfig = {}): ToolDefinition {
       );
       const timeoutMs = args.timeout_ms ?? config.timeoutMs;
       let timer: ReturnType<typeof setTimeout> | undefined;
-      const timeout = new Promise<string>(
-        (_, reject) =>
-          (timer = setTimeout(
-            () =>
-              reject(
-                new Error(
-                  `ACP agent '${args.agent}' timed out after ${timeoutMs}ms`,
-                ),
-              ),
-            timeoutMs,
-          )),
-      );
+      const timeout =
+        timeoutMs > 0
+          ? new Promise<string>(
+              (_, reject) =>
+                (timer = setTimeout(
+                  () =>
+                    reject(
+                      new Error(
+                        `ACP agent '${args.agent}' timed out after ${timeoutMs}ms`,
+                      ),
+                    ),
+                  timeoutMs,
+                )),
+            )
+          : undefined;
       const abort = () => client.close();
       ctx.abort.addEventListener('abort', abort, { once: true });
       try {
-        return await Promise.race([client.run(args.prompt), timeout]);
+        const run = client.run(args.prompt);
+        return timeout ? await Promise.race([run, timeout]) : await run;
       } finally {
         if (timer) clearTimeout(timer);
         ctx.abort.removeEventListener('abort', abort);