Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions flutter_app/lib/src/agent_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/// A failure the assistant wants to surface to the user with a readable
/// message. Thrown by both the cloud and local agent paths; consumers render
/// [message] directly rather than branching on the provider.
class AgentException implements Exception {
const AgentException(this.message);

final String message;

@override
String toString() => message;
}
31 changes: 22 additions & 9 deletions flutter_app/lib/src/agent_llm_provider.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'agent_config_store.dart';
import 'agent_exception.dart';
import 'chat_session_mutation.dart';
import 'cloud_agent_client.dart';
import 'mail_tools.dart';
Expand All @@ -19,6 +20,7 @@ class AgentLlmRequest {
required this.context,
required this.memoryText,
required this.appendMemory,
required this.readMemory,
required this.readSchedule,
required this.mailTools,
required this.onToolTrace,
Expand All @@ -33,6 +35,7 @@ class AgentLlmRequest {
final PromptContext context;
final String memoryText;
final Future<void> Function(String text) appendMemory;
final Future<String> Function() readMemory;
final Future<String> Function() readSchedule;
final MailToolRunner mailTools;
final void Function(ToolTrace trace) onToolTrace;
Expand Down Expand Up @@ -131,7 +134,7 @@ class LocalNativeLlmProvider implements AgentLlmProvider {
final toolContext = StudyOsToolContext(
promptContext: request.context,
appendMemory: request.appendMemory,
readMemory: () async => request.memoryText,
readMemory: request.readMemory,
readSchedule: request.readSchedule,
mailTools: request.mailTools,
nativeTools: nativeTools,
Expand All @@ -155,15 +158,12 @@ class LocalNativeLlmProvider implements AgentLlmProvider {
toolContext,
);
} on Object catch (error) {
final failedOutput = _toolFailureOutput(error);
request.onToolTrace(
_traceForCall(
call,
'failed',
callId: callId,
output: error.toString(),
),
_traceForCall(call, 'failed', callId: callId, output: failedOutput),
);
rethrow;
feedback.add('- ${call.name}: $failedOutput');
continue;
}
request.onToolTrace(
_traceForCall(call, 'done', callId: callId, output: output),
Expand All @@ -179,6 +179,14 @@ class LocalNativeLlmProvider implements AgentLlmProvider {
localBackend: request.config.localBackend.name,
);
}
// Mirror the cloud client: a turn that still requests tools after the
// round budget is exhausted is a stuck loop, not an answer. Surface it as
// an error instead of returning raw tool directives to the user.
if (_toolCalls(response).isNotEmpty) {
throw const AgentException(
'Local tool loop exceeded the maximum number of tool rounds.',
);
}
return response;
}

Expand Down Expand Up @@ -262,6 +270,11 @@ class LocalNativeLlmProvider implements AgentLlmProvider {
}
}

String _toolFailureOutput(Object error) {
final message = error.toString().trim();
return message.isEmpty ? 'Tool failed.' : 'Tool failed: $message';
}

class _LocalToolCall {
const _LocalToolCall({required this.name, required this.arguments});

Expand Down Expand Up @@ -295,7 +308,7 @@ class CloudLlmProvider implements AgentLlmProvider {
Future<String> send(AgentLlmRequest request) async {
final apiKey = await _configStore.readApiKey();
if (apiKey == null || apiKey.isEmpty) {
throw const CloudAgentException('Cloud API key is required.');
throw const AgentException('Cloud API key is required.');
}
return _cloudClient.sendMessage(
config: request.config,
Expand Down
1 change: 1 addition & 0 deletions flutter_app/lib/src/agent_request_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class AgentRequestRunner {
context: context,
memoryText: memoryText,
appendMemory: appendMemory,
readMemory: memoryStore.read,
readSchedule: readSchedule,
mailTools: mailTools,
onToolTrace: onToolTrace,
Expand Down
175 changes: 101 additions & 74 deletions flutter_app/lib/src/cloud_agent_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:convert';

import 'package:http/http.dart' as http;

import 'agent_exception.dart';
import 'cloud_tool_definitions.dart';
import 'mail_tools.dart';
import 'models.dart';
Expand All @@ -25,6 +26,7 @@ class CloudAgentClient {
final http.Client _httpClient;
final StudyOsToolExecutor _toolExecutor;
final NativeToolRunner? _nativeTools;
static const int _maxToolRounds = 3;

Future<String> sendMessage({
required AgentConfig config,
Expand All @@ -42,41 +44,28 @@ class CloudAgentClient {
}) async {
final endpoint = Uri.tryParse(config.cloudEndpoint.trim());
if (endpoint == null || !endpoint.hasScheme || !endpoint.hasAuthority) {
throw const CloudAgentException('Custom AI server URL is not valid.');
throw const AgentException('Custom AI server URL is not valid.');
}
if (config.cloudModel.trim().isEmpty) {
throw const CloudAgentException('Model name is required.');
throw const AgentException('Model name is required.');
}
if (apiKey.trim().isEmpty) {
throw const CloudAgentException('API key is required.');
throw const AgentException('API key is required.');
}

final supportedNativeToolNames =
await _nativeTools?.supportedToolNames() ?? const <String>{};
final request = _requestBody(
var request = _requestBody(
config: config,
history: history,
userText: userText,
context: context,
supportedNativeToolNames: supportedNativeToolNames,
);
final message = await _fetchTurn(
endpoint,
apiKey,
request,
onDelta,
cancelToken,
final messages = List<Map<String, Object?>>.from(
request['messages'] as List,
);
final toolCalls = _toolCalls(message);
if (toolCalls.isEmpty) {
return _contentFromMessage(message);
}

// A streamed turn that resolved into tool calls carries no user-facing
// answer yet; clear the live buffer before the follow-up answer streams.
onDelta?.call(const AgentStreamDelta(reset: true));

final toolMessages = <Map<String, Object?>>[];
final toolContext = StudyOsToolContext(
promptContext: context,
appendMemory: appendMemory,
Expand All @@ -85,47 +74,71 @@ class CloudAgentClient {
mailTools: mailTools,
nativeTools: _nativeTools,
);
for (final call in toolCalls) {
if (cancelToken?.isCancelled ?? false) {
throw const AgentCancelledException();
for (var round = 0; ; round += 1) {
final message = await _fetchTurn(
endpoint,
apiKey,
request,
onDelta,
cancelToken,
);
final toolCalls = _toolCalls(message);
if (toolCalls.isEmpty) {
return _contentFromMessage(message);
}
onToolTrace?.call(_traceForCall(call, 'running'));
final String output;
try {
output = await _toolExecutor.execute(
call.name,
call.arguments,
toolContext,
);
} on Object catch (error) {
onToolTrace?.call(
_traceForCall(call, 'failed', output: error.toString()),
if (round >= _maxToolRounds) {
throw const AgentException(
'Cloud tool loop exceeded the maximum number of tool rounds.',
);
rethrow;
}
onToolTrace?.call(_traceForCall(call, 'done', output: output));
toolMessages.add(<String, Object?>{
'role': 'tool',
'tool_call_id': call.id,
'content': output,
});
}

final followUp = await _fetchTurn(
endpoint,
apiKey,
<String, Object?>{
// A streamed turn that resolved into tool calls carries no user-facing
// answer yet; clear the live buffer before the follow-up answer streams.
onDelta?.call(const AgentStreamDelta(reset: true));

final toolMessages = <Map<String, Object?>>[];
for (final call in toolCalls) {
if (cancelToken?.isCancelled ?? false) {
throw const AgentCancelledException();
}
onToolTrace?.call(_traceForCall(call, 'running'));
final String output;
try {
output = await _toolExecutor.execute(
call.name,
call.arguments,
toolContext,
);
} on Object catch (error) {
final failure = _toolFailureOutput(error);
onToolTrace?.call(_traceForCall(call, 'failed', output: failure));
toolMessages.add(<String, Object?>{
'role': 'tool',
'tool_call_id': call.id,
'content': failure,
});
continue;
}
onToolTrace?.call(_traceForCall(call, 'done', output: output));
toolMessages.add(<String, Object?>{
'role': 'tool',
'tool_call_id': call.id,
'content': output,
});
}

messages
..add(message)
..addAll(toolMessages);
request = <String, Object?>{
'model': config.cloudModel.trim(),
'messages': <Map<String, Object?>>[
...List<Map<String, Object?>>.from(request['messages'] as List),
message,
...toolMessages,
],
},
onDelta,
cancelToken,
);
return _contentFromMessage(followUp);
'messages': messages,
'tools': cloudToolDefinitions(
supportedNativeToolNames: supportedNativeToolNames,
),
'tool_choice': 'auto',
};
}
}

/// Fetches one assistant turn, returning a message map shaped like
Expand All @@ -142,12 +155,31 @@ class CloudAgentClient {
throw const AgentCancelledException();
}
if (onDelta == null) {
final response = await _post(endpoint, apiKey, body);
final response = await _awaitCancellable(
_post(endpoint, apiKey, body),
cancelToken,
);
return _messageFromResponse(_decodeResponse(response));
}
return _streamTurn(endpoint, apiKey, body, onDelta, cancelToken);
}

/// Awaits [future] but abandons it the moment the request is cancelled, so a
/// stalled connect (e.g. a misconfigured or unreachable endpoint) unblocks
/// Stop immediately instead of hanging until the socket times out. The
/// abandoned request keeps running in the background; [Future.any] swallows
/// its late completion so it never surfaces as an unhandled error.
Future<T> _awaitCancellable<T>(
Future<T> future,
AgentCancelToken? cancelToken,
) {
if (cancelToken == null) return future;
final cancelled = cancelToken.whenCancelled.then<T>(
(_) => throw const AgentCancelledException(),
);
return Future.any<T>(<Future<T>>[future, cancelled]);
}

Future<Map<String, Object?>> _streamTurn(
Uri endpoint,
String apiKey,
Expand All @@ -161,10 +193,13 @@ class CloudAgentClient {
..headers['Accept'] = 'text/event-stream'
..body = jsonEncode(<String, Object?>{...body, 'stream': true});

final response = await _httpClient.send(request);
final response = await _awaitCancellable(
_httpClient.send(request),
cancelToken,
);
if (response.statusCode < 200 || response.statusCode >= 300) {
await response.stream.drain<void>();
throw CloudAgentException(
throw AgentException(
'Custom AI service returned HTTP ${response.statusCode}.',
);
}
Expand Down Expand Up @@ -331,39 +366,35 @@ class CloudAgentClient {

Map<String, Object?> _decodeResponse(http.Response response) {
if (response.statusCode < 200 || response.statusCode >= 300) {
throw CloudAgentException(
throw AgentException(
'Custom AI service returned HTTP ${response.statusCode}.',
);
}

final decoded = jsonDecode(response.body);
if (decoded is! Map<String, Object?>) {
throw const CloudAgentException('Cloud response was not a JSON object.');
throw const AgentException('Cloud response was not a JSON object.');
}
return decoded;
}

Map<String, Object?> _messageFromResponse(Map<String, Object?> decoded) {
final choices = decoded['choices'];
if (choices is! List || choices.isEmpty || choices.first is! Map) {
throw const CloudAgentException(
'Cloud response did not include choices.',
);
throw const AgentException('Cloud response did not include choices.');
}
final choice = Map<String, Object?>.from(choices.first as Map);
final message = choice['message'];
if (message is! Map) {
throw const CloudAgentException(
'Cloud response did not include content.',
);
throw const AgentException('Cloud response did not include content.');
}
return Map<String, Object?>.from(message);
}

String _contentFromMessage(Map<String, Object?> message) {
final content = message['content']?.toString();
if (content == null || content.trim().isEmpty) {
throw const CloudAgentException('Cloud response content was empty.');
throw const AgentException('Cloud response content was empty.');
}
return content;
}
Expand Down Expand Up @@ -425,11 +456,7 @@ class _StreamingToolCall {
final StringBuffer arguments = StringBuffer();
}

class CloudAgentException implements Exception {
const CloudAgentException(this.message);

final String message;

@override
String toString() => message;
String _toolFailureOutput(Object error) {
final message = error.toString().trim();
return message.isEmpty ? 'Tool failed.' : 'Tool failed: $message';
}
Loading
Loading