send method
Implementation
@override
Future<void> send(
String text, {
List<PromptAttachment> attachments = const [],
}) async {
if (_disposed) throw StateError('Session is disposed.');
if (_active != null) throw StateError('A response is already running.');
if (text.trim().isEmpty && attachments.isEmpty) return;
final user = _userMessage(text, attachments);
final system = <Map<String, Object?>>[
if (systemPrompt.isNotEmpty) {'role': 'system', 'content': systemPrompt},
];
_checkContext([...system, user]);
final retained = List<List<Map<String, Object?>>>.of(_history);
// Remove whole turns, preserving tool-call/result pairing.
while (retained.isNotEmpty &&
_contextSize([...system, ...retained.expand((t) => t), user]) >
maxContextBytes) {
retained.removeAt(0);
}
final history = retained.expand((t) => t).toList();
final working = <Map<String, Object?>>[user];
final cancellation = ChatCancellation();
_active = cancellation;
_error = null;
_turn++;
final turnId = _turn;
_messages.add(
ChatMessageData(
id: '$sessionId:$turnId:user',
role: ChatMessageRole.user,
text: text,
turnId: turnId,
metadata: {
if (attachments.isNotEmpty)
'contentBlocks': [
for (final a in attachments)
{'type': 'image', 'data': a.data, 'mimeType': a.imageMimeType},
],
},
),
);
_notify();
try {
for (var round = 0; round <= maxToolRounds; round++) {
cancellation.check();
final requestMessages = [...system, ...history, ...working];
_checkContext(requestMessages);
final message = _LlmTextMessage(
'$sessionId:$turnId:$round',
turnId,
inputBudget,
);
final reasoning = StringBuffer();
var reasoningBytes = 0;
final calls = <int, _ToolCallBuffer>{};
String? finishReason;
var visible = false;
await for (final chunk in client.stream(
messages: requestMessages,
cancellation: cancellation,
tools: tools.map((t) => t.toJson()).toList(),
)) {
cancellation.check();
final choices = chunk['choices'];
if (choices is! List || choices.isEmpty) continue;
final choice = choices
.whereType<Map>()
.where((c) => c['index'] == 0)
.firstOrNull;
if (choice == null) continue;
final reason = choice['finish_reason'];
if (reason is String) finishReason = reason;
final delta = choice['delta'];
if (delta is! Map) continue;
final thought = delta['reasoning_content'];
if (thought is String) {
reasoningBytes += utf8.encode(thought).length;
if (reasoningBytes > inputBudget.maxThoughtTextBytes) {
throw const ChatApiException(
'Reasoning exceeded its size limit.',
);
}
reasoning.write(thought);
}
final content = delta['content'] ?? delta['refusal'];
if (content is String && content.isNotEmpty) {
message.append(content);
if (!visible) {
_messages.add(message);
visible = true;
}
}
final updates = delta['tool_calls'];
if (updates is List) {
for (final update in updates) {
if (update is! Map || update['index'] is! int) {
throw const ChatApiException('Invalid tool-call stream.');
}
final index = update['index'] as int;
if (index < 0 || index >= maxToolCallsPerRound) {
throw const ChatApiException(
'Too many tool calls in one response.',
);
}
calls
.putIfAbsent(
index,
() => _ToolCallBuffer(inputBudget.maxMetadataBytes),
)
.add(update);
}
}
_notify();
}
cancellation.check();
message.finish();
if (finishReason == null) {
throw const ChatApiException(
'The model did not finish its response.',
);
}
if (calls.isEmpty) {
if (finishReason == 'tool_calls') {
throw const ChatApiException(
'The model finished without its tool calls.',
);
}
if (finishReason != 'stop') {
throw ChatApiException(
finishReason == 'length'
? 'The model reached its output limit. The partial reply is shown.'
: 'The model could not complete this reply.',
);
}
working.add({'role': 'assistant', 'content': message.text});
_history
..clear()
..addAll(retained)
..add(working);
while (_history.length > maxHistoryTurns) {
_history.removeAt(0);
}
return;
}
if (finishReason != 'tool_calls') {
throw const ChatApiException(
'The model returned incomplete tool calls.',
);
}
if (round >= maxToolRounds) {
throw const ChatApiException('The tool round limit was reached.');
}
final ordered = calls.keys.toList()..sort();
final completed = [for (final i in ordered) calls[i]!.finish()];
if (completed.map((c) => c.id).toSet().length != completed.length) {
throw const ChatApiException('The model reused a tool-call ID.');
}
working.add({
'role': 'assistant',
if (reasoning.isNotEmpty) 'reasoning_content': reasoning.toString(),
'content': message.text.isEmpty ? null : message.text,
'tool_calls': [for (final c in completed) c.toJson()],
});
for (final call in completed) {
cancellation.check();
final tool = tools.where((t) => t.name == call.name).firstOrNull;
final index = _messages.length;
_messages.add(
ChatMessageData(
id: '$sessionId:$turnId:tool:${call.id}',
role: ChatMessageRole.tool,
text: call.name,
turnId: turnId,
metadata: {
'toolCallId': call.id,
'title': call.name,
'kind': 'tool',
'status': 'in_progress',
'rawInput': call.arguments,
},
),
);
_notify();
var failed = false;
String output;
if (tool == null) {
output = 'Error: unknown tool.';
failed = true;
} else {
Map<String, Object?>? arguments;
try {
final value = jsonDecode(call.arguments);
if (value is Map<String, dynamic>) {
arguments = Map<String, Object?>.from(value);
}
} catch (_) {
/* Do not execute malformed arguments. */
}
if (arguments == null) {
output = 'Error: tool arguments must be a JSON object.';
failed = true;
} else if (tool.requiresApproval &&
!await _approve(call, cancellation)) {
output = 'Tool execution denied by the user.';
failed = true;
} else {
cancellation.check();
try {
output = await cancellation.bind(
tool.execute(arguments, cancellation),
);
} on ChatCancelled {
rethrow;
} catch (_) {
output = 'Error: the tool failed.';
failed = true;
}
}
}
cancellation.check();
if (utf8.encode(output).length > inputBudget.maxMetadataBytes) {
output = 'Error: tool output exceeded its size limit.';
failed = true;
}
final original = _messages[index] as ChatMessageData;
_messages[index] = original.copyWith(
metadata: {
...original.metadata,
'status': failed ? 'failed' : 'completed',
'rawOutput': output,
},
);
working.add({
'role': 'tool',
'tool_call_id': call.id,
'content': output,
});
_notify();
}
}
} on ChatCancelled {
_settleTools('cancelled');
_messages.add(
ChatMessageData(
role: ChatMessageRole.status,
text: 'Response stopped.',
turnId: turnId,
),
);
} catch (error) {
_settleTools('failed');
_error = error is ChatApiException
? error.toString()
: 'The response could not be completed.';
_messages.add(
ChatMessageData(
role: ChatMessageRole.error,
text: _error!,
turnId: turnId,
),
);
} finally {
if (identical(_active, cancellation)) {
_active = null;
_permission = null;
_permissionDecision = null;
_trimTimeline();
_notify();
}
}
}