diff --git a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidNativeToolExecutor.kt b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidNativeToolExecutor.kt new file mode 100644 index 0000000..eb826ef --- /dev/null +++ b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidNativeToolExecutor.kt @@ -0,0 +1,97 @@ +package com.studyos.studyos_agent + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.provider.Settings +import com.example.studyOS.offline.Tools + +class AndroidNativeToolExecutor(context: Context) { + private val appContext = context.applicationContext + private val tools: Tools by lazy { Tools(appContext) } + + fun canControlFlashlight(): Boolean { + return appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_FLASH) + } + + fun capabilities(): List> { + return listOf( + supported("get_device_status"), + supported( + "set_flashlight", + canControlFlashlight(), + "This device does not report a camera flash.", + ), + supported("open_installed_app"), + supported("search_youtube"), + supported("open_system_setting"), + mapOf( + "name" to "create_reminder", + "supported" to false, + "reason" to "Reminder scheduling is reserved for the reminder PR.", + ), + ) + } + + fun execute(name: String, arguments: Map<*, *>): String { + return when (name) { + "get_device_status" -> tools.getDeviceStatus() + "set_flashlight" -> tools.toggleFlashlight(booleanArgument(arguments, "enabled")) + "open_installed_app" -> { + val appName = stringArgument(arguments, "name") + tools.openApp(appName) + } + "search_youtube" -> { + val query = stringArgument(arguments, "query") + tools.searchYoutube(query) + "Opened YouTube search for '$query'." + } + "open_system_setting" -> openSystemSetting(stringArgument(arguments, "setting")) + else -> throw IllegalArgumentException("Native tool is not available: $name") + } + } + + private fun openSystemSetting(setting: String): String { + val normalized = setting.trim().lowercase() + val action = when (normalized) { + "wifi" -> Settings.ACTION_WIFI_SETTINGS + "bluetooth" -> Settings.ACTION_BLUETOOTH_SETTINGS + "location" -> Settings.ACTION_LOCATION_SOURCE_SETTINGS + "mobile_data" -> Settings.ACTION_DATA_ROAMING_SETTINGS + else -> throw IllegalArgumentException( + "Unsupported setting '$setting'. Use wifi, bluetooth, location, or mobile_data.", + ) + } + val intent = Intent(action).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + appContext.startActivity(intent) + return "Opened $normalized settings. Direct toggles are controlled by Android." + } + + private fun supported( + name: String, + supported: Boolean = true, + reasonWhenUnsupported: String? = null, + ): Map { + return mapOf( + "name" to name, + "supported" to supported, + "reason" to if (supported) null else reasonWhenUnsupported, + ) + } + + private fun stringArgument(arguments: Map<*, *>, key: String): String { + val value = arguments[key]?.toString()?.trim().orEmpty() + if (value.isBlank()) { + throw IllegalArgumentException("Missing required '$key' argument.") + } + return value + } + + private fun booleanArgument(arguments: Map<*, *>, key: String): Boolean { + return when (val value = arguments[key]) { + is Boolean -> value + is String -> value.equals("true", ignoreCase = true) + else -> throw IllegalArgumentException("Missing required boolean '$key' argument.") + } + } +} diff --git a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt index 9b27b77..1de22d0 100644 --- a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt +++ b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt @@ -25,6 +25,7 @@ class MainActivity : FlutterActivity() { private var localPromptClient: AndroidLocalPromptClient? = null private var localModelStore: AndroidLocalModelStore? = null private var liteRtToolExecutor: AndroidLiteRtToolExecutor? = null + private var nativeToolExecutor: AndroidNativeToolExecutor? = null private lateinit var intentBridge: AndroidIntentBridge override fun onCreate(savedInstanceState: Bundle?) { @@ -67,6 +68,8 @@ class MainActivity : FlutterActivity() { "initialize" -> result.success(initializeNativeLayer()) "getWorldState" -> result.success(worldStateMap()) "getCapabilities" -> result.success(capabilities()) + "getNativeToolCapabilities" -> result.success(nativeToolCapabilities()) + "executeNativeTool" -> executeNativeTool(call, result) "listLocalModels" -> result.success(localModelStore().listModels()) "downloadLocalModel" -> downloadLocalModel(call, result) "cancelLocalModelDownload" -> { @@ -296,6 +299,32 @@ class MainActivity : FlutterActivity() { } } + private fun nativeToolExecutor(): AndroidNativeToolExecutor { + val existing = nativeToolExecutor + if (existing != null) return existing + return AndroidNativeToolExecutor(applicationContext).also { + nativeToolExecutor = it + } + } + + private fun executeNativeTool(call: MethodCall, result: MethodChannel.Result) { + val name = call.argument("name")?.trim().orEmpty() + val arguments = call.argument>("arguments") ?: emptyMap() + if (name.isBlank()) { + result.error("native_tool_missing_name", "Native tool name is required.", null) + return + } + try { + result.success(nativeToolExecutor().execute(name, arguments)) + } catch (error: Throwable) { + result.error( + "native_tool_failed", + error.message ?: "Native tool failed.", + null, + ) + } + } + private fun downloadLocalModel(call: MethodCall, result: MethodChannel.Result) { val id = call.argument("id").orEmpty() val label = call.argument("label").orEmpty() @@ -394,14 +423,24 @@ class MainActivity : FlutterActivity() { "canUseOfflineLiteRtModel" to true, "canManageDownloadedLiteRtModels" to true, "canUseAndroidGeminiNanoPrompt" to true, - "canControlFlashlight" to true, + "canControlFlashlight" to nativeToolExecutor().canControlFlashlight(), "canStartPhoneCall" to hasPermission(Manifest.permission.CALL_PHONE), + "nativeToolContractVersion" to 1, + "nativeTools" to nativeToolExecutor().capabilities(), "androidAssistantSnapshot" to intentBridge.snapshotStatus(), "iosParity" to "limited by iOS background execution and app-control policies", "webDesktopParity" to "limited shell only until adapters are implemented", ) + localPromptClient().capabilities() } + private fun nativeToolCapabilities(): Map { + return mapOf( + "platform" to "android", + "nativeToolContractVersion" to 1, + "nativeTools" to nativeToolExecutor().capabilities(), + ) + } + private fun hasLocationPermission(): Boolean { return hasPermission(Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION) diff --git a/flutter_app/ios/Runner/StudyOSNativeBridge.swift b/flutter_app/ios/Runner/StudyOSNativeBridge.swift index 7fae6ba..56a75c6 100644 --- a/flutter_app/ios/Runner/StudyOSNativeBridge.swift +++ b/flutter_app/ios/Runner/StudyOSNativeBridge.swift @@ -59,6 +59,10 @@ final class StudyOSNativeBridge: NSObject, FlutterStreamHandler, CLLocationManag result(worldState()) case "getCapabilities": result(capabilities()) + case "getNativeToolCapabilities": + result(nativeToolCapabilityMap()) + case "executeNativeTool": + executeNativeTool(call: call, result: result) case "publishIntentSnapshot": publishIntentSnapshot(call: call, result: result) case "consumePendingIntentPrompt": @@ -209,6 +213,22 @@ final class StudyOSNativeBridge: NSObject, FlutterStreamHandler, CLLocationManag result("iOS speech started.") } + private func executeNativeTool(call: FlutterMethodCall, result: FlutterResult) { + guard + let args = call.arguments as? [String: Any], + let name = args["name"] as? String + else { + result(FlutterError(code: "native_tool_missing_name", message: "Native tool name is required.", details: nil)) + return + } + + result(FlutterError( + code: "native_tool_unsupported", + message: "Native tool is not supported on iOS in this build: \(name).", + details: nil + )) + } + private func worldState() -> [String: Any] { var state: [String: Any] = [ "platform": "ios", @@ -241,13 +261,15 @@ final class StudyOSNativeBridge: NSObject, FlutterStreamHandler, CLLocationManag "canOpenInstalledApps": false, "canReadCalendar": false, "canUseOfflineLiteRtModel": false, - "canControlFlashlight": true, + "canControlFlashlight": false, "canStartPhoneCall": true, "canUseSpeechRecognition": SFSpeechRecognizer(locale: Locale.current)?.isAvailable ?? false, "canUseTextToSpeech": true, "canCreateLocalNotificationReminder": true, "canUseAppIntents": canUseAppIntents() ] + values["nativeToolContractVersion"] = 1 + values["nativeTools"] = nativeToolCapabilities() #if canImport(FoundationModels) if #available(iOS 26.0, *) { @@ -264,6 +286,26 @@ final class StudyOSNativeBridge: NSObject, FlutterStreamHandler, CLLocationManag return values } + private func nativeToolCapabilities() -> [[String: Any]] { + let iosControlReason = "This native control is Android-only in this build." + return [ + ["name": "get_device_status", "supported": false, "reason": iosControlReason], + ["name": "set_flashlight", "supported": false, "reason": iosControlReason], + ["name": "open_installed_app", "supported": false, "reason": "iOS does not support arbitrary installed-app launching from this app."], + ["name": "search_youtube", "supported": false, "reason": iosControlReason], + ["name": "open_system_setting", "supported": false, "reason": iosControlReason], + ["name": "create_reminder", "supported": false, "reason": "Reminder tool exposure is reserved for the reminder PR."] + ] + } + + private func nativeToolCapabilityMap() -> [String: Any] { + return [ + "platform": "ios", + "nativeToolContractVersion": 1, + "nativeTools": nativeToolCapabilities() + ] + } + private func canUseAppIntents() -> Bool { #if canImport(AppIntents) if #available(iOS 16.0, *) { diff --git a/flutter_app/lib/src/agent_llm_provider.dart b/flutter_app/lib/src/agent_llm_provider.dart index 25693a1..e99caac 100644 --- a/flutter_app/lib/src/agent_llm_provider.dart +++ b/flutter_app/lib/src/agent_llm_provider.dart @@ -5,6 +5,7 @@ import 'mail_tools.dart'; import 'memory_store.dart'; import 'models.dart'; import 'native_bridge.dart'; +import 'native_tool_router.dart'; import 'prompt_context.dart'; import 'studyos_tool_catalog.dart'; import 'studyos_tool_executor.dart'; @@ -104,7 +105,12 @@ class LocalNativeLlmProvider implements AgentLlmProvider { @override Future send(AgentLlmRequest request) async { - final systemPrompt = _localSystemPrompt(request.context.systemPrompt()); + final nativeTools = NativeToolRouter(_bridge); + final supportedNativeToolNames = await nativeTools.supportedToolNames(); + final systemPrompt = _localSystemPrompt( + request.context.systemPrompt(), + supportedNativeToolNames, + ); var response = await _bridge.sendMessage( request.userText, systemPrompt: systemPrompt, @@ -117,6 +123,7 @@ class LocalNativeLlmProvider implements AgentLlmProvider { readMemory: () async => request.memoryText, readSchedule: request.readSchedule, mailTools: request.mailTools, + nativeTools: nativeTools, ); for (var round = 0; round < _maxToolRounds; round += 1) { @@ -163,7 +170,10 @@ class LocalNativeLlmProvider implements AgentLlmProvider { return response; } - String _localSystemPrompt(String basePrompt) { + String _localSystemPrompt( + String basePrompt, + Set supportedNativeToolNames, + ) { final buffer = StringBuffer() ..writeln(basePrompt) ..writeln() @@ -181,7 +191,7 @@ class LocalNativeLlmProvider implements AgentLlmProvider { ) ..writeln('After tool results are returned, answer naturally.') ..writeln('Available StudyOS tools:'); - for (final tool in studyOsTools) { + for (final tool in studyOsToolsForNativeSupport(supportedNativeToolNames)) { final args = tool.required.isEmpty ? '{}' : '{${tool.required.map((name) => '"$name":"..."').join(',')}}'; diff --git a/flutter_app/lib/src/agent_request_runner.dart b/flutter_app/lib/src/agent_request_runner.dart index 2e1b931..b293d65 100644 --- a/flutter_app/lib/src/agent_request_runner.dart +++ b/flutter_app/lib/src/agent_request_runner.dart @@ -5,6 +5,7 @@ import 'mail_tools.dart'; import 'memory_store.dart'; import 'models.dart'; import 'native_bridge.dart'; +import 'native_tool_router.dart'; import 'prompt_context.dart'; class AgentRequestRunner { @@ -23,7 +24,9 @@ class AgentRequestRunner { configStore: configStore, memoryStore: memoryStore, appendMemory: appendMemory, - cloudClient: cloudClient ?? CloudAgentClient(), + cloudClient: + cloudClient ?? + CloudAgentClient(nativeTools: NativeToolRouter(bridge)), ); final NativeBridge bridge; diff --git a/flutter_app/lib/src/cloud_agent_client.dart b/flutter_app/lib/src/cloud_agent_client.dart index 11af24f..4158fc2 100644 --- a/flutter_app/lib/src/cloud_agent_client.dart +++ b/flutter_app/lib/src/cloud_agent_client.dart @@ -5,17 +5,25 @@ import 'package:http/http.dart' as http; import 'cloud_tool_definitions.dart'; import 'mail_tools.dart'; import 'models.dart'; +import 'native_tool_router.dart'; import 'prompt_context.dart'; import 'studyos_tool_catalog.dart'; import 'studyos_tool_executor.dart'; class CloudAgentClient { - CloudAgentClient({http.Client? httpClient, StudyOsToolExecutor? toolExecutor}) - : _httpClient = httpClient ?? http.Client(), - _toolExecutor = toolExecutor ?? const StudyOsToolExecutor(); + CloudAgentClient({ + http.Client? httpClient, + StudyOsToolExecutor? toolExecutor, + NativeToolRunner? nativeTools, + }) : _httpClient = httpClient ?? http.Client(), + _toolExecutor = toolExecutor ?? const StudyOsToolExecutor(), + // Keep the public constructor parameter named `nativeTools`. + // ignore: prefer_initializing_formals + _nativeTools = nativeTools; final http.Client _httpClient; final StudyOsToolExecutor _toolExecutor; + final NativeToolRunner? _nativeTools; Future sendMessage({ required AgentConfig config, @@ -40,11 +48,14 @@ class CloudAgentClient { throw const CloudAgentException('API key is required.'); } + final supportedNativeToolNames = + await _nativeTools?.supportedToolNames() ?? const {}; final request = _requestBody( config: config, history: history, userText: userText, context: context, + supportedNativeToolNames: supportedNativeToolNames, ); final response = await _post(endpoint, apiKey, request); final decoded = _decodeResponse(response); @@ -61,6 +72,7 @@ class CloudAgentClient { readMemory: readMemory, readSchedule: readSchedule, mailTools: mailTools, + nativeTools: _nativeTools, ); for (final call in toolCalls) { onToolTrace?.call(_traceForCall(call, 'running')); @@ -116,6 +128,7 @@ class CloudAgentClient { required List history, required String userText, required PromptContext context, + required Set supportedNativeToolNames, }) { final historyWithoutCurrent = history.isNotEmpty && @@ -135,7 +148,9 @@ class CloudAgentClient { }, {'role': 'user', 'content': userText}, ], - 'tools': cloudToolDefinitions(), + 'tools': cloudToolDefinitions( + supportedNativeToolNames: supportedNativeToolNames, + ), 'tool_choice': 'auto', }; } diff --git a/flutter_app/lib/src/cloud_tool_definitions.dart b/flutter_app/lib/src/cloud_tool_definitions.dart index 58d2251..42a4015 100644 --- a/flutter_app/lib/src/cloud_tool_definitions.dart +++ b/flutter_app/lib/src/cloud_tool_definitions.dart @@ -1,7 +1,11 @@ import 'studyos_tool_catalog.dart'; -List> cloudToolDefinitions() { - return studyOsTools.map(_tool).toList(); +List> cloudToolDefinitions({ + Set supportedNativeToolNames = const {}, +}) { + return studyOsToolsForNativeSupport( + supportedNativeToolNames, + ).map(_tool).toList(); } Map _tool(StudyOsToolSpec spec) { diff --git a/flutter_app/lib/src/native_bridge.dart b/flutter_app/lib/src/native_bridge.dart index cd46f76..98e4eb6 100644 --- a/flutter_app/lib/src/native_bridge.dart +++ b/flutter_app/lib/src/native_bridge.dart @@ -35,6 +35,24 @@ class NativeBridge { return result ?? const {}; } + Future> getNativeToolCapabilities() async { + final result = await _methods.invokeMapMethod( + 'getNativeToolCapabilities', + ); + return result ?? const {}; + } + + Future executeNativeTool( + String name, + Map arguments, + ) async { + final result = await _methods.invokeMethod( + 'executeNativeTool', + {'name': name, 'arguments': arguments}, + ); + return result ?? 'Native tool returned no response.'; + } + Future>> listLocalModels() async { final result = await _methods.invokeListMethod('listLocalModels'); return (result ?? const []) diff --git a/flutter_app/lib/src/native_tool_router.dart b/flutter_app/lib/src/native_tool_router.dart new file mode 100644 index 0000000..b5f0c4e --- /dev/null +++ b/flutter_app/lib/src/native_tool_router.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +import 'native_bridge.dart'; + +const nativeDeviceStatusToolName = 'get_device_status'; +const nativeSetFlashlightToolName = 'set_flashlight'; +const nativeOpenInstalledAppToolName = 'open_installed_app'; +const nativeSearchYoutubeToolName = 'search_youtube'; +const nativeOpenSystemSettingToolName = 'open_system_setting'; +const nativeCreateReminderToolName = 'create_reminder'; + +const activeNativeToolNames = { + nativeDeviceStatusToolName, + nativeSetFlashlightToolName, + nativeOpenInstalledAppToolName, + nativeSearchYoutubeToolName, + nativeOpenSystemSettingToolName, +}; + +abstract class NativeToolRunner { + Future> supportedToolNames(); + + Future execute(String toolName, String arguments); +} + +class NativeToolRouter implements NativeToolRunner { + NativeToolRouter(this._bridge); + + final NativeBridge _bridge; + Future? _capabilities; + + @override + Future> supportedToolNames() async { + final capabilities = await _loadCapabilities(); + return capabilities.supportedToolNames(activeNativeToolNames); + } + + @override + Future execute(String toolName, String arguments) async { + if (!activeNativeToolNames.contains(toolName)) { + return 'Native tool is not available: $toolName'; + } + + final Map decodedArguments; + try { + decodedArguments = _decodeArguments(arguments); + } on FormatException { + return 'Native tool arguments were not valid JSON.'; + } + + final capabilities = await _loadCapabilities(); + final support = capabilities.supportFor(toolName); + if (!support.supported) { + return support.messageFor(toolName); + } + + try { + return await _bridge.executeNativeTool(toolName, decodedArguments); + } on PlatformException catch (error) { + return error.message ?? 'Native tool failed: ${error.code}'; + } + } + + Future _loadCapabilities() { + return _capabilities ??= _bridge.getNativeToolCapabilities().then( + NativeToolCapabilities.fromMap, + ); + } + + Map _decodeArguments(String arguments) { + final trimmed = arguments.trim(); + if (trimmed.isEmpty) return {}; + final decoded = jsonDecode(trimmed); + if (decoded is! Map) { + throw const FormatException('Expected JSON object arguments.'); + } + return Map.from(decoded); + } +} + +class NativeToolCapabilities { + const NativeToolCapabilities(this._tools); + + factory NativeToolCapabilities.fromMap(Map capabilities) { + final entries = capabilities['nativeTools']; + final tools = {}; + if (entries is List) { + for (final entry in entries) { + if (entry is! Map) continue; + final mapped = Map.from(entry); + final name = mapped['name']?.toString(); + if (name == null || name.isEmpty) continue; + tools[name] = NativeToolSupport( + supported: mapped['supported'] == true, + reason: mapped['reason']?.toString(), + ); + } + } + return NativeToolCapabilities(tools); + } + + final Map _tools; + + NativeToolSupport supportFor(String toolName) { + return _tools[toolName] ?? const NativeToolSupport(supported: false); + } + + Set supportedToolNames(Iterable toolNames) { + return toolNames + .where((toolName) => supportFor(toolName).supported) + .toSet(); + } +} + +class NativeToolSupport { + const NativeToolSupport({required this.supported, this.reason}); + + final bool supported; + final String? reason; + + String messageFor(String toolName) { + final detail = reason?.trim(); + if (detail == null || detail.isEmpty) { + return 'Native tool is not supported on this device: $toolName'; + } + return 'Native tool is not supported on this device: $toolName. $detail'; + } +} diff --git a/flutter_app/lib/src/studyos_tool_catalog.dart b/flutter_app/lib/src/studyos_tool_catalog.dart index 94c7948..d1bf946 100644 --- a/flutter_app/lib/src/studyos_tool_catalog.dart +++ b/flutter_app/lib/src/studyos_tool_catalog.dart @@ -1,3 +1,5 @@ +import 'native_tool_router.dart'; + class StudyOsToolSpec { const StudyOsToolSpec({ required this.name, @@ -152,6 +154,71 @@ const findMailDeadlinesTool = StudyOsToolSpec( required: [], ); +const getDeviceStatusTool = StudyOsToolSpec( + name: nativeDeviceStatusToolName, + description: + 'Read native device status when the platform supports this local action.', + traceSummary: 'Reading native device status.', + properties: {}, + required: [], +); + +const setFlashlightTool = StudyOsToolSpec( + name: nativeSetFlashlightToolName, + description: + 'Turn the device flashlight on or off when the platform allows it.', + traceSummary: 'Setting the native flashlight state.', + properties: { + 'enabled': { + 'type': 'boolean', + 'description': 'True to turn the flashlight on, false to turn it off.', + }, + }, + required: ['enabled'], +); + +const openInstalledAppTool = StudyOsToolSpec( + name: nativeOpenInstalledAppToolName, + description: + 'Open an installed Android app by display name after the user asks for it.', + traceSummary: 'Opening an installed app.', + properties: { + 'name': { + 'type': 'string', + 'description': 'Display name of the app to open.', + }, + }, + required: ['name'], +); + +const searchYoutubeTool = StudyOsToolSpec( + name: nativeSearchYoutubeToolName, + description: 'Open YouTube search results for a user-requested query.', + traceSummary: 'Opening YouTube search.', + properties: { + 'query': { + 'type': 'string', + 'description': 'Search query to open in YouTube or the browser.', + }, + }, + required: ['query'], +); + +const openSystemSettingTool = StudyOsToolSpec( + name: nativeOpenSystemSettingToolName, + description: + 'Open a native settings panel for Wi-Fi, Bluetooth, location, or mobile data; does not claim direct toggle control.', + traceSummary: 'Opening a native settings panel.', + properties: { + 'setting': { + 'type': 'string', + 'enum': ['wifi', 'bluetooth', 'location', 'mobile_data'], + 'description': 'The settings panel to open.', + }, + }, + required: ['setting'], +); + const studyOsTools = [ appendMemoryTool, readMemoriesTool, @@ -162,8 +229,25 @@ const studyOsTools = [ searchMailTool, getMailMessageTool, findMailDeadlinesTool, + getDeviceStatusTool, + setFlashlightTool, + openInstalledAppTool, + searchYoutubeTool, + openSystemSettingTool, ]; +List studyOsToolsForNativeSupport( + Set supportedNativeToolNames, +) { + return studyOsTools + .where( + (tool) => + !activeNativeToolNames.contains(tool.name) || + supportedNativeToolNames.contains(tool.name), + ) + .toList(); +} + StudyOsToolSpec? studyOsToolByName(String name) { for (final tool in studyOsTools) { if (tool.name == name) return tool; diff --git a/flutter_app/lib/src/studyos_tool_executor.dart b/flutter_app/lib/src/studyos_tool_executor.dart index 3bb5d93..6a24d97 100644 --- a/flutter_app/lib/src/studyos_tool_executor.dart +++ b/flutter_app/lib/src/studyos_tool_executor.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'mail_tools.dart'; import 'memory_store.dart'; +import 'native_tool_router.dart'; import 'prompt_context.dart'; class StudyOsToolContext { @@ -11,6 +12,7 @@ class StudyOsToolContext { required this.readMemory, required this.readSchedule, required this.mailTools, + required this.nativeTools, }); final PromptContext promptContext; @@ -18,6 +20,7 @@ class StudyOsToolContext { final Future Function() readMemory; final Future Function() readSchedule; final MailToolRunner mailTools; + final NativeToolRunner? nativeTools; } class StudyOsToolExecutor { @@ -38,10 +41,28 @@ class StudyOsToolExecutor { 'search_mail' || 'get_mail_message' || 'find_mail_deadlines' => context.mailTools.execute(toolName, arguments), + _ when activeNativeToolNames.contains(toolName) => _executeNativeTool( + toolName, + arguments, + context.nativeTools, + ), _ => 'Tool is not available: $toolName', }; } + Future _executeNativeTool( + String toolName, + String arguments, + NativeToolRunner? nativeTools, + ) { + if (nativeTools == null) { + return Future.value( + 'Native tool is not available in this runtime: $toolName', + ); + } + return nativeTools.execute(toolName, arguments); + } + Future _appendMemory( String arguments, Future Function(String text) appendMemory, @@ -68,6 +89,7 @@ StudyOsToolContext studyOsToolContext({ required MemoryStore memoryStore, required Future Function() readSchedule, required MailToolRunner mailTools, + NativeToolRunner? nativeTools, }) { return StudyOsToolContext( promptContext: promptContext, @@ -75,5 +97,6 @@ StudyOsToolContext studyOsToolContext({ readMemory: memoryStore.read, readSchedule: readSchedule, mailTools: mailTools, + nativeTools: nativeTools, ); } diff --git a/flutter_app/test/agent_llm_provider_test.dart b/flutter_app/test/agent_llm_provider_test.dart index 1179c36..3103ca7 100644 --- a/flutter_app/test/agent_llm_provider_test.dart +++ b/flutter_app/test/agent_llm_provider_test.dart @@ -7,6 +7,7 @@ import 'package:studyos_agent/src/mail_tools.dart'; import 'package:studyos_agent/src/memory_store.dart'; import 'package:studyos_agent/src/models.dart'; import 'package:studyos_agent/src/native_bridge.dart'; +import 'package:studyos_agent/src/native_tool_router.dart'; import 'package:studyos_agent/src/prompt_context.dart'; void main() { @@ -103,8 +104,64 @@ void main() { expect(bridge.lastSystemPrompt, contains('get_recent_mail')); expect(bridge.lastSystemPrompt, contains('search_mail')); expect(bridge.lastSystemPrompt, contains('find_mail_deadlines')); + expect( + bridge.lastSystemPrompt, + isNot(contains(nativeSetFlashlightToolName)), + ); }, ); + + test('local provider advertises only supported native tools', () async { + final bridge = _FakeNativeBridge( + 'Plain local response.', + nativeTools: const >[ + { + 'name': nativeDeviceStatusToolName, + 'supported': true, + }, + { + 'name': nativeSetFlashlightToolName, + 'supported': false, + }, + ], + ); + final provider = LocalNativeLlmProvider(bridge); + + await provider.send( + AgentLlmRequest( + config: const AgentConfig( + provider: AgentProvider.local, + cloudEndpoint: 'https://example.invalid/v1/chat/completions', + cloudModel: 'test-model', + hasApiKey: false, + localModelId: 'test-local', + localModelPath: '/tmp/model.litertlm', + ), + sessions: const [], + activeSessionId: null, + userText: 'What is my device status?', + context: const PromptContext( + profile: null, + memory: '', + worldState: {}, + ), + memoryText: '', + appendMemory: (_) async {}, + readSchedule: () async => 'No schedule.', + mailTools: MailToolRunner( + repository: MailRepository.test(), + profile: null, + ), + onToolTrace: (_) {}, + ), + ); + + expect(bridge.lastSystemPrompt, contains(nativeDeviceStatusToolName)); + expect( + bridge.lastSystemPrompt, + isNot(contains(nativeSetFlashlightToolName)), + ); + }); } class _FakeLlmProvider implements AgentLlmProvider { @@ -129,11 +186,20 @@ class _FakeLlmProvider implements AgentLlmProvider { } class _FakeNativeBridge extends NativeBridge { - _FakeNativeBridge(this.response); + _FakeNativeBridge( + this.response, { + this.nativeTools = const >[], + }); final String response; + final List> nativeTools; String? lastSystemPrompt; + @override + Future> getNativeToolCapabilities() async { + return {'nativeTools': nativeTools}; + } + @override Future sendMessage( String text, { diff --git a/flutter_app/test/cloud_agent_client_test.dart b/flutter_app/test/cloud_agent_client_test.dart index 459616b..77d4199 100644 --- a/flutter_app/test/cloud_agent_client_test.dart +++ b/flutter_app/test/cloud_agent_client_test.dart @@ -8,6 +8,7 @@ import 'package:studyos_agent/src/cloud_tool_definitions.dart'; import 'package:studyos_agent/src/mail_repository.dart'; import 'package:studyos_agent/src/mail_tools.dart'; import 'package:studyos_agent/src/models.dart'; +import 'package:studyos_agent/src/native_tool_router.dart'; import 'package:studyos_agent/src/prompt_context.dart'; void main() { @@ -32,6 +33,21 @@ void main() { 'find_mail_deadlines', ]), ); + expect(toolNames, isNot(contains(nativeDeviceStatusToolName))); + }); + + test('cloud tools include only supported native tools', () { + final toolNames = + cloudToolDefinitions( + supportedNativeToolNames: {nativeDeviceStatusToolName}, + ) + .map((tool) => tool['function']) + .whereType() + .map((function) => function['name']) + .toList(); + + expect(toolNames, contains(nativeDeviceStatusToolName)); + expect(toolNames, isNot(contains(nativeSetFlashlightToolName))); }); test('emits traces for cloud tool calls', () async { diff --git a/flutter_app/test/native_tool_router_test.dart b/flutter_app/test/native_tool_router_test.dart new file mode 100644 index 0000000..c21da8a --- /dev/null +++ b/flutter_app/test/native_tool_router_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/native_bridge.dart'; +import 'package:studyos_agent/src/native_tool_router.dart'; + +void main() { + test('NativeToolRouter executes supported native tools', () async { + final bridge = _FakeNativeBridge( + capabilities: { + 'nativeTools': >[ + { + 'name': nativeSearchYoutubeToolName, + 'supported': true, + }, + ], + }, + response: 'Opened YouTube search.', + ); + final router = NativeToolRouter(bridge); + + final response = await router.execute( + nativeSearchYoutubeToolName, + '{"query":"study techniques"}', + ); + + expect(response, 'Opened YouTube search.'); + expect(bridge.executedTool, nativeSearchYoutubeToolName); + expect(bridge.executedArguments, { + 'query': 'study techniques', + }); + }); + + test('NativeToolRouter returns capability reason when unsupported', () async { + final bridge = _FakeNativeBridge( + capabilities: { + 'nativeTools': >[ + { + 'name': nativeOpenInstalledAppToolName, + 'supported': false, + 'reason': 'iOS cannot open arbitrary installed apps.', + }, + ], + }, + ); + final router = NativeToolRouter(bridge); + + final response = await router.execute( + nativeOpenInstalledAppToolName, + '{"name":"Camera"}', + ); + + expect( + response, + 'Native tool is not supported on this device: open_installed_app. ' + 'iOS cannot open arbitrary installed apps.', + ); + expect(bridge.executedTool, isNull); + }); + + test('NativeToolRouter lists only supported active native tools', () async { + final bridge = _FakeNativeBridge( + capabilities: { + 'nativeTools': >[ + { + 'name': nativeDeviceStatusToolName, + 'supported': true, + }, + { + 'name': nativeOpenInstalledAppToolName, + 'supported': false, + }, + { + 'name': nativeCreateReminderToolName, + 'supported': true, + }, + ], + }, + ); + final router = NativeToolRouter(bridge); + + expect(await router.supportedToolNames(), { + nativeDeviceStatusToolName, + }); + }); + + test('NativeToolRouter rejects non-object JSON arguments', () async { + final router = NativeToolRouter(_FakeNativeBridge()); + + expect( + await router.execute(nativeSetFlashlightToolName, 'true'), + 'Native tool arguments were not valid JSON.', + ); + }); + + test('NativeToolRouter keeps reminder inactive for #30', () async { + final router = NativeToolRouter(_FakeNativeBridge()); + + expect( + await router.execute(nativeCreateReminderToolName, '{}'), + 'Native tool is not available: create_reminder', + ); + }); +} + +class _FakeNativeBridge extends NativeBridge { + _FakeNativeBridge({ + this.capabilities = const {}, + this.response = 'Native response.', + }); + + final Map capabilities; + final String response; + String? executedTool; + Map? executedArguments; + + @override + Future> getNativeToolCapabilities() async { + return capabilities; + } + + @override + Future executeNativeTool( + String name, + Map arguments, + ) async { + executedTool = name; + executedArguments = arguments; + return response; + } +} diff --git a/flutter_app/test/studyos_tool_executor_test.dart b/flutter_app/test/studyos_tool_executor_test.dart index a4780b5..0dddbc2 100644 --- a/flutter_app/test/studyos_tool_executor_test.dart +++ b/flutter_app/test/studyos_tool_executor_test.dart @@ -1,7 +1,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:studyos_agent/src/mail_repository.dart'; import 'package:studyos_agent/src/mail_tools.dart'; +import 'package:studyos_agent/src/native_tool_router.dart'; import 'package:studyos_agent/src/prompt_context.dart'; +import 'package:studyos_agent/src/studyos_tool_catalog.dart'; import 'package:studyos_agent/src/studyos_tool_executor.dart'; void main() { @@ -52,12 +54,51 @@ void main() { ); }, ); + + test('StudyOS catalog exposes active native tools for #30 only', () { + final toolNames = studyOsTools.map((tool) => tool.name).toSet(); + + expect(toolNames, contains(nativeDeviceStatusToolName)); + expect(toolNames, contains(nativeSetFlashlightToolName)); + expect(toolNames, contains(nativeOpenInstalledAppToolName)); + expect(toolNames, contains(nativeSearchYoutubeToolName)); + expect(toolNames, contains(nativeOpenSystemSettingToolName)); + expect(toolNames, isNot(contains(nativeCreateReminderToolName))); + }); + + test( + 'StudyOsToolExecutor routes native tools through native runner', + () async { + final nativeTools = _FakeNativeToolRunner('Flashlight enabled.'); + final executor = StudyOsToolExecutor(); + + final response = await executor.execute( + nativeSetFlashlightToolName, + '{"enabled":true}', + _context(nativeTools: nativeTools), + ); + + expect(response, 'Flashlight enabled.'); + expect(nativeTools.calls, [nativeSetFlashlightToolName]); + expect(nativeTools.arguments, ['{"enabled":true}']); + }, + ); + + test('StudyOsToolExecutor gates native tools without runner', () async { + final executor = StudyOsToolExecutor(); + + expect( + await executor.execute(nativeDeviceStatusToolName, '{}', _context()), + 'Native tool is not available in this runtime: get_device_status', + ); + }); } StudyOsToolContext _context({ Future Function(String text)? appendMemory, Future Function()? readMemory, Future Function()? readSchedule, + NativeToolRunner? nativeTools, }) { return StudyOsToolContext( promptContext: const PromptContext( @@ -69,5 +110,24 @@ StudyOsToolContext _context({ readMemory: readMemory ?? () async => '', readSchedule: readSchedule ?? () async => '', mailTools: MailToolRunner(repository: MailRepository(), profile: null), + nativeTools: nativeTools, ); } + +class _FakeNativeToolRunner implements NativeToolRunner { + _FakeNativeToolRunner(this.response); + + final String response; + final calls = []; + final arguments = []; + + @override + Future> supportedToolNames() async => activeNativeToolNames; + + @override + Future execute(String toolName, String arguments) async { + calls.add(toolName); + this.arguments.add(arguments); + return response; + } +}