Skip to content

Commit fe16f0a

Browse files
feat(mobile): give the Apple on-device model the chat's tools (#7341)
* feat(harness-sdk): the kilo harness SDK #5878 * feat(mobile): rebuild the chat tab on the harness SDK #5895 * chore(kilo-mcp): refresh catalog.json from CI * feat(mobile): connect chat to the Kilo MCP server with the signed-in session (#6420) #6420 * chore: publish the verified app-agent settings tools + remote MCP stack as one PR (level 1) * fix(mobile): preserve settings and remote MCP state across cold loads * fix(mobile): satisfy async and control-flow lint rules * fix(harness-sdk): keep the library's abort link until the operation ends fetch resolves when the headers arrive, so the per-request unlink let close() leave an open stream running until the deadline timer fired. The deadline now holds each joined signal once and removes it at stop. * fix(harness-sdk): bound the credential by the operation's deadline The token was asked before the deadline existed, so a slow refresh could hold a chat open past the discovery bound. The deadline now comes first and the credential races it. * fix(harness-sdk): give a call a deadline above the time it is waited on inline A remote tool is waited on for 15 s before the session backgrounds it, and the call's own default deadline was also 15 s, so a backgrounded call failed at that moment instead of answering. A call now defaults to 60 s; discovery keeps 15 s. * fix(mobile): publish the Kilo tools answer that a cache hit serves The registry builds a chat from the snapshot, not from the return value. A cache hit returned the ready answer while another scope's discovery had published connecting over it, so the chat opened on the base tools. * fix(mobile): keep each Kilo tools discovery on its caller's deadline A caller that joined a running discovery took the first caller's deadline, so an open that met a Retry waited fifteen seconds and a Retry that met an open was cut to four. A caller now joins only a discovery with its own deadline, and only the latest discovery is published. * fix(mobile): lock the Kilo tools switch when there is nothing to use In the unavailable state the switch read off while the chat's setting was on, so a tap wrote the value already stored and the switch snapped back. The view now says whether the switch can move, and the sheet disables it when not. * fix(deps): rebuild the lockfile on main's pins The merge rebuilt the lockfile from the branch side, so pnpm resolved again every package main had added since. That moved @peculiar/asn1-* to 2.9.5 and broke the passkey verification tests in apps/web. The lockfile is now main's, with only the harness SDK's own entries added. * fix(mobile): pass the segments to the toaster's tab bar check Main added a shouldHideTabBar call in app-root-providers.tsx with the old one-argument form. This branch reads the chat conversation from the segments, so the call now passes them too. * test(mobile): read the hoisted query client mock in the account switch test Main's lifecycle file already holds queryClientMock at module scope. The branch's test declared it again from a dynamic import, which lint refuses as a shadow and as an unbound method. * fix(mobile): describe the spend alerts notification setting Main added the spendAlerts notification category. The agent settings catalog maps every category to a description, so typecheck failed on the missing one. * fix(mobile): use the shared MCP servers copy in the chat sheet Main added profiles.mcp.title and profiles.mcp.add with the same English copy as modelChat.mcp.serversSection and modelChat.mcp.addServer, and the catalog check refuses two keys for one copy. The chat section now reads the profile keys, and the two chat keys are removed from every catalog. * test(mobile): load the real config module in the MCP URL test vitest.setup.ts now stubs @/lib/config for every pure suite, so the plain dynamic import in config.test.ts returned the stub and KILO_MCP_URL read as undefined. The test now loads the module through vi.importActual. * test(mobile): allow the remote MCP id slug in the case guard slugFor in remote-mcp-store.ts lowercases a server name to build the callable tool-name id. The id is never shown, so the file joins the non-display list. * feat(mobile): support configurable Quick Chat backends * test(harness): construct synthetic credential URL explicitly * fix(mobile): address Quick Chat backend review findings * fix(mobile): reopen a failed chat from Retry * test(mobile): allow protocol-identifier case normalization in backend files * fix(mobile): wait for stored backends before the send check * feat(mobile): add on-device Apple Foundation Models to Quick Chat * feat(mobile): add on-device Gemini Nano to Quick Chat on Android * fix(mobile): reference the Retry label in the on-device background copy * feat(mobile): add llama.rn GGUF models to Quick Chat * style(mobile): format the on-device GGUF files * test(mobile): allow the GGUF file-extension comparison in the case guard * fix(mobile): keep unreadable model entries from deleting downloads * test(mobile): split the model-index policy tests into their own suite * fix(mobile): never rewrite a model list holding an unreadable entry * fix(mobile): keep any list that did not parse in full from being rewritten * fix(mobile): read a GGUF result that carries nulls or only text * fix(mobile): treat a null parsed content as absent * test(mobile): pin the null-heavy llama.rn result at the client level * feat(mobile): give the Apple on-device model the chat's tools Foundation Models calls harness tools through dynamic tools. The model's calls go to JavaScript as harness toolCall parts with the transcript ids. The generation waits while the harness runs them, and the next request resumes it with the results. Later requests rebuild the transcript with the tool calls and their outputs. JavaScript reduces each JSON Schema to what DynamicGenerationSchema can express. A tool that does not fit is left out and logged. Gemini Nano stays text-only because the ML Kit Prompt API has no function calling. * fix(mobile): keep native schema and message types module-private * fix(mobile): count tokens for a request that ends on tool outputs A resumed tool round sends a request whose last entry is a tool output. The Apple module refused to count it, so usage fell back to the estimate. Only generation now needs a user prompt last. * fix(mobile): drop the duplicate llama.rn proguard rule The merge of main added a second extraProguardRules next to main's combined rule, which app.config.ts rejects as a duplicate key. Keep main's combined rule. * fix(kilo-app): build the harness SDK before the EAS bundle The release EAS build bundles apps/mobile with Metro, which resolves @kilocode/harness-sdk through its package.json exports to dist/index.js. The workspace package is not built on EAS, so the Android and iOS bundles fail with 'none of these files exist' and the release never submits. Add the eas-build-post-install hook the EAS build runs after install. It builds the SDK, so Metro finds dist before it bundles. Verified with expo export:embed --eager --platform android: it fails without dist and succeeds after the hook builds it. * ci(trufflehog): exclude the synthetic-credential test fixture security.test.ts builds a credential URL from the literal 'user:private-key@provider.example'. The commit that added it stays in this branch's history, so trufflehog reports it on every base..head scan even though a later commit replaced it with a constructed value. Exclude the fixture path. * fix(db): drop the stale duplicate 0263_drop_quick_chat migration main lands the drop as 0274_drop_quick_chat.sql (registered in the journal). The same drop sits in this branch's lineage as 0263_drop_quick_chat.sql, unregistered and colliding with 0263_spend_alerts_rollup_covering_index. The merge kept it because main never had that path. Remove it; the tree now matches main. * fix(mobile): keep NativeImage module-private knip flags the export as unused: only native-request.ts builds an image part. Drop the export keyword. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 86d00bb commit fe16f0a

