diff --git a/examples/coding-agent/composition.md b/examples/coding-agent/composition.md
index c6c624f0fb..9896c44420 100644
--- a/examples/coding-agent/composition.md
+++ b/examples/coding-agent/composition.md
@@ -45,6 +45,8 @@ flowchart LR
cfg --> plugin_coding_tool_fs
plugin_coding_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"]
cfg --> plugin_coding_tool_fs_search
+ plugin_coding_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"]
+ cfg --> plugin_coding_timeout_policy
plugin_coding_spill_local["spill-local
@deepseek-ai/dsh-spill-local"]
cfg --> plugin_coding_spill_local
plugin_coding_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"]
@@ -68,6 +70,7 @@ flowchart LR
| `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
| `tool-fs` | `@deepseek-ai/dsh-tool-fs` |
| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` |
+| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` |
| `spill-local` | `@deepseek-ai/dsh-spill-local` |
| `spill-policy` | `@deepseek-ai/dsh-spill-policy` |
diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml
index 5861e6a602..c175bbaf97 100644
--- a/examples/coding-agent/cordis.yml
+++ b/examples/coding-agent/cordis.yml
@@ -127,6 +127,13 @@
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
+# The tool-call timeout enforcer: arms each declared ToolDefinition.timeoutMs
+# (the search tools above declare 30s) as a deadline on exec.signal. Without
+# it a declared budget is advisory and only the bash executor's own timeout
+# backstop applies.
+- id: timeout-policy
+ name: '@deepseek-ai/dsh-timeout-policy'
+
# Tool-output spill stack: a local backend that saves oversized tool text under
# a private session-scoped dir, and the tools/post-execute policy that replaces
# an over-budget plain-text result with a preview + the spill path (the model
diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts
index 2d9d0b63dd..b73c31bd1f 100644
--- a/packages/fs/tool-fs-search/src/search-core.ts
+++ b/packages/fs/tool-fs-search/src/search-core.ts
@@ -103,15 +103,26 @@ function classifyRunFailure(toolName: string, result: BashRunResult): SearchErro
}
/**
- * Acquire the COMPLETE raw stdout of a finished run. Untruncated stdout is used
- * as-is; truncated stdout is recovered from the executor's local raw spill file
- * only when the complete file fits within `rawOutputMaxBytes`. A missing spill
- * path or an over-cap file is a clear failure telling the model to narrow the
- * search — never a silently-partial parse.
+ * Acquire the COMPLETE raw stdout of a finished run, enforcing
+ * `rawOutputMaxBytes` on BOTH transports: inline executor text (an executor
+ * retaining more than this package's cap must not smuggle an over-cap parse
+ * through the untruncated path) and the executor's local raw spill file, read
+ * only when the complete file fits the cap. A missing spill path or over-cap
+ * output is a clear failure telling the model to narrow the search — never a
+ * silently-partial parse.
*/
async function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxBytes: number): Promise {
- if (!result.stdout.truncated) return result.stdout.text
const narrow = 'narrow pattern, path, or include and retry'
+ if (!result.stdout.truncated) {
+ const inlineBytes = Buffer.byteLength(result.stdout.text, 'utf8')
+ if (inlineBytes > rawOutputMaxBytes) {
+ throw new SearchError(
+ `${toolName} produced ${inlineBytes} bytes of raw output, over the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
+ 'SEARCH_RAW_OUTPUT_OVERFLOW',
+ )
+ }
+ return result.stdout.text
+ }
const spillPath = result.stdout.spillPath
if (spillPath === undefined) {
throw new SearchError(
diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts
index 6a539956a4..55b54555ae 100644
--- a/packages/fs/tool-fs-search/tests/tools.spec.ts
+++ b/packages/fs/tool-fs-search/tests/tools.spec.ts
@@ -392,6 +392,18 @@ describe('raw output acquisition', () => {
expect(text(result)).toContain('narrow pattern, path, or include')
})
+ it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when UNTRUNCATED inline stdout exceeds the cap', async () => {
+ // An executor retaining more inline than this package's cap (or a
+ // deployment lowering rawOutputMaxBytes below the bash retention) must not
+ // smuggle an over-cap parse through the untruncated path.
+ dir = await mkdtemp(join(tmpdir(), 'dsh-search-raw-'))
+ const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } })
+ bash.handler = () => runResult(`${'x'.repeat(64)}\n`)
+ const result = await call(ctx, 'grep', { pattern: 'x' })
+ expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
+ expect(text(result)).toContain('narrow pattern, path, or include')
+ })
+
it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has no spill path', async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-search-raw-'))
const { ctx, bash } = await setup()