shellTool function
Creates the bash tool: executes a shell command via ExecutionEnv.exec
and returns stdout followed by stderr, truncated to the last
defaultToolMaxLines lines / defaultToolMaxBytes bytes. A non-zero
exit code, timeout, or abort throws (the loop turns it into an error
tool result, pi semantics).
With jobs (and an environment implementing BackgroundShell) the tool
gains two background behaviors:
background: truestarts the command detached and returns immediately with the job id; completion is reported back as a follow-up message.- Foreground runs still block, but a user steering message mid-run (the loop's soft-yield token, see currentYieldToken) moves the command into a background job WITHOUT killing it: the tool call answers with the job id and a partial-output tail, and the user message is delivered at the next step boundary.
Implementation
AgentTool shellTool(ExecutionEnv env, {ShellJobRegistry? jobs}) {
return AgentTool(
name: bashToolName,
label: 'bash',
tier: ApprovalTier.exec,
description:
'Execute a bash command in the current working directory. Returns '
'stdout and stderr. Output is truncated to the last '
'$defaultToolMaxLines lines or ${defaultToolMaxBytes ~/ 1024}KB '
'(whichever is hit first). Optionally provide a timeout in seconds. '
'For long-running commands (builds, servers, watchers) pass '
'background: true — the command keeps running as a job, you get its '
'id immediately and are notified when it finishes; check progress '
'with bash_job. A foreground command that is still running when the '
'user sends a message is moved to a background job untouched (never '
'killed) so the user gets an answer right away.',
parameters: const {
'type': 'object',
'properties': {
'command': {
'type': 'string',
'description': 'The bash command to execute',
},
'timeout': {
'type': 'number',
'description': 'Timeout in seconds (optional, no default timeout)',
},
'background': {
'type': 'boolean',
'description':
'Run detached and return the job id immediately (optional, '
'default false). Use for long-running commands.',
},
},
'required': ['command'],
},
execute: (arguments, cancelToken, onUpdate) async {
cancelToken?.throwIfCancelled();
final command = arguments['command'] as String;
final timeoutArg = arguments['timeout'] as num?;
final timeout = timeoutArg == null ? null : _resolveTimeout(timeoutArg);
final background = arguments['background'] as bool? ?? false;
final canJob = jobs != null && jobs.isSupported;
if (background) {
if (!canJob) {
return ToolExecutionResult.text(
'Background execution is not supported in this environment — '
'run the command in the foreground with an explicit timeout.',
);
}
final entry = await jobs.start(
command,
options: ShellExecOptions(
cwd: env.cwd,
timeout: timeout,
cancelToken: cancelToken,
),
);
return ToolExecutionResult.text(
'Started background job ${entry.id}.\n'
'Log: ${entry.logPath}\n'
'You will be notified when it finishes; check progress with '
'bash_job (action: status | output | stop).',
);
}
// Yield-aware foreground run: executed as a job from the start so a
// steering message can move it to the background mid-flight without
// killing the process. Settled before any yield → identical result to
// the classic inline path.
if (canJob && currentYieldToken() != null) {
return _shellViaJob(
env,
jobs,
command,
timeout: timeout,
timeoutArg: timeoutArg,
cancelToken: cancelToken,
yieldToken: currentYieldToken()!,
);
}
final result = await env.exec(
command,
options: ShellExecOptions(
cwd: env.cwd,
timeout: timeout,
cancelToken: cancelToken,
),
);
String outputOf(ShellExecResult execResult) {
final parts = <String>[
if (execResult.stdout.isNotEmpty) execResult.stdout,
if (execResult.stderr.isNotEmpty) execResult.stderr,
];
return parts.join('\n');
}
String truncate(String output) {
final truncation = _truncateTail(output);
if (!truncation.truncated) return output;
final startLine = truncation.totalLines - truncation.outputLines + 1;
final endLine = truncation.totalLines;
var notice =
'\n\n[Showing lines $startLine-$endLine of ${truncation.totalLines}';
if (truncation.truncatedBy == _TruncatedBy.bytes) {
notice += ' (${formatToolSize(defaultToolMaxBytes)} limit)';
}
return '${truncation.content}$notice.]';
}
if (result.isErr) {
final error = result.errorOrNull!;
throw switch (error.code) {
ExecutionErrorCode.aborted => StateError(
_appendStatus('', 'Command aborted'),
),
ExecutionErrorCode.timeout => StateError(
_appendStatus(
'',
'Command timed out after ${timeoutArg ?? 'unknown'} seconds',
),
),
_ => StateError('$error'),
};
}
final execResult = result.valueOrNull!;
final rawOutput = outputOf(execResult);
if (execResult.exitCode != 0) {
throw StateError(
_appendStatus(
truncate(rawOutput),
'Command exited with code ${execResult.exitCode}',
),
);
}
final output = truncate(rawOutput);
return ToolExecutionResult.text(output.isEmpty ? '(no output)' : output);
},
);
}