14 files changed

Lines changed: 1402 additions & 233 deletions

‎apps/mobile/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ app's minSdk does not change.
107107
- Gemini Nano runs only while Kilo is in the foreground. Leaving the app stops the reply with fixed copy; **Retry** works after returning.
108108

109109
- **Manage backends** shows its status and why it is unavailable: Apple Intelligence off, device not eligible, model not ready, or iOS older than 26.
110-
- On-device models are text-only. They never receive tool definitions.
110+
- Apple's model receives the chat's tools through Foundation Models tool calling. When the model calls a tool, the generation waits, JavaScript runs the tool through the harness, and the next request resumes the same generation with the results. A tool whose arguments schema Foundation Models cannot express (a union, a reference, a map with free keys) is left out and logged; an optional argument of that kind is left out of its tool.
111+
- Gemini Nano is text-only: the ML Kit GenAI Prompt API has no function calling, so it never receives tool definitions.
111112
- An unavailable or busy model fails the send with fixed copy. It never falls back to Kilo or another backend.
112113
- Switching between Kilo, a custom backend, and an on-device model shows the context-transfer warning.
113114
- Usage comes from the model when it reports counts, then its token counter, then an estimate of three characters per token, so compaction still runs on the small window.

‎apps/mobile/modules/kilo-apple-model/ios/KiloAppleModel.podspec‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ Pod::Spec.new do |s|
22
s.name = 'KiloAppleModel'
33
s.version = '1.0.0'
44
s.summary = 'Inference-only Apple Foundation Models bridge for Quick Chat'
5-
s.description = 'Rebuilds the supplied transcript for each request and streams on-device text inference without storing chats or executing tools.'
5+
s.description = 'Rebuilds the supplied transcript for each request and streams on-device inference without storing chats. Tool calls go to JavaScript, which runs them and returns their results.'
66
s.license = { :type => 'Proprietary' }
77
s.author = 'Kilo'
88
s.homepage = 'https://github.com/Kilo-Org/cloud'

