Skip to content

Commit 350e7d9

Browse files
committed
feat(agent-core-v2): add env bindings for task timeout and print policy keys
1 parent 1403d96 commit 350e7d9

6 files changed

Lines changed: 131 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Add environment variable overrides for the background Bash task timeout and the print-mode background policy.

docs/configuration/config-files.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ Retries only apply to transient failures — connection errors, timeouts, HTTP 4
377377
| `print_wait_ceiling_s` | `integer` | `2147483` | In print mode (`pythinker -p`), the wall-clock ceiling (seconds) for the wait/steer loop when `print_background_mode` is `"drain"` or `"steer"` (the default is ~24.8 days — effectively unbounded). Has no effect outside print mode or when it is `"exit"` |
378378
| `print_max_turns` | `integer` | `100000` | In print mode (`pythinker -p`) with `print_background_mode = "steer"`, the maximum number of new turns that may be triggered by background-task completions, to keep the steering loop bounded (the default is effectively unbounded) |
379379

380-
`keep_alive_on_exit` can be overridden by the `PYTHINKER_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` environment variable, and `max_running_tasks` by `PYTHINKER_CODE_BACKGROUND_MAX_RUNNING_TASKS`; both take higher priority than `config.toml`.
380+
`keep_alive_on_exit` can be overridden by the `PYTHINKER_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` environment variable, `max_running_tasks` by `PYTHINKER_CODE_BACKGROUND_MAX_RUNNING_TASKS`, `bash_task_timeout_s` by `PYTHINKER_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S`, and `print_background_mode`, `print_wait_ceiling_s`, and `print_max_turns` by `PYTHINKER_CODE_BACKGROUND_PRINT_BACKGROUND_MODE`, `PYTHINKER_CODE_BACKGROUND_PRINT_WAIT_CEILING_S`, and `PYTHINKER_CODE_BACKGROUND_PRINT_MAX_TURNS`; all take higher priority than `config.toml`.
381381

382382
In print mode (`pythinker -p "<prompt>"`), Pythinker Code stays alive after the main agent's turn as long as background tasks are still pending: each completion is fed back to the main agent as a synthetic user message, steering it into a new turn (`print_background_mode = "steer"` by default), and the run exits once a turn ends with nothing pending. The loop is bounded by `print_wait_ceiling_s` and `print_max_turns`, both effectively unbounded by default. Background work is never killed by a wall-clock cap in print mode either: background `Bash` tasks default to no timeout (`bash_task_timeout_s = 0`), and subagents run without a timeout (`[subagent] timeout_ms` and `[dynamic_workflow] timeout_ms` both default to `0` unless explicitly set), so only the model itself stops a task. Set `print_background_mode` to `"drain"` to wait for tasks without feeding results back, or `"exit"` to end the run as soon as the main agent finishes.
383383

