run method
Runs the agent toward goal under mode. Returns the model's final
summary text (also already written via AgentHandlers.writeLine), or
null if the agent was aborted/cancelled or hit the step cap without
concluding.
abort lets the host stop the run (Ctrl-C or an abort answer at a
prompt); the loop checks it at each safe point and bails out of a pending
model call, confirming first unless AgentAbort.isConfirmed.
Implementation
Future<String?> run(
String goal, {
required AgentMode mode,
AgentAbort? abort,
}) async {
final cancel = abort ?? AgentAbort();
final messages = <AiMessage>[
AiMessage.system(_systemPrompt(mode)),
AiMessage.user(goal),
];
// In plan mode, tracks the approval verdict once a plan is accepted.
PlanApproval? planApproval;
_lastCommandMutated = false;
_replanNeeded = false;
_announcedExecutor = false;
_runUsage = AiUsage.zero;
_runRequests = 0;
_runClock
..reset()
..start();
_frame(); // a horizontal rule opening the interaction
final aborted = style.blocked('ai: aborted.');
for (var step = 0; step < config.maxSteps; step++) {
if (await _aborted(cancel)) return _finish(aborted);
final phase = _phaseFor(mode: mode, planApproval: planApproval);
final model = config.modelFor(phase);
_maybeAnnounceExecutor(phase);
final AiResult result;
try {
// Race the model call against an abort so Ctrl-C during a slow request
// is responsive; the request itself is abandoned (its result ignored).
final chat = provider.chat(
messages: messages,
tools: _tools,
model: model,
);
final won = await Future.any<bool>([
chat.then((_) => true),
cancel.whenRequested.then((_) => false),
]);
if (!won) {
if (await _aborted(cancel)) return _finish(aborted);
}
result = await chat; // resolved (won) or user declined the abort
} on AiProviderException catch (e) {
return _finish('ai: provider error: ${e.message}');
}
if (result.usage != null) {
_runUsage += result.usage!;
_runRequests++;
}
final text = result.text?.trim();
messages.add(
AiMessage.assistant(text: result.text, toolCalls: result.toolCalls),
);
final styledText = (text == null || text.isEmpty)
? null
: (phase == AgentPhase.executing
? style.executing(text)
: style.planning(text));
if (!result.wantsTools) {
// Final answer for this turn: a blank line then the styled summary
// (same framing as _finish), then a preliminary stats line so the
// running token totals are visible before the continue/end prompt.
// Offer to keep chatting in the same context before closing.
handlers.writeLine('');
if (styledText != null) handlers.writeLine(styledText);
_writeStats();
final followUp = await _promptContinue();
if (followUp == null) {
_frame(); // closing rule (stats already shown above)
return text;
}
// Continue in the same context: append the user turn, reset the
// in-flight plan state so a new sub-goal re-investigates/re-plans, and
// give it a fresh step budget.
messages.add(AiMessage.user(followUp));
planApproval = null;
_lastCommandMutated = false;
_replanNeeded = false;
step = -1; // the for-loop ++ makes the next iteration step 0
continue;
}
if (styledText != null) handlers.writeLine(styledText);
var cancelled = false;
for (final call in result.toolCalls) {
final outcome = await _handleToolCall(
call,
mode: mode,
planApproval: planApproval,
onPlanApproval: (a) => planApproval = a,
);
messages.add(AiMessage.tool(outcome.result));
if (outcome.cancelled) cancelled = true;
}
if (cancelled) return _finish(null);
if (await _aborted(cancel)) return _finish(aborted);
}
return _finish(
'ai: stopped after ${config.maxSteps} steps without finishing.',
);
}