‎apps/mobile/modules/kilo-apple-model/ios/KiloAppleModelModule.swift‎

Lines changed: 324 additions & 27 deletions
Large diffs are not rendered by default.

‎apps/mobile/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"release:internal:ios": "pnpx eas-cli@24.8.0 build -p ios --profile production --auto-submit",
1414
"release:internal:android": "pnpx eas-cli@24.8.0 build -p android --profile production --auto-submit",
1515
"assert:config": "node scripts/assert-expo-config.mjs",
16+
"eas-build-post-install": "pnpm --filter @kilocode/harness-sdk run build",
1617
"typecheck": "tsgo --noEmit",
1718
"lint": "pnpm -w exec oxlint --config apps/mobile/.oxlintrc.json apps/mobile/src",
1819
"format": "oxfmt src",

‎apps/mobile/src/components/chat/model-tools-hint.tsx‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,15 @@ export function ModelToolsHint({ model, backends, localModels }: Readonly<ModelT
4141
const customModel = backends
4242
.find(backend => backend.id === customTarget?.backendId)
4343
?.models.find(one => one.id === customTarget?.modelId);
44-
// System on-device models get no tools, like a custom model without tools.
45-
if (
46-
customModel === undefined &&
47-
ggufModel === undefined &&
48-
!localModels.some(local => local.targetId === model)
49-
) {
44+
const systemModel = localModels.find(local => local.targetId === model);
45+
if (customModel === undefined && ggufModel === undefined && systemModel === undefined) {
5046
return null;
5147
}
52-
const tools = customModel?.tools === true || ggufModel?.tools === true;
48+
// A system model receives tools when its module says it runs the tool loop.
49+
const tools =
50+
customModel?.tools === true ||
51+
ggufModel?.tools === true ||
52+
systemModel?.availability?.tools === true;
5353
return (
5454
<Text className="px-4 pt-2 text-xs text-muted-foreground">
5555
{t(modelCapabilityKey(tools, images))}

‎apps/mobile/src/lib/chat/backend-routing.test.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const apple = vi.hoisted(() => {
2121
maxOutputTokens: 4096,
2222
systemInstructions: true,
2323
tokenCounting: true,
24+
tools: true,
2425
};
2526
return {
2627
available,
@@ -40,6 +41,7 @@ const apple = vi.hoisted(() => {
4041
}
4142
}),
4243
cancel: vi.fn<(id: string) => Promise<void>>().mockResolvedValue(undefined),
44+
resume: vi.fn<(id: string) => Promise<void>>().mockResolvedValue(undefined),
4345
countTokens: vi.fn<() => Promise<number>>().mockResolvedValue(9),
4446
addListener: (_name: string, listener: (event: NativeModelEvent) => void) => {
4547
listeners.add(listener);
@@ -101,8 +103,9 @@ const question = (model: string): ModelRequest => ({
101103
},
102104
});
103105

104-
it('routes an on-device target to its native model without Kilo or tool definitions', async () => {
106+
it('routes an on-device target to its native model without Kilo, with the tools it runs', async () => {
105107
const { client, kilo, fetch } = localRouter();
108+
expect(targetSupportsTools('local:apple')).toBe(false);
106109
const events = await Effect.runPromise(Stream.runCollect(client.stream(question('local:apple'))));
107110
expect([...events]).toMatchObject([
108111
{ kind: 'delta', text: 'On device' },
@@ -111,8 +114,11 @@ it('routes an on-device target to its native model without Kilo or tool definiti
111114
expect(kilo).not.toHaveBeenCalled();
112115
expect(fetch).not.toHaveBeenCalled();
113116
expect(apple.generate).toHaveBeenCalledOnce();
114-
expect(apple.generate.mock.calls[0]?.[0]).not.toHaveProperty('tools');
115-
expect(targetSupportsTools('local:apple')).toBe(false);
117+
expect(apple.generate.mock.calls[0]?.[0]).toMatchObject({
118+
tools: [{ name: 'time', description: 'Current time' }],
119+
});
120+
// The module said it runs the tool loop, so new chats on it are opened with tools.
121+
expect(targetSupportsTools('local:apple')).toBe(true);
116122
expect(targetSupportsTools('kilo/default')).toBe(true);
117123
// The availability read at send time supplies the window that drives compaction.
118124
expect(targetModelFacts('local:apple', [], { apiKinds: ['messages'] })).toEqual({

‎apps/mobile/src/lib/chat/local-models.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ export type LocalModelStatus = {
4141
type SystemModelModule = InstanceType<
4242
typeof NativeModule<{ onModelEvent: (event: NativeModelEvent) => void }>
4343
> &
44-
Required<Omit<NativeModelBridge, 'addListener'>>;
44+
Required<Omit<NativeModelBridge, 'addListener' | 'resume'>> &
45+
// Only a module that runs the tool loop has it: Apple has it, Android does not.
46+
Pick<NativeModelBridge, 'resume'>;
4547

4648
/** A provider that cannot answer its availability check is unavailable, not missing. */
4749
const UNANSWERED: NativeAvailability = {
@@ -102,12 +104,19 @@ function systemModelProvider(
102104
const count = await native.countTokens(request);
103105
return count;
104106
},
107+
...(native.resume === undefined
108+
? {}
109+
: {
110+
resume: async (id, results) => {
111+
await native.resume?.(id, results);
112+
},
113+
}),
105114
addListener: (eventName, listener) => native.addListener(eventName, listener),
106115
}),
107116
facts: () => systemFacts(known.get(provider)),
108-
// System on-device models get no tool definitions. They get images only
109-
// when the last availability answer says the device's model reads them.
110-
supportsTools: () => false,
117+
// The module says whether it runs the tool loop. Until it answers, no tools are sent.
118+
supportsTools: () => known.get(provider)?.tools === true,
119+
// They get images only when the last availability answer says the device's model reads them.
111120
supportsImages: () => known.get(provider)?.images === true,
112121
};
113122
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { type ModelClientService, type ModelEvent, type ModelRequest } from '@kilocode/harness-sdk';
2+
import { Effect, Either, Stream } from 'effect';
3+
import { describe, expect, it, vi } from 'vitest';
4+
5+
import { backendFailureKey } from './backend-target';
6+
import { type NativeAvailability, nativeModelClient } from './native-model-client';
7+
import {
8+
answerWith,
9+
AVAILABLE,
10+
collect,
11+
fakeBridge,
12+
request,
13+
type Script,
14+
} from './native-model-client.test-helpers';
15+
16+
vi.mock('@/i18n', () => ({ i18n: { t: (key: string) => key } }));
17+
18+
describe('native on-device model client with tools', () => {
19+
const TOOLS: NativeAvailability = { ...AVAILABLE, tools: true };
20+
const call = { id: 'fm-call-1', name: 'time', arguments: '{}' };
21+
22+
/** The request the harness sends after it ran the round's calls. */
23+
const answered = (results: readonly { callId: string; body: string }[]): ModelRequest => ({
24+
...request,
25+
prompt: {
26+
system: request.prompt.system,
27+
messages: [
28+
...request.prompt.messages,
29+
{
30+
role: 'assistant',
31+
cache: false,
32+
parts: [{ kind: 'toolCall', callId: call.id, name: 'time', arguments: '{}' }],
33+
},
34+
{
35+
role: 'user',
36+
cache: true,
37+
parts: results.map(result => ({ kind: 'toolResult', ...result, failed: false }) as const),
38+
},
39+
],
40+
},
41+
});
42+
43+
async function said(client: ModelClientService, asked = request): Promise<ModelEvent[]> {
44+
const result = await Effect.runPromise(Effect.either(Stream.runCollect(client.stream(asked))));
45+
if (Either.isLeft(result)) {
46+
throw new Error('Expected the answer to finish');
47+
}
48+
return [...result.right];
49+
}
50+
51+
function toolBridge(script: Script) {
52+
const fake = fakeBridge(script);
53+
fake.bridge.availability.mockResolvedValue(TOOLS);
54+
return fake;
55+
}
56+
57+
it('sends paired tool calls and outputs, and the tools Foundation Models can take', async () => {
58+
const { bridge } = toolBridge(answerWith({}));
59+
const asked: ModelRequest = {
60+
...request,
61+
tools: [
62+
...(request.tools ?? []),
63+
{
64+
name: 'lookup',
65+
description: 'Looks something up',
66+
parameters: {
67+
type: 'object',
68+
properties: { query: { type: 'string', description: 'What to find' } },
69+
required: ['query'],
70+
},
71+
},
72+
{
73+
name: 'merge',
74+
description: 'Needs a union',
75+
parameters: {
76+
type: 'object',
77+
properties: { value: { anyOf: [{ type: 'string' }, { type: 'number' }] } },
78+
required: ['value'],
79+
},
80+
},
81+
],
82+
prompt: {
83+
...request.prompt,
84+
messages: [
85+
{
86+
role: 'assistant',
87+
cache: false,
88+
parts: [{ kind: 'toolCall', callId: 'never-answered', name: 'time', arguments: '{}' }],
89+
},
90+
...request.prompt.messages,
91+
],
92+
},
93+
};
94+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
95+
await said(nativeModelClient(bridge), asked);
96+
expect(warn).toHaveBeenCalledExactlyOnceWith(
97+
'[native-model] tool left out: merge.value: anyOf'
98+
);
99+
warn.mockRestore();
100+
expect(bridge.generate).toHaveBeenCalledExactlyOnceWith({
101+
id: expect.any(String),
102+
system: 'Be brief.\n\nAnswer in English.',
103+
messages: [
104+
{ role: 'user', text: 'What is Kilo?' },
105+
{ role: 'assistant', text: 'A coding agent.' },
106+
{ role: 'toolCalls', calls: [{ id: 'call', name: 'time', arguments: '{}' }] },
107+
{ role: 'toolOutput', callId: 'call', name: 'time', text: 'noon' },
108+
{ role: 'user', text: 'Thanks' },
109+
],
110+
maxTokens: 1000,
111+
tools: [
112+
{
113+
name: 'time',
114+
description: 'Current time',
115+
parameters: JSON.stringify({ type: 'object', properties: [] }),
116+
},
117+
{
118+
name: 'lookup',
119+
description: 'Looks something up',
120+
parameters: JSON.stringify({
121+
type: 'object',
122+
properties: [
123+
{
124+
name: 'query',
125+
description: 'What to find',
126+
optional: false,
127+
schema: { type: 'string', description: 'What to find' },
128+
},
129+
],
130+
}),
131+
},
132+
],
133+
});
134+
});
135+
136+
it('ends the round on the calls, then resumes the same generation with their results', async () => {
137+
const { bridge, emit } = toolBridge((native, send) => {
138+
send({ id: native.id, kind: 'toolCalls', calls: [call] });
139+
});
140+
const client = nativeModelClient(bridge);
141+
expect(await said(client)).toEqual([
142+
{ kind: 'toolCall', call },
143+
{
144+
kind: 'done',
145+
stop: 'tools',
146+
usage: expect.objectContaining({ cacheReadTokens: 0, cacheWriteTokens: 0 }),
147+
},
148+
]);
149+
// The generation waits for the results; nothing stops it.
150+
expect(bridge.cancel).not.toHaveBeenCalled();
151+
const id = bridge.generate.mock.calls[0]?.[0].id ?? '';
152+
153+
bridge.resume.mockImplementation(async resumed => {
154+
// The native module sends its events after it receives the results.
155+
await Promise.resolve();
156+
emit({ id: resumed, kind: 'delta', text: 'It is noon.' });
157+
emit({
158+
id: resumed,
159+
kind: 'done',
160+
stop: 'end',
161+
usageSource: 'counted',
162+
inputTokens: 90,
163+
outputTokens: 4,
164+
});
165+
});
166+
const second = await said(client, answered([{ callId: call.id, body: '12:00' }]));
167+
expect(bridge.resume).toHaveBeenCalledExactlyOnceWith(id, [{ callId: call.id, body: '12:00' }]);
168+
expect(bridge.generate).toHaveBeenCalledOnce();
169+
expect(second).toEqual([
170+
{ kind: 'delta', text: 'It is noon.' },
171+
{
172+
kind: 'done',
173+
stop: 'end',
174+
usage: { inputTokens: 90, outputTokens: 4, cacheReadTokens: 0, cacheWriteTokens: 0 },
175+
},
176+
]);
177+
});
178+
179+
it('stops a waiting generation that a new question leaves behind', async () => {
180+
let round = 0;
181+
const { bridge } = toolBridge((native, send) => {
182+
round += 1;
183+
if (round === 1) {
184+
send({ id: native.id, kind: 'toolCalls', calls: [call] });
185+
} else {
186+
answerWith({})(native, send);
187+
}
188+
});
189+
const client = nativeModelClient(bridge);
190+
await said(client);
191+
const waiting = bridge.generate.mock.calls[0]?.[0].id;
192+
const next = await said(client);
193+
expect(next.at(-1)).toMatchObject({ kind: 'done', stop: 'end' });
194+
expect(bridge.cancel).toHaveBeenCalledExactlyOnceWith(waiting);
195+
expect(bridge.resume).not.toHaveBeenCalled();
196+
expect(bridge.generate).toHaveBeenCalledTimes(2);
197+
});
198+
199+
it('fails explicitly on results that answer no waiting generation', async () => {
200+
const { bridge } = toolBridge((native, send) => {
201+
send({ id: native.id, kind: 'toolCalls', calls: [call] });
202+
});
203+
const client = nativeModelClient(bridge);
204+
await said(client);
205+
const waiting = bridge.generate.mock.calls[0]?.[0].id;
206+
const result = await collect(bridge, client.stream(answered([{ callId: 'other', body: 'x' }])));
207+
expect(Either.isLeft(result) && backendFailureKey(result.left)).toBe(
208+
'modelChat.localModels.failed'
209+
);
210+
expect(bridge.cancel).toHaveBeenCalledExactlyOnceWith(waiting);
211+
expect(bridge.resume).not.toHaveBeenCalled();
212+
expect(bridge.generate).toHaveBeenCalledOnce();
213+
});
214+
215+
it('keeps a provider without the tool loop text-only', async () => {
216+
const { bridge } = toolBridge(answerWith({}));
217+
await said(nativeModelClient({ ...bridge, resume: undefined }));
218+
const sent = bridge.generate.mock.calls[0]?.[0];
219+
expect(sent).not.toHaveProperty('tools');
220+
expect(sent?.messages.map(message => message.role)).toEqual(['user', 'assistant', 'user']);
221+
});
222+
});

0 commit comments

Comments
 (0)