docs/configuration/env-vars.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ Switches that control the behavior of subsystems such as telemetry, background t
126126
| `PYTHINKER_CODE_PASSWORD` | Set a parallel auth credential for the `pythinker web` local server, valid alongside the bearer token; recommended when binding the server beyond loopback — see [Local server and API](../guides/server.md#authentication) | Any non-empty string; when unset, only the token is valid |
127127
| `PYTHINKER_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
128128
| `PYTHINKER_CODE_BACKGROUND_MAX_RUNNING_TASKS` | Cap on concurrently running background tasks; takes higher priority than `[background] max_running_tasks` in `config.toml` (unset means no cap) | Positive integer; invalid values are ignored |
129+
| `PYTHINKER_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S` | Default timeout (seconds) for background `Bash` tasks, also used to re-arm foreground commands moved to the background; takes higher priority than `[task] bash_task_timeout_s` (`0` means no timeout) | Non-negative integer; invalid values are ignored |
130+
| `PYTHINKER_CODE_BACKGROUND_PRINT_BACKGROUND_MODE` | What `pythinker -p` does while background tasks are still pending after the main turn; takes higher priority than `[task] print_background_mode` | `exit`, `drain`, or `steer`; invalid values are ignored |
131+
| `PYTHINKER_CODE_BACKGROUND_PRINT_WAIT_CEILING_S` | Wall-clock ceiling (seconds) for the print-mode drain/steer wait; takes higher priority than `[task] print_wait_ceiling_s` | Positive integer; invalid values are ignored |
132+
| `PYTHINKER_CODE_BACKGROUND_PRINT_MAX_TURNS` | Maximum number of new turns triggered by background-task completions in print mode; takes higher priority than `[task] print_max_turns` | Positive integer; invalid values are ignored |
129133
| `PYTHINKER_IMAGE_MAX_EDGE_PX` | Longest-edge ceiling (px) for image compression; takes higher priority than `[image] max_edge_px` in `config.toml` (default `2000`) | Positive integer; invalid values are ignored |
130134
| `PYTHINKER_IMAGE_READ_BYTE_BUDGET` | Per-image byte budget for model-initiated image reads (`ReadMediaFile` default reads); takes higher priority than `[image] read_byte_budget` in `config.toml` (default `262144`, i.e. 256 KB) | Positive integer; invalid values are ignored |
131135
| `PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | Unset (no default catalog; unset means only built-in entries are shown); accepts `http://`, `file://` URLs, and local paths |

packages/agent-core-v2/docs/config-manifest.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ enabled = false
5959
# env:
6060
# keep_alive_on_exit <- PYTHINKER_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT (custom parse)
6161
# max_running_tasks <- PYTHINKER_CODE_BACKGROUND_MAX_RUNNING_TASKS (custom parse)
62+
# bash_task_timeout_s <- PYTHINKER_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S (custom parse)
63+
# print_wait_ceiling_s <- PYTHINKER_CODE_BACKGROUND_PRINT_WAIT_CEILING_S (custom parse)
64+
# print_background_mode <- PYTHINKER_CODE_BACKGROUND_PRINT_BACKGROUND_MODE (custom parse)
65+
# print_max_turns <- PYTHINKER_CODE_BACKGROUND_PRINT_MAX_TURNS (custom parse)
6266
# ##########################################################################
6367

6468
[background]
@@ -432,6 +436,10 @@ timeout_ms = 7200000
432436
# env:
433437
# keep_alive_on_exit <- PYTHINKER_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT (custom parse)
434438
# max_running_tasks <- PYTHINKER_CODE_BACKGROUND_MAX_RUNNING_TASKS (custom parse)
439+
# bash_task_timeout_s <- PYTHINKER_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S (custom parse)
440+
# print_wait_ceiling_s <- PYTHINKER_CODE_BACKGROUND_PRINT_WAIT_CEILING_S (custom parse)
441+
# print_background_mode <- PYTHINKER_CODE_BACKGROUND_PRINT_BACKGROUND_MODE (custom parse)
442+
# print_max_turns <- PYTHINKER_CODE_BACKGROUND_PRINT_MAX_TURNS (custom parse)
435443
# ##########################################################################
436444

437445
[task]

packages/agent-core-v2/src/agent/task/configSection.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,37 @@ export function resolvePrintBackgroundMode(config: IConfigService): PrintBackgro
4545

4646
export const KEEP_ALIVE_ON_EXIT_ENV = 'PYTHINKER_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT';
4747
export const MAX_RUNNING_TASKS_ENV = 'PYTHINKER_CODE_BACKGROUND_MAX_RUNNING_TASKS';
48+
export const BASH_TASK_TIMEOUT_S_ENV = 'PYTHINKER_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S';
49+
export const PRINT_WAIT_CEILING_S_ENV = 'PYTHINKER_CODE_BACKGROUND_PRINT_WAIT_CEILING_S';
50+
export const PRINT_BACKGROUND_MODE_ENV = 'PYTHINKER_CODE_BACKGROUND_PRINT_BACKGROUND_MODE';
51+
export const PRINT_MAX_TURNS_ENV = 'PYTHINKER_CODE_BACKGROUND_PRINT_MAX_TURNS';
4852

4953
function parsePositiveInt(raw: string): number | undefined {
5054
const value = raw.trim();
5155
if (value.length === 0 || !/^\d+$/.test(value)) return undefined;
5256
const parsed = Number(value);
53-
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
57+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
58+
}
59+
60+
function parseNonNegativeInt(raw: string): number | undefined {
61+
const value = raw.trim();
62+
if (value.length === 0 || !/^\d+$/.test(value)) return undefined;
63+
const parsed = Number(value);
64+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
65+
}
66+
67+
function parsePrintBackgroundMode(raw: string): PrintBackgroundMode | undefined {
68+
const parsed = PrintBackgroundModeSchema.safeParse(raw.trim());
69+
return parsed.success ? parsed.data : undefined;
5470
}
5571

5672
export const taskEnvBindings: EnvBindings<AgentTaskConfig> = envBindings(AgentTaskConfigSchema, {
5773
keepAliveOnExit: { env: KEEP_ALIVE_ON_EXIT_ENV, parse: parseBooleanEnv },
5874
maxRunningTasks: { env: MAX_RUNNING_TASKS_ENV, parse: parsePositiveInt },
75+
bashTaskTimeoutS: { env: BASH_TASK_TIMEOUT_S_ENV, parse: parseNonNegativeInt },
76+
printWaitCeilingS: { env: PRINT_WAIT_CEILING_S_ENV, parse: parsePositiveInt },
77+
printBackgroundMode: { env: PRINT_BACKGROUND_MODE_ENV, parse: parsePrintBackgroundMode },
78+
printMaxTurns: { env: PRINT_MAX_TURNS_ENV, parse: parsePositiveInt },
5979
});
6080

6181
export const stripTaskEnv = stripEnvBoundFields(taskEnvBindings);

packages/agent-core-v2/test/app/config/config.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,12 @@ import {
6969
import '#/app/kosongConfig/envOverlay';
7070
import { type ThinkingConfig } from '#/kosong/model/thinking';
7171
import {
72+
BASH_TASK_TIMEOUT_S_ENV,
7273
KEEP_ALIVE_ON_EXIT_ENV,
7374
MAX_RUNNING_TASKS_ENV,
75+
PRINT_BACKGROUND_MODE_ENV,
76+
PRINT_MAX_TURNS_ENV,
77+
PRINT_WAIT_CEILING_S_ENV,
7478
resolveAgentTaskConfig,
7579
resolvePrintBackgroundMode,
7680
type AgentTaskConfig,
@@ -1549,6 +1553,81 @@ describe('task config section', () => {
15491553
env[KEEP_ALIVE_ON_EXIT_ENV] = 'true';
15501554
expect(resolvePrintBackgroundMode(config)).toBe('drain');
15511555

1556+
disposables.dispose();
1557+
});
1558+
it('applies the bashTaskTimeoutS env binding, accepting 0 as no timeout', async () => {
1559+
const env: Record<string, string> = {};
1560+
const { config, disposables } = await createTaskConfig(env);
1561+
1562+
expect(config.get<AgentTaskConfig>('task')?.bashTaskTimeoutS).toBeUndefined();
1563+
1564+
env[BASH_TASK_TIMEOUT_S_ENV] = 'abc';
1565+
expect(config.get<AgentTaskConfig>('task')?.bashTaskTimeoutS).toBeUndefined();
1566+
env[BASH_TASK_TIMEOUT_S_ENV] = '-5';
1567+
expect(config.get<AgentTaskConfig>('task')?.bashTaskTimeoutS).toBeUndefined();
1568+
1569+
env[BASH_TASK_TIMEOUT_S_ENV] = '0';
1570+
expect(config.get<AgentTaskConfig>('task')?.bashTaskTimeoutS).toBe(0);
1571+
expect(config.get<AgentTaskConfig>('background')?.bashTaskTimeoutS).toBe(0);
1572+
1573+
env[BASH_TASK_TIMEOUT_S_ENV] = '30';
1574+
expect(config.get<AgentTaskConfig>('task')?.bashTaskTimeoutS).toBe(30);
1575+
1576+
disposables.dispose();
1577+
});
1578+
1579+
it('applies the print policy env bindings and ignores invalid values', async () => {
1580+
const env: Record<string, string> = {};
1581+
const { config, disposables } = await createTaskConfig(env);
1582+
1583+
env[PRINT_WAIT_CEILING_S_ENV] = '0';
1584+
expect(config.get<AgentTaskConfig>('task')?.printWaitCeilingS).toBeUndefined();
1585+
env[PRINT_WAIT_CEILING_S_ENV] = '3600';
1586+
expect(config.get<AgentTaskConfig>('task')?.printWaitCeilingS).toBe(3600);
1587+
1588+
env[PRINT_MAX_TURNS_ENV] = 'abc';
1589+
expect(config.get<AgentTaskConfig>('task')?.printMaxTurns).toBeUndefined();
1590+
env[PRINT_MAX_TURNS_ENV] = '7';
1591+
expect(config.get<AgentTaskConfig>('task')?.printMaxTurns).toBe(7);
1592+
1593+
env[PRINT_BACKGROUND_MODE_ENV] = 'wait';
1594+
expect(resolvePrintBackgroundMode(config)).toBe('steer');
1595+
env[PRINT_BACKGROUND_MODE_ENV] = 'exit';
1596+
expect(resolvePrintBackgroundMode(config)).toBe('exit');
1597+
env[PRINT_BACKGROUND_MODE_ENV] = ' drain ';
1598+
expect(resolvePrintBackgroundMode(config)).toBe('drain');
1599+
1600+
disposables.dispose();
1601+
});
1602+
1603+
it('lets the print policy env bindings override the config values', async () => {
1604+
const env: Record<string, string> = {
1605+
[PRINT_BACKGROUND_MODE_ENV]: 'exit',
1606+
[PRINT_WAIT_CEILING_S_ENV]: '3600',
1607+
};
1608+
const { config, disposables } = await createTaskConfig(
1609+
env,
1610+
'[task]\nprint_background_mode = "drain"\nprint_wait_ceiling_s = 60\n',
1611+
);
1612+
1613+
expect(resolvePrintBackgroundMode(config)).toBe('exit');
1614+
expect(resolveAgentTaskConfig(config)?.printWaitCeilingS).toBe(3600);
1615+
1616+
disposables.dispose();
1617+
});
1618+
1619+
it('ignores unsafe integers without discarding sibling env bindings', async () => {
1620+
const env: Record<string, string> = {
1621+
[BASH_TASK_TIMEOUT_S_ENV]: '9007199254740992',
1622+
[PRINT_WAIT_CEILING_S_ENV]: '9007199254740992',
1623+
[PRINT_BACKGROUND_MODE_ENV]: 'exit',
1624+
};
1625+
const { config, disposables } = await createTaskConfig(env);
1626+
1627+
expect(config.get<AgentTaskConfig>('task')?.bashTaskTimeoutS).toBeUndefined();
1628+
expect(config.get<AgentTaskConfig>('task')?.printWaitCeilingS).toBeUndefined();
1629+
expect(resolvePrintBackgroundMode(config)).toBe('exit');
1630+
15521631
disposables.dispose();
15531632
});
15541633
});
@@ -1666,6 +1745,19 @@ describe('applyPrintModeConfigDefaults', () => {
16661745

16671746
disposables.dispose();
16681747
});
1748+
it('does not override keys set via env bindings', async () => {
1749+
const { config, disposables } = await createConfig({
1750+
[BASH_TASK_TIMEOUT_S_ENV]: '30',
1751+
});
1752+
1753+
await applyPrintModeConfigDefaults(config);
1754+
1755+
expect(resolveAgentTaskConfig(config)?.bashTaskTimeoutS).toBe(30);
1756+
expect(config.inspect('task').memoryValue).toBeUndefined();
1757+
1758+
disposables.dispose();
1759+
});
1760+
16691761
});
16701762

16711763
describe('dynamic workflow config section', () => {

0 commit comments

Comments
 (0)