react-native-gemma-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +457 -0
- package/package.json +52 -0
- package/skills/calculator.ts +47 -0
- package/skills/deviceLocation.ts +180 -0
- package/skills/index.ts +3 -0
- package/skills/queryWikipedia.ts +96 -0
- package/skills/readCalendar.ts +74 -0
- package/skills/webSearch.ts +75 -0
- package/src/AgentOrchestrator.ts +315 -0
- package/src/BM25Scorer.ts +118 -0
- package/src/FunctionCallParser.ts +113 -0
- package/src/GemmaAgentProvider.tsx +101 -0
- package/src/InferenceEngine.ts +301 -0
- package/src/ModelManager.ts +244 -0
- package/src/SkillRegistry.ts +60 -0
- package/src/SkillSandbox.tsx +155 -0
- package/src/index.ts +52 -0
- package/src/types.ts +197 -0
- package/src/useGemmaAgent.ts +222 -0
- package/src/useModelDownload.ts +80 -0
- package/src/useSkillRegistry.ts +58 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Message,
|
|
3
|
+
AgentEvent,
|
|
4
|
+
AgentConfig,
|
|
5
|
+
SkillResult,
|
|
6
|
+
} from './types';
|
|
7
|
+
import type { InferenceEngine } from './InferenceEngine';
|
|
8
|
+
import type { SkillRegistry } from './SkillRegistry';
|
|
9
|
+
import { BM25Scorer } from './BM25Scorer';
|
|
10
|
+
import {
|
|
11
|
+
validateToolCalls,
|
|
12
|
+
extractToolCallsFromText,
|
|
13
|
+
type ParsedToolCall,
|
|
14
|
+
} from './FunctionCallParser';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_CONFIG: Required<AgentConfig> = {
|
|
17
|
+
maxChainDepth: 5,
|
|
18
|
+
skillTimeout: 30_000,
|
|
19
|
+
systemPrompt:
|
|
20
|
+
'You are a helpful AI assistant running on-device. Answer concisely and accurately.',
|
|
21
|
+
skillRouting: 'all',
|
|
22
|
+
maxToolsPerInvocation: 5,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type SkillExecutor = (
|
|
26
|
+
html: string,
|
|
27
|
+
params: Record<string, unknown>,
|
|
28
|
+
timeout?: number,
|
|
29
|
+
) => Promise<SkillResult>;
|
|
30
|
+
|
|
31
|
+
export class AgentOrchestrator {
|
|
32
|
+
private engine: InferenceEngine;
|
|
33
|
+
private registry: SkillRegistry;
|
|
34
|
+
private executor: SkillExecutor | null = null;
|
|
35
|
+
private config: Required<AgentConfig>;
|
|
36
|
+
private history: Message[] = [];
|
|
37
|
+
private _isProcessing = false;
|
|
38
|
+
private bm25: BM25Scorer = new BM25Scorer();
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
engine: InferenceEngine,
|
|
42
|
+
registry: SkillRegistry,
|
|
43
|
+
config?: AgentConfig,
|
|
44
|
+
) {
|
|
45
|
+
this.engine = engine;
|
|
46
|
+
this.registry = registry;
|
|
47
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get isProcessing(): boolean {
|
|
51
|
+
return this._isProcessing;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get conversation(): ReadonlyArray<Message> {
|
|
55
|
+
return this.history;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Set the JS skill executor. Wired by the React layer to SkillSandbox.
|
|
60
|
+
* Not needed if you only use native skills.
|
|
61
|
+
*/
|
|
62
|
+
setSkillExecutor(executor: SkillExecutor): void {
|
|
63
|
+
this.executor = executor;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Send a user message through the full agent loop:
|
|
68
|
+
* inference → tool call detection → skill execution → re-invoke model
|
|
69
|
+
*
|
|
70
|
+
* Returns the final assistant response text.
|
|
71
|
+
*/
|
|
72
|
+
async sendMessage(
|
|
73
|
+
text: string,
|
|
74
|
+
onEvent?: (event: AgentEvent) => void,
|
|
75
|
+
): Promise<string> {
|
|
76
|
+
if (this._isProcessing) {
|
|
77
|
+
throw new Error('Already processing a message. Wait for completion.');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this._isProcessing = true;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
this.history = [...this.history, { role: 'user', content: text }];
|
|
84
|
+
|
|
85
|
+
const tools = this.getToolsForQuery(text);
|
|
86
|
+
let depth = 0;
|
|
87
|
+
|
|
88
|
+
while (depth < this.config.maxChainDepth) {
|
|
89
|
+
depth++;
|
|
90
|
+
|
|
91
|
+
const messages: Message[] = [
|
|
92
|
+
{ role: 'system', content: this.config.systemPrompt },
|
|
93
|
+
...this.history,
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
onEvent?.({ type: 'thinking' });
|
|
97
|
+
|
|
98
|
+
const result = await this.engine.generate(
|
|
99
|
+
messages,
|
|
100
|
+
{
|
|
101
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
102
|
+
toolChoice: tools.length > 0 ? 'auto' : undefined,
|
|
103
|
+
},
|
|
104
|
+
(tokenEvent) => {
|
|
105
|
+
onEvent?.({ type: 'token', token: tokenEvent.token });
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Check for tool calls — primary (llama.rn native) then fallback (text scan)
|
|
110
|
+
let parsedCalls = validateToolCalls(result.toolCalls, this.registry);
|
|
111
|
+
if (parsedCalls.length === 0 && result.text.trim()) {
|
|
112
|
+
parsedCalls = extractToolCallsFromText(result.text, this.registry);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// No tool calls → final response
|
|
116
|
+
if (parsedCalls.length === 0) {
|
|
117
|
+
const responseText = result.content || result.text;
|
|
118
|
+
this.history = [
|
|
119
|
+
...this.history,
|
|
120
|
+
{ role: 'assistant', content: responseText },
|
|
121
|
+
];
|
|
122
|
+
onEvent?.({
|
|
123
|
+
type: 'response',
|
|
124
|
+
text: responseText,
|
|
125
|
+
reasoning: result.reasoning,
|
|
126
|
+
});
|
|
127
|
+
return responseText;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Add assistant message with tool_calls to history.
|
|
131
|
+
// Strip thinking/reasoning from content — it leaks into chat UI otherwise.
|
|
132
|
+
// Empty string is safe: llama.rn's Jinja template handles empty content
|
|
133
|
+
// on assistant messages with tool_calls (OpenAI-compatible format).
|
|
134
|
+
this.history = [
|
|
135
|
+
...this.history,
|
|
136
|
+
{
|
|
137
|
+
role: 'assistant',
|
|
138
|
+
content: '',
|
|
139
|
+
tool_calls: result.toolCalls,
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
// Execute each tool call and append results
|
|
144
|
+
for (const call of parsedCalls) {
|
|
145
|
+
onEvent?.({
|
|
146
|
+
type: 'skill_called',
|
|
147
|
+
name: call.name,
|
|
148
|
+
parameters: call.parameters,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const skillResult = await this.executeSkill(call);
|
|
152
|
+
|
|
153
|
+
onEvent?.({
|
|
154
|
+
type: 'skill_result',
|
|
155
|
+
name: call.name,
|
|
156
|
+
result: skillResult,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const resultContent = skillResult.error
|
|
160
|
+
? `Error: ${skillResult.error}`
|
|
161
|
+
: skillResult.result ?? 'No result';
|
|
162
|
+
|
|
163
|
+
// tool_call_id must be a string — generate one if llama.rn didn't provide it
|
|
164
|
+
const toolCallId =
|
|
165
|
+
call.id ??
|
|
166
|
+
result.toolCalls.find(tc => tc.function.name === call.name)?.id ??
|
|
167
|
+
`call_${call.name}_${depth}`;
|
|
168
|
+
|
|
169
|
+
this.history = [
|
|
170
|
+
...this.history,
|
|
171
|
+
{
|
|
172
|
+
role: 'tool',
|
|
173
|
+
content: resultContent,
|
|
174
|
+
tool_call_id: toolCallId,
|
|
175
|
+
name: call.name,
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Loop back — model will see tool results and generate a response
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Max chain depth reached
|
|
184
|
+
const fallback =
|
|
185
|
+
'I tried to use tools but reached the maximum chain depth. Here is what I know so far.';
|
|
186
|
+
this.history = [
|
|
187
|
+
...this.history,
|
|
188
|
+
{ role: 'assistant', content: fallback },
|
|
189
|
+
];
|
|
190
|
+
onEvent?.({ type: 'response', text: fallback, reasoning: null });
|
|
191
|
+
return fallback;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
const errorMsg =
|
|
194
|
+
err instanceof Error ? err.message : 'Unknown error';
|
|
195
|
+
onEvent?.({ type: 'error', error: errorMsg });
|
|
196
|
+
throw err;
|
|
197
|
+
} finally {
|
|
198
|
+
this._isProcessing = false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
reset(): void {
|
|
203
|
+
this.history = [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
setSystemPrompt(prompt: string): void {
|
|
207
|
+
this.config = { ...this.config, systemPrompt: prompt };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private getToolsForQuery(query: string) {
|
|
211
|
+
if (this.config.skillRouting !== 'bm25') {
|
|
212
|
+
return this.registry.toToolDefinitions();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const allSkills = this.registry.getSkills();
|
|
216
|
+
if (allSkills.length <= this.config.maxToolsPerInvocation) {
|
|
217
|
+
return this.registry.toToolDefinitions();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this.bm25.buildIndex(allSkills);
|
|
221
|
+
const ranked = this.bm25.topN(query, this.config.maxToolsPerInvocation);
|
|
222
|
+
|
|
223
|
+
return ranked.map(({ skill }) => ({
|
|
224
|
+
type: 'function' as const,
|
|
225
|
+
function: {
|
|
226
|
+
name: skill.name,
|
|
227
|
+
description:
|
|
228
|
+
skill.description +
|
|
229
|
+
(skill.instructions ? `\n${skill.instructions}` : ''),
|
|
230
|
+
parameters: {
|
|
231
|
+
type: 'object' as const,
|
|
232
|
+
properties: skill.parameters,
|
|
233
|
+
required: skill.requiredParameters,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private async checkConnectivity(): Promise<boolean> {
|
|
240
|
+
try {
|
|
241
|
+
const controller = new AbortController();
|
|
242
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
243
|
+
await fetch('https://www.google.com/generate_204', {
|
|
244
|
+
method: 'HEAD',
|
|
245
|
+
signal: controller.signal,
|
|
246
|
+
});
|
|
247
|
+
clearTimeout(timeout);
|
|
248
|
+
return true;
|
|
249
|
+
} catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async executeSkill(call: ParsedToolCall): Promise<SkillResult> {
|
|
255
|
+
const { skill, parameters } = call;
|
|
256
|
+
|
|
257
|
+
if (skill.requiresNetwork) {
|
|
258
|
+
const online = await this.checkConnectivity();
|
|
259
|
+
if (!online) {
|
|
260
|
+
return {
|
|
261
|
+
error: 'No internet connection. This skill requires network access.',
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (skill.type === 'native' && skill.execute) {
|
|
267
|
+
try {
|
|
268
|
+
return await withTimeout(
|
|
269
|
+
skill.execute(parameters),
|
|
270
|
+
this.config.skillTimeout,
|
|
271
|
+
);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
return {
|
|
274
|
+
error:
|
|
275
|
+
err instanceof Error ? err.message : 'Native skill failed',
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (skill.type === 'js' && skill.html) {
|
|
281
|
+
if (!this.executor) {
|
|
282
|
+
return {
|
|
283
|
+
error: 'No skill executor available. SkillSandbox not mounted.',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
return await this.executor(
|
|
288
|
+
skill.html,
|
|
289
|
+
parameters,
|
|
290
|
+
this.config.skillTimeout,
|
|
291
|
+
);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
return {
|
|
294
|
+
error:
|
|
295
|
+
err instanceof Error
|
|
296
|
+
? err.message
|
|
297
|
+
: 'JS skill execution failed',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
error: `Cannot execute skill "${call.name}" — unsupported type "${skill.type}"`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
309
|
+
return Promise.race([
|
|
310
|
+
promise,
|
|
311
|
+
new Promise<never>((_, reject) =>
|
|
312
|
+
setTimeout(() => reject(new Error(`Skill timed out after ${ms}ms`)), ms),
|
|
313
|
+
),
|
|
314
|
+
]);
|
|
315
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { SkillManifest } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BM25 (Best Matching 25) scorer for skill pre-filtering.
|
|
5
|
+
*
|
|
6
|
+
* Ranks skills by relevance to a user query using term frequency / inverse
|
|
7
|
+
* document frequency with length normalization. Pure math, no ML model.
|
|
8
|
+
*
|
|
9
|
+
* Each skill's "document" is: name + description + parameter descriptions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const K1 = 1.5;
|
|
13
|
+
const B = 0.75;
|
|
14
|
+
|
|
15
|
+
type DocEntry = {
|
|
16
|
+
skill: SkillManifest;
|
|
17
|
+
tokens: string[];
|
|
18
|
+
termFreqs: Map<string, number>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class BM25Scorer {
|
|
22
|
+
private docs: DocEntry[] = [];
|
|
23
|
+
private avgDl = 0;
|
|
24
|
+
/** Number of documents containing each term */
|
|
25
|
+
private df: Map<string, number> = new Map();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the index from a set of skill manifests.
|
|
29
|
+
* Call this once when skills are registered (or change).
|
|
30
|
+
*/
|
|
31
|
+
buildIndex(skills: SkillManifest[]): void {
|
|
32
|
+
this.docs = [];
|
|
33
|
+
this.df = new Map();
|
|
34
|
+
|
|
35
|
+
for (const skill of skills) {
|
|
36
|
+
const text = this.skillToText(skill);
|
|
37
|
+
const tokens = this.tokenize(text);
|
|
38
|
+
|
|
39
|
+
const termFreqs = new Map<string, number>();
|
|
40
|
+
const seen = new Set<string>();
|
|
41
|
+
|
|
42
|
+
for (const t of tokens) {
|
|
43
|
+
termFreqs.set(t, (termFreqs.get(t) ?? 0) + 1);
|
|
44
|
+
if (!seen.has(t)) {
|
|
45
|
+
seen.add(t);
|
|
46
|
+
this.df.set(t, (this.df.get(t) ?? 0) + 1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.docs.push({ skill, tokens, termFreqs });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const totalTokens = this.docs.reduce((sum, d) => sum + d.tokens.length, 0);
|
|
54
|
+
this.avgDl = this.docs.length > 0 ? totalTokens / this.docs.length : 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Score all indexed skills against a query. Returns skills ranked by
|
|
59
|
+
* descending BM25 score.
|
|
60
|
+
*/
|
|
61
|
+
score(query: string): Array<{ skill: SkillManifest; score: number }> {
|
|
62
|
+
const queryTokens = this.tokenize(query);
|
|
63
|
+
const n = this.docs.length;
|
|
64
|
+
|
|
65
|
+
const results = this.docs.map((doc) => {
|
|
66
|
+
let total = 0;
|
|
67
|
+
const dl = doc.tokens.length;
|
|
68
|
+
|
|
69
|
+
for (const qt of queryTokens) {
|
|
70
|
+
const tf = doc.termFreqs.get(qt) ?? 0;
|
|
71
|
+
if (tf === 0) continue;
|
|
72
|
+
|
|
73
|
+
const docFreq = this.df.get(qt) ?? 0;
|
|
74
|
+
// IDF with floor at 0 to avoid negative scores
|
|
75
|
+
const idf = Math.max(
|
|
76
|
+
0,
|
|
77
|
+
Math.log((n - docFreq + 0.5) / (docFreq + 0.5) + 1),
|
|
78
|
+
);
|
|
79
|
+
const tfNorm =
|
|
80
|
+
(tf * (K1 + 1)) / (tf + K1 * (1 - B + B * (dl / this.avgDl)));
|
|
81
|
+
|
|
82
|
+
total += idf * tfNorm;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { skill: doc.skill, score: total };
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return results.sort((a, b) => b.score - a.score);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Convenience: score and return only the top-N skills.
|
|
93
|
+
*/
|
|
94
|
+
topN(
|
|
95
|
+
query: string,
|
|
96
|
+
n: number,
|
|
97
|
+
): Array<{ skill: SkillManifest; score: number }> {
|
|
98
|
+
return this.score(query).slice(0, n);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private skillToText(skill: SkillManifest): string {
|
|
102
|
+
const parts = [skill.name, skill.description];
|
|
103
|
+
for (const [key, param] of Object.entries(skill.parameters)) {
|
|
104
|
+
parts.push(key);
|
|
105
|
+
if (param.description) parts.push(param.description);
|
|
106
|
+
}
|
|
107
|
+
if (skill.instructions) parts.push(skill.instructions);
|
|
108
|
+
return parts.join(' ');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private tokenize(text: string): string[] {
|
|
112
|
+
return text
|
|
113
|
+
.toLowerCase()
|
|
114
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
115
|
+
.split(/\s+/)
|
|
116
|
+
.filter((t) => t.length > 1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ToolCall, SkillManifest } from './types';
|
|
2
|
+
import type { SkillRegistry } from './SkillRegistry';
|
|
3
|
+
|
|
4
|
+
export type ParsedToolCall = {
|
|
5
|
+
name: string;
|
|
6
|
+
parameters: Record<string, unknown>;
|
|
7
|
+
skill: SkillManifest;
|
|
8
|
+
/** Original tool call ID from llama.rn (needed for tool role messages) */
|
|
9
|
+
id?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Primary path: validate tool_calls from llama.rn's native parser
|
|
14
|
+
* against registered skills. Returns only calls for known skills.
|
|
15
|
+
*/
|
|
16
|
+
export function validateToolCalls(
|
|
17
|
+
toolCalls: ToolCall[],
|
|
18
|
+
registry: SkillRegistry,
|
|
19
|
+
): ParsedToolCall[] {
|
|
20
|
+
const validated: ParsedToolCall[] = [];
|
|
21
|
+
|
|
22
|
+
for (const tc of toolCalls) {
|
|
23
|
+
const skill = registry.getSkill(tc.function.name);
|
|
24
|
+
if (!skill) continue;
|
|
25
|
+
|
|
26
|
+
let parameters: Record<string, unknown>;
|
|
27
|
+
try {
|
|
28
|
+
parameters = JSON.parse(tc.function.arguments);
|
|
29
|
+
} catch {
|
|
30
|
+
parameters = {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
validated.push({
|
|
34
|
+
name: tc.function.name,
|
|
35
|
+
parameters,
|
|
36
|
+
skill,
|
|
37
|
+
id: tc.id,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return validated;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Fallback: scan raw text for JSON tool call blocks when llama.rn's
|
|
46
|
+
* native PEG parser misses them (e.g., malformed special tokens).
|
|
47
|
+
*
|
|
48
|
+
* Looks for two patterns:
|
|
49
|
+
* 1. {"tool_call": {"name": "...", "parameters": {...}}}
|
|
50
|
+
* 2. {"name": "...", "arguments": {...}}
|
|
51
|
+
*/
|
|
52
|
+
export function extractToolCallsFromText(
|
|
53
|
+
text: string,
|
|
54
|
+
registry: SkillRegistry,
|
|
55
|
+
): ParsedToolCall[] {
|
|
56
|
+
const results: ParsedToolCall[] = [];
|
|
57
|
+
|
|
58
|
+
// Find JSON-like blocks in the text
|
|
59
|
+
const jsonBlocks = findJsonBlocks(text);
|
|
60
|
+
|
|
61
|
+
for (const block of jsonBlocks) {
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(block);
|
|
64
|
+
|
|
65
|
+
let name: string | undefined;
|
|
66
|
+
let parameters: Record<string, unknown> = {};
|
|
67
|
+
|
|
68
|
+
if (parsed.tool_call && typeof parsed.tool_call === 'object') {
|
|
69
|
+
name = parsed.tool_call.name;
|
|
70
|
+
parameters = parsed.tool_call.parameters ?? {};
|
|
71
|
+
} else if (parsed.name && typeof parsed.name === 'string') {
|
|
72
|
+
name = parsed.name;
|
|
73
|
+
const raw = parsed.arguments ?? parsed.parameters ?? {};
|
|
74
|
+
parameters = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!name) continue;
|
|
78
|
+
|
|
79
|
+
const skill = registry.getSkill(name);
|
|
80
|
+
if (!skill) continue;
|
|
81
|
+
|
|
82
|
+
results.push({ name, parameters, skill });
|
|
83
|
+
} catch {
|
|
84
|
+
// Skip malformed JSON
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return results;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extract balanced JSON blocks from text by tracking brace depth.
|
|
93
|
+
*/
|
|
94
|
+
function findJsonBlocks(text: string): string[] {
|
|
95
|
+
const blocks: string[] = [];
|
|
96
|
+
let depth = 0;
|
|
97
|
+
let start = -1;
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < text.length; i++) {
|
|
100
|
+
if (text[i] === '{') {
|
|
101
|
+
if (depth === 0) start = i;
|
|
102
|
+
depth++;
|
|
103
|
+
} else if (text[i] === '}') {
|
|
104
|
+
depth--;
|
|
105
|
+
if (depth === 0 && start >= 0) {
|
|
106
|
+
blocks.push(text.slice(start, i + 1));
|
|
107
|
+
start = -1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return blocks;
|
|
113
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useRef,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { ModelManager } from './ModelManager';
|
|
9
|
+
import { InferenceEngine } from './InferenceEngine';
|
|
10
|
+
import { SkillRegistry } from './SkillRegistry';
|
|
11
|
+
import { AgentOrchestrator } from './AgentOrchestrator';
|
|
12
|
+
import { SkillSandbox, type SkillSandboxHandle } from './SkillSandbox';
|
|
13
|
+
import type {
|
|
14
|
+
ModelConfig,
|
|
15
|
+
SkillManifest,
|
|
16
|
+
InferenceEngineConfig,
|
|
17
|
+
AgentConfig,
|
|
18
|
+
} from './types';
|
|
19
|
+
|
|
20
|
+
export type GemmaAgentContextValue = {
|
|
21
|
+
modelManager: ModelManager;
|
|
22
|
+
engine: InferenceEngine;
|
|
23
|
+
registry: SkillRegistry;
|
|
24
|
+
orchestrator: AgentOrchestrator;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const GemmaAgentContext = createContext<GemmaAgentContextValue | null>(null);
|
|
28
|
+
|
|
29
|
+
export type GemmaAgentProviderProps = {
|
|
30
|
+
/** Model download config (repoId, filename, etc.) */
|
|
31
|
+
model: ModelConfig;
|
|
32
|
+
/** Skills to register on mount */
|
|
33
|
+
skills?: SkillManifest[];
|
|
34
|
+
/** Base system prompt for the agent */
|
|
35
|
+
systemPrompt?: string;
|
|
36
|
+
/** Inference engine configuration */
|
|
37
|
+
engineConfig?: InferenceEngineConfig;
|
|
38
|
+
/** Agent orchestrator configuration */
|
|
39
|
+
agentConfig?: AgentConfig;
|
|
40
|
+
children: React.ReactNode;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function GemmaAgentProvider({
|
|
44
|
+
model,
|
|
45
|
+
skills,
|
|
46
|
+
systemPrompt,
|
|
47
|
+
engineConfig,
|
|
48
|
+
agentConfig,
|
|
49
|
+
children,
|
|
50
|
+
}: GemmaAgentProviderProps) {
|
|
51
|
+
const sandboxRef = useRef<SkillSandboxHandle>(null);
|
|
52
|
+
|
|
53
|
+
// Create SDK instances once (stable across re-renders)
|
|
54
|
+
const instances = useRef<GemmaAgentContextValue | null>(null);
|
|
55
|
+
if (!instances.current) {
|
|
56
|
+
const modelManager = new ModelManager(model);
|
|
57
|
+
const engine = new InferenceEngine(engineConfig);
|
|
58
|
+
const registry = new SkillRegistry();
|
|
59
|
+
|
|
60
|
+
const orchestrator = new AgentOrchestrator(engine, registry, {
|
|
61
|
+
...agentConfig,
|
|
62
|
+
systemPrompt: systemPrompt ?? agentConfig?.systemPrompt,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (skills) {
|
|
66
|
+
for (const skill of skills) {
|
|
67
|
+
registry.registerSkill(skill);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
instances.current = { modelManager, engine, registry, orchestrator };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Wire SkillSandbox executor into orchestrator after mount
|
|
75
|
+
useLayoutEffect(() => {
|
|
76
|
+
if (sandboxRef.current) {
|
|
77
|
+
instances.current!.orchestrator.setSkillExecutor(
|
|
78
|
+
sandboxRef.current.execute,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const value = useMemo(() => instances.current!, []);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<GemmaAgentContext.Provider value={value}>
|
|
87
|
+
{children}
|
|
88
|
+
<SkillSandbox ref={sandboxRef} />
|
|
89
|
+
</GemmaAgentContext.Provider>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function useGemmaAgentContext(): GemmaAgentContextValue {
|
|
94
|
+
const ctx = useContext(GemmaAgentContext);
|
|
95
|
+
if (!ctx) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
'useGemmaAgent must be used within a <GemmaAgentProvider>',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return ctx;
|
|
101
|
+
}
|