From 16f5f60979c659978cbd3b97e934396367bb0597 Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Wed, 24 Jun 2026 18:00:45 +0000 Subject: [PATCH 1/5] feat: add native tool routing contract --- .../AndroidNativeToolExecutor.kt | 97 +++++++++++++++ .../com/studyos/studyos_agent/MainActivity.kt | 32 ++++- .../ios/Runner/StudyOSNativeBridge.swift | 34 +++++- flutter_app/lib/src/agent_llm_provider.dart | 2 + flutter_app/lib/src/agent_request_runner.dart | 5 +- flutter_app/lib/src/cloud_agent_client.dart | 12 +- flutter_app/lib/src/native_bridge.dart | 11 ++ flutter_app/lib/src/native_tool_router.dart | 111 ++++++++++++++++++ flutter_app/lib/src/studyos_tool_catalog.dart | 72 ++++++++++++ .../lib/src/studyos_tool_executor.dart | 23 ++++ flutter_app/test/native_tool_router_test.dart | 101 ++++++++++++++++ .../test/studyos_tool_executor_test.dart | 54 +++++++++ 12 files changed, 549 insertions(+), 5 deletions(-) create mode 100644 flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidNativeToolExecutor.kt create mode 100644 flutter_app/lib/src/native_tool_router.dart create mode 100644 flutter_app/test/native_tool_router_test.dart 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..464a8da 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,7 @@ class MainActivity : FlutterActivity() { "initialize" -> result.success(initializeNativeLayer()) "getWorldState" -> result.success(worldStateMap()) "getCapabilities" -> result.success(capabilities()) + "executeNativeTool" -> executeNativeTool(call, result) "listLocalModels" -> result.success(localModelStore().listModels()) "downloadLocalModel" -> downloadLocalModel(call, result) "cancelLocalModelDownload" -> { @@ -296,6 +298,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,8 +422,10 @@ 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", diff --git a/flutter_app/ios/Runner/StudyOSNativeBridge.swift b/flutter_app/ios/Runner/StudyOSNativeBridge.swift index 7fae6ba..378eb8f 100644 --- a/flutter_app/ios/Runner/StudyOSNativeBridge.swift +++ b/flutter_app/ios/Runner/StudyOSNativeBridge.swift @@ -59,6 +59,8 @@ final class StudyOSNativeBridge: NSObject, FlutterStreamHandler, CLLocationManag result(worldState()) case "getCapabilities": result(capabilities()) + case "executeNativeTool": + executeNativeTool(call: call, result: result) case "publishIntentSnapshot": publishIntentSnapshot(call: call, result: result) case "consumePendingIntentPrompt": @@ -209,6 +211,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 +259,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 +284,18 @@ 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 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..906a050 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'; @@ -117,6 +118,7 @@ class LocalNativeLlmProvider implements AgentLlmProvider { readMemory: () async => request.memoryText, readSchedule: request.readSchedule, mailTools: request.mailTools, + nativeTools: NativeToolRouter(_bridge), ); for (var round = 0; round < _maxToolRounds; round += 1) { 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..2ab6f51 100644 --- a/flutter_app/lib/src/cloud_agent_client.dart +++ b/flutter_app/lib/src/cloud_agent_client.dart @@ -5,17 +5,24 @@ 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}) + CloudAgentClient({ + http.Client? httpClient, + StudyOsToolExecutor? toolExecutor, + NativeToolRunner? nativeTools, + }) : _httpClient = httpClient ?? http.Client(), - _toolExecutor = toolExecutor ?? const StudyOsToolExecutor(); + _toolExecutor = toolExecutor ?? const StudyOsToolExecutor(), + _nativeTools = nativeTools; final http.Client _httpClient; final StudyOsToolExecutor _toolExecutor; + final NativeToolRunner? _nativeTools; Future sendMessage({ required AgentConfig config, @@ -61,6 +68,7 @@ class CloudAgentClient { readMemory: readMemory, readSchedule: readSchedule, mailTools: mailTools, + nativeTools: _nativeTools, ); for (final call in toolCalls) { onToolTrace?.call(_traceForCall(call, 'running')); diff --git a/flutter_app/lib/src/native_bridge.dart b/flutter_app/lib/src/native_bridge.dart index cd46f76..20d44a7 100644 --- a/flutter_app/lib/src/native_bridge.dart +++ b/flutter_app/lib/src/native_bridge.dart @@ -35,6 +35,17 @@ class NativeBridge { 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..58b1387 --- /dev/null +++ b/flutter_app/lib/src/native_tool_router.dart @@ -0,0 +1,111 @@ +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 execute(String toolName, String arguments); +} + +class NativeToolRouter implements NativeToolRunner { + const NativeToolRouter(this._bridge); + + final NativeBridge _bridge; + + @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 = NativeToolCapabilities.fromMap( + await _bridge.getCapabilities(), + ); + 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}'; + } + } + + 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); + } +} + +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..de98656 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,6 +229,11 @@ const studyOsTools = [ searchMailTool, getMailMessageTool, findMailDeadlinesTool, + getDeviceStatusTool, + setFlashlightTool, + openInstalledAppTool, + searchYoutubeTool, + openSystemSettingTool, ]; StudyOsToolSpec? studyOsToolByName(String name) { 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/native_tool_router_test.dart b/flutter_app/test/native_tool_router_test.dart new file mode 100644 index 0000000..1aa4df9 --- /dev/null +++ b/flutter_app/test/native_tool_router_test.dart @@ -0,0 +1,101 @@ +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 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> getCapabilities() async => 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..7ff0274 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,48 @@ 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 +107,21 @@ 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 execute(String toolName, String arguments) async { + calls.add(toolName); + this.arguments.add(arguments); + return response; + } +} From 7c15271daf42164bc56edf1ab2d58c4b66482734 Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Wed, 24 Jun 2026 19:11:08 +0000 Subject: [PATCH 2/5] fix: filter native tool advertisements --- flutter_app/lib/src/agent_llm_provider.dart | 16 +++-- flutter_app/lib/src/cloud_agent_client.dart | 8 ++- .../lib/src/cloud_tool_definitions.dart | 8 ++- flutter_app/lib/src/native_tool_router.dart | 27 +++++-- flutter_app/lib/src/studyos_tool_catalog.dart | 12 ++++ flutter_app/test/agent_llm_provider_test.dart | 71 ++++++++++++++++++- flutter_app/test/cloud_agent_client_test.dart | 15 ++++ flutter_app/test/native_tool_router_test.dart | 28 ++++++++ .../test/studyos_tool_executor_test.dart | 3 + 9 files changed, 176 insertions(+), 12 deletions(-) diff --git a/flutter_app/lib/src/agent_llm_provider.dart b/flutter_app/lib/src/agent_llm_provider.dart index 906a050..e99caac 100644 --- a/flutter_app/lib/src/agent_llm_provider.dart +++ b/flutter_app/lib/src/agent_llm_provider.dart @@ -105,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, @@ -118,7 +123,7 @@ class LocalNativeLlmProvider implements AgentLlmProvider { readMemory: () async => request.memoryText, readSchedule: request.readSchedule, mailTools: request.mailTools, - nativeTools: NativeToolRouter(_bridge), + nativeTools: nativeTools, ); for (var round = 0; round < _maxToolRounds; round += 1) { @@ -165,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() @@ -183,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/cloud_agent_client.dart b/flutter_app/lib/src/cloud_agent_client.dart index 2ab6f51..7dde7ff 100644 --- a/flutter_app/lib/src/cloud_agent_client.dart +++ b/flutter_app/lib/src/cloud_agent_client.dart @@ -47,11 +47,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); @@ -124,6 +127,7 @@ class CloudAgentClient { required List history, required String userText, required PromptContext context, + required Set supportedNativeToolNames, }) { final historyWithoutCurrent = history.isNotEmpty && @@ -143,7 +147,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..2d74a4c 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_tool_router.dart b/flutter_app/lib/src/native_tool_router.dart index 58b1387..e841c10 100644 --- a/flutter_app/lib/src/native_tool_router.dart +++ b/flutter_app/lib/src/native_tool_router.dart @@ -20,13 +20,22 @@ const activeNativeToolNames = { }; abstract class NativeToolRunner { + Future> supportedToolNames(); + Future execute(String toolName, String arguments); } class NativeToolRouter implements NativeToolRunner { - const NativeToolRouter(this._bridge); + 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 { @@ -41,9 +50,7 @@ class NativeToolRouter implements NativeToolRunner { return 'Native tool arguments were not valid JSON.'; } - final capabilities = NativeToolCapabilities.fromMap( - await _bridge.getCapabilities(), - ); + final capabilities = await _loadCapabilities(); final support = capabilities.supportFor(toolName); if (!support.supported) { return support.messageFor(toolName); @@ -56,6 +63,12 @@ class NativeToolRouter implements NativeToolRunner { } } + Future _loadCapabilities() { + return _capabilities ??= _bridge + .getCapabilities() + .then(NativeToolCapabilities.fromMap); + } + Map _decodeArguments(String arguments) { final trimmed = arguments.trim(); if (trimmed.isEmpty) return {}; @@ -93,6 +106,12 @@ class NativeToolCapabilities { 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 { diff --git a/flutter_app/lib/src/studyos_tool_catalog.dart b/flutter_app/lib/src/studyos_tool_catalog.dart index de98656..d1bf946 100644 --- a/flutter_app/lib/src/studyos_tool_catalog.dart +++ b/flutter_app/lib/src/studyos_tool_catalog.dart @@ -236,6 +236,18 @@ const studyOsTools = [ 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/test/agent_llm_provider_test.dart b/flutter_app/test/agent_llm_provider_test.dart index 1179c36..f814ec1 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,6 +104,65 @@ 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)), + ); }, ); } @@ -129,11 +189,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> getCapabilities() 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..88e9f36 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,20 @@ 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 index 1aa4df9..5259227 100644 --- a/flutter_app/test/native_tool_router_test.dart +++ b/flutter_app/test/native_tool_router_test.dart @@ -56,6 +56,34 @@ void main() { 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()); diff --git a/flutter_app/test/studyos_tool_executor_test.dart b/flutter_app/test/studyos_tool_executor_test.dart index 7ff0274..3207bb3 100644 --- a/flutter_app/test/studyos_tool_executor_test.dart +++ b/flutter_app/test/studyos_tool_executor_test.dart @@ -118,6 +118,9 @@ class _FakeNativeToolRunner implements NativeToolRunner { final calls = []; final arguments = []; + @override + Future> supportedToolNames() async => activeNativeToolNames; + @override Future execute(String toolName, String arguments) async { calls.add(toolName); From 09ef7dd35c8b389886098da2035af54c5849820e Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Wed, 24 Jun 2026 21:53:19 +0000 Subject: [PATCH 3/5] fix: avoid heavyweight capability checks during send --- .../kotlin/com/studyos/studyos_agent/MainActivity.kt | 9 +++++++++ flutter_app/ios/Runner/StudyOSNativeBridge.swift | 10 ++++++++++ flutter_app/lib/src/native_bridge.dart | 7 +++++++ flutter_app/lib/src/native_tool_router.dart | 2 +- flutter_app/test/agent_llm_provider_test.dart | 2 +- flutter_app/test/native_tool_router_test.dart | 4 +++- 6 files changed, 31 insertions(+), 3 deletions(-) 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 464a8da..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 @@ -68,6 +68,7 @@ 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) @@ -432,6 +433,14 @@ class MainActivity : FlutterActivity() { ) + 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 378eb8f..56a75c6 100644 --- a/flutter_app/ios/Runner/StudyOSNativeBridge.swift +++ b/flutter_app/ios/Runner/StudyOSNativeBridge.swift @@ -59,6 +59,8 @@ 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": @@ -296,6 +298,14 @@ final class StudyOSNativeBridge: NSObject, FlutterStreamHandler, CLLocationManag ] } + 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/native_bridge.dart b/flutter_app/lib/src/native_bridge.dart index 20d44a7..98e4eb6 100644 --- a/flutter_app/lib/src/native_bridge.dart +++ b/flutter_app/lib/src/native_bridge.dart @@ -35,6 +35,13 @@ class NativeBridge { return result ?? const {}; } + Future> getNativeToolCapabilities() async { + final result = await _methods.invokeMapMethod( + 'getNativeToolCapabilities', + ); + return result ?? const {}; + } + Future executeNativeTool( String name, Map arguments, diff --git a/flutter_app/lib/src/native_tool_router.dart b/flutter_app/lib/src/native_tool_router.dart index e841c10..09a9ef0 100644 --- a/flutter_app/lib/src/native_tool_router.dart +++ b/flutter_app/lib/src/native_tool_router.dart @@ -65,7 +65,7 @@ class NativeToolRouter implements NativeToolRunner { Future _loadCapabilities() { return _capabilities ??= _bridge - .getCapabilities() + .getNativeToolCapabilities() .then(NativeToolCapabilities.fromMap); } diff --git a/flutter_app/test/agent_llm_provider_test.dart b/flutter_app/test/agent_llm_provider_test.dart index f814ec1..c6664b3 100644 --- a/flutter_app/test/agent_llm_provider_test.dart +++ b/flutter_app/test/agent_llm_provider_test.dart @@ -199,7 +199,7 @@ class _FakeNativeBridge extends NativeBridge { String? lastSystemPrompt; @override - Future> getCapabilities() async { + Future> getNativeToolCapabilities() async { return {'nativeTools': nativeTools}; } diff --git a/flutter_app/test/native_tool_router_test.dart b/flutter_app/test/native_tool_router_test.dart index 5259227..cef4dba 100644 --- a/flutter_app/test/native_tool_router_test.dart +++ b/flutter_app/test/native_tool_router_test.dart @@ -115,7 +115,9 @@ class _FakeNativeBridge extends NativeBridge { Map? executedArguments; @override - Future> getCapabilities() async => capabilities; + Future> getNativeToolCapabilities() async { + return capabilities; + } @override Future executeNativeTool( From 99dfe12185788ee52af7f7f2d17c1ee350c8942a Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Wed, 24 Jun 2026 22:28:08 +0000 Subject: [PATCH 4/5] style: format native tool routing changes --- flutter_app/lib/src/cloud_agent_client.dart | 7 +- .../lib/src/cloud_tool_definitions.dart | 6 +- flutter_app/lib/src/native_tool_router.dart | 6 +- flutter_app/test/agent_llm_provider_test.dart | 99 +++++++++---------- flutter_app/test/cloud_agent_client_test.dart | 15 +-- flutter_app/test/native_tool_router_test.dart | 8 +- .../test/studyos_tool_executor_test.dart | 27 ++--- 7 files changed, 83 insertions(+), 85 deletions(-) diff --git a/flutter_app/lib/src/cloud_agent_client.dart b/flutter_app/lib/src/cloud_agent_client.dart index 7dde7ff..6bc9c0b 100644 --- a/flutter_app/lib/src/cloud_agent_client.dart +++ b/flutter_app/lib/src/cloud_agent_client.dart @@ -15,10 +15,9 @@ class CloudAgentClient { http.Client? httpClient, StudyOsToolExecutor? toolExecutor, NativeToolRunner? nativeTools, - }) - : _httpClient = httpClient ?? http.Client(), - _toolExecutor = toolExecutor ?? const StudyOsToolExecutor(), - _nativeTools = nativeTools; + }) : _httpClient = httpClient ?? http.Client(), + _toolExecutor = toolExecutor ?? const StudyOsToolExecutor(), + _nativeTools = nativeTools; final http.Client _httpClient; final StudyOsToolExecutor _toolExecutor; diff --git a/flutter_app/lib/src/cloud_tool_definitions.dart b/flutter_app/lib/src/cloud_tool_definitions.dart index 2d74a4c..42a4015 100644 --- a/flutter_app/lib/src/cloud_tool_definitions.dart +++ b/flutter_app/lib/src/cloud_tool_definitions.dart @@ -3,9 +3,9 @@ import 'studyos_tool_catalog.dart'; List> cloudToolDefinitions({ Set supportedNativeToolNames = const {}, }) { - return studyOsToolsForNativeSupport(supportedNativeToolNames) - .map(_tool) - .toList(); + return studyOsToolsForNativeSupport( + supportedNativeToolNames, + ).map(_tool).toList(); } Map _tool(StudyOsToolSpec spec) { diff --git a/flutter_app/lib/src/native_tool_router.dart b/flutter_app/lib/src/native_tool_router.dart index 09a9ef0..b5f0c4e 100644 --- a/flutter_app/lib/src/native_tool_router.dart +++ b/flutter_app/lib/src/native_tool_router.dart @@ -64,9 +64,9 @@ class NativeToolRouter implements NativeToolRunner { } Future _loadCapabilities() { - return _capabilities ??= _bridge - .getNativeToolCapabilities() - .then(NativeToolCapabilities.fromMap); + return _capabilities ??= _bridge.getNativeToolCapabilities().then( + NativeToolCapabilities.fromMap, + ); } Map _decodeArguments(String arguments) { diff --git a/flutter_app/test/agent_llm_provider_test.dart b/flutter_app/test/agent_llm_provider_test.dart index c6664b3..3103ca7 100644 --- a/flutter_app/test/agent_llm_provider_test.dart +++ b/flutter_app/test/agent_llm_provider_test.dart @@ -111,60 +111,57 @@ void main() { }, ); - 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); + 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: (_) {}, + 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)), - ); - }, - ); + expect(bridge.lastSystemPrompt, contains(nativeDeviceStatusToolName)); + expect( + bridge.lastSystemPrompt, + isNot(contains(nativeSetFlashlightToolName)), + ); + }); } class _FakeLlmProvider implements AgentLlmProvider { diff --git a/flutter_app/test/cloud_agent_client_test.dart b/flutter_app/test/cloud_agent_client_test.dart index 88e9f36..77d4199 100644 --- a/flutter_app/test/cloud_agent_client_test.dart +++ b/flutter_app/test/cloud_agent_client_test.dart @@ -37,13 +37,14 @@ void main() { }); test('cloud tools include only supported native tools', () { - final toolNames = cloudToolDefinitions( - supportedNativeToolNames: {nativeDeviceStatusToolName}, - ) - .map((tool) => tool['function']) - .whereType() - .map((function) => function['name']) - .toList(); + final toolNames = + cloudToolDefinitions( + supportedNativeToolNames: {nativeDeviceStatusToolName}, + ) + .map((tool) => tool['function']) + .whereType() + .map((function) => function['name']) + .toList(); expect(toolNames, contains(nativeDeviceStatusToolName)); expect(toolNames, isNot(contains(nativeSetFlashlightToolName))); diff --git a/flutter_app/test/native_tool_router_test.dart b/flutter_app/test/native_tool_router_test.dart index cef4dba..c21da8a 100644 --- a/flutter_app/test/native_tool_router_test.dart +++ b/flutter_app/test/native_tool_router_test.dart @@ -77,13 +77,11 @@ void main() { ); final router = NativeToolRouter(bridge); - expect( - await router.supportedToolNames(), - {nativeDeviceStatusToolName}, - ); + expect(await router.supportedToolNames(), { + nativeDeviceStatusToolName, + }); }); - test('NativeToolRouter rejects non-object JSON arguments', () async { final router = NativeToolRouter(_FakeNativeBridge()); diff --git a/flutter_app/test/studyos_tool_executor_test.dart b/flutter_app/test/studyos_tool_executor_test.dart index 3207bb3..0dddbc2 100644 --- a/flutter_app/test/studyos_tool_executor_test.dart +++ b/flutter_app/test/studyos_tool_executor_test.dart @@ -66,20 +66,23 @@ void main() { expect(toolNames, isNot(contains(nativeCreateReminderToolName))); }); - test('StudyOsToolExecutor routes native tools through native runner', () async { - final nativeTools = _FakeNativeToolRunner('Flashlight enabled.'); - final executor = StudyOsToolExecutor(); + 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), - ); + final response = await executor.execute( + nativeSetFlashlightToolName, + '{"enabled":true}', + _context(nativeTools: nativeTools), + ); - expect(response, 'Flashlight enabled.'); - expect(nativeTools.calls, [nativeSetFlashlightToolName]); - expect(nativeTools.arguments, ['{"enabled":true}']); - }); + expect(response, 'Flashlight enabled.'); + expect(nativeTools.calls, [nativeSetFlashlightToolName]); + expect(nativeTools.arguments, ['{"enabled":true}']); + }, + ); test('StudyOsToolExecutor gates native tools without runner', () async { final executor = StudyOsToolExecutor(); From 785f8de2b66f46f40e9ed57242286a989553feb2 Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Wed, 24 Jun 2026 22:30:43 +0000 Subject: [PATCH 5/5] fix: preserve native tools constructor API --- flutter_app/lib/src/cloud_agent_client.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flutter_app/lib/src/cloud_agent_client.dart b/flutter_app/lib/src/cloud_agent_client.dart index 6bc9c0b..4158fc2 100644 --- a/flutter_app/lib/src/cloud_agent_client.dart +++ b/flutter_app/lib/src/cloud_agent_client.dart @@ -17,6 +17,8 @@ class CloudAgentClient { 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;