@ateam-ai-mobile/plugin-runtime 0.1.5 → 0.1.8
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/dist/chunk-RXR6M623.mjs +3085 -0
- package/dist/chunk-RXR6M623.mjs.map +1 -0
- package/dist/index.d.mts +2083 -12
- package/dist/index.d.ts +2083 -12
- package/dist/index.js +2605 -1332
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +265 -2157
- package/dist/index.mjs.map +1 -1
- package/dist/loader-UUOPZQSI.mjs +9 -0
- package/dist/loader-UUOPZQSI.mjs.map +1 -0
- package/package.json +2 -2
- package/src/bundles/bundleManager.ts +496 -51
- package/src/bundles/integrity.ts +29 -0
- package/src/bundles/storage.ts +59 -0
- package/src/deviceStateHost.ts +222 -0
- package/src/hooks/usePlugin.ts +14 -4
- package/src/host.ts +82 -8
- package/src/index.ts +96 -2
- package/src/pluginRuntime.ts +108 -0
- package/src/sdk/CameraPreview.tsx +94 -0
- package/src/sdk/device-state.ts +1315 -0
- package/src/sdk/index.ts +42 -3
- package/src/sdk/registration.ts +40 -0
- package/src/sdk/types.ts +489 -8
- package/src/sdk/voice.ts +186 -0
- package/src/tools/loader.ts +185 -0
- package/src/tools/platformTools.ts +41 -0
- package/src/tools/registry.ts +190 -0
- package/src/tools/serverCalls.ts +78 -0
- package/src/tools/sessionSync.ts +107 -0
- package/src/voiceHost.ts +128 -0
package/src/sdk/voice.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* props.voice — the live conversational SESSION a plugin may drive.
|
|
3
|
+
*
|
|
4
|
+
* Voice is a SESSION, not a device sensor. `props.device.*` is mechanical verbs
|
|
5
|
+
* that throw; `props.deviceState.*` is eight semantic domains describing the
|
|
6
|
+
* device. A Realtime conversation is neither — it is a long-lived loop with its
|
|
7
|
+
* own state (connecting/connected), its own transcript, and two ways to take a
|
|
8
|
+
* turn (the user speaks, or the agent speaks first). So it gets its own gated
|
|
9
|
+
* surface, mirroring the deviceState GRAMMAR (start / stop / getSnapshot /
|
|
10
|
+
* subscribe) because a reasoning loop reads it the same way — state with
|
|
11
|
+
* freshness, not bare values.
|
|
12
|
+
*
|
|
13
|
+
* TRUST BOUNDARY. This type is the WHOLE surface. There is deliberately NO way
|
|
14
|
+
* from here to inject fake user speech, read raw audio, reach the WebView, or
|
|
15
|
+
* hide the mic-active indicator — those are new doors, and the reason
|
|
16
|
+
* `CameraPreview` is a component rather than a module is that we keep closing
|
|
17
|
+
* doors of that shape. `prompt()` is an AGENT turn, not a forged user turn.
|
|
18
|
+
*
|
|
19
|
+
* See docs/handoff/SPEC_voice_session_without_the_voice_screen_2026-09-12.md.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The session lifecycle — identical vocabulary to the built-in `CallState`. */
|
|
23
|
+
export type VoiceState = 'idle' | 'connecting' | 'connected' | 'error';
|
|
24
|
+
|
|
25
|
+
/** One line of the conversation. `tool` entries are device/skill tool activity. */
|
|
26
|
+
export interface VoiceTranscriptEntry {
|
|
27
|
+
role: 'user' | 'ai' | 'tool';
|
|
28
|
+
text: string;
|
|
29
|
+
ts: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A point-in-time read of the session — what a reasoning loop polls. */
|
|
33
|
+
export interface VoiceSnapshot {
|
|
34
|
+
state: VoiceState;
|
|
35
|
+
sessionId: string | null;
|
|
36
|
+
muted: boolean;
|
|
37
|
+
/** The agent is currently producing audio. */
|
|
38
|
+
speaking: boolean;
|
|
39
|
+
/** The user is currently being heard (partial user transcript in flight). */
|
|
40
|
+
listening: boolean;
|
|
41
|
+
/** The user is currently speaking, per the session's own VAD (authoritative,
|
|
42
|
+
* distinct from `listening` which tracks a partial transcript). */
|
|
43
|
+
userSpeaking: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Epoch ms of the last moment the user STOPPED speaking, or `null` if they
|
|
46
|
+
* have not spoken yet this session. `null` is deliberate and load-bearing: a
|
|
47
|
+
* silence timer must arm on the first real `speaking:false`, never at session
|
|
48
|
+
* start — "not speaking" and "has never spoken" are different, and arming on
|
|
49
|
+
* the wrong one fires before anyone has said anything.
|
|
50
|
+
*/
|
|
51
|
+
lastUserSpeechEndedAt: number | null;
|
|
52
|
+
/** The last error, if `state === 'error'`; also carries a refusal reason when
|
|
53
|
+
* this surface was handed to a plugin that did not declare `voice`. */
|
|
54
|
+
lastError: string | null;
|
|
55
|
+
/** The most recent LOCAL device-tool call the model made through the session
|
|
56
|
+
* (name + epoch ms), or null if none yet. Diagnostic: proves whether the
|
|
57
|
+
* model's call_device_tool actually reached the phone vs was never made. */
|
|
58
|
+
lastDeviceTool: { name: string; at: number } | null;
|
|
59
|
+
/** The sessionId the Core allowset is bound to. Diagnostic: authorization
|
|
60
|
+
* requires it to equal `sessionId`; a mismatch is why tools refuse. */
|
|
61
|
+
authorizedSessionId: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface VoiceStartOptions {
|
|
65
|
+
/** Hand the mic to a specific skill on connect (voice /session → takeMic),
|
|
66
|
+
* exactly as `sys.focusUiPlugin("platform:voice", { skillSlug })` does. */
|
|
67
|
+
skillSlug?: string;
|
|
68
|
+
/**
|
|
69
|
+
* A PER-CALL persona override, same shape as the authored
|
|
70
|
+
* `solution.voice.persona` ({ name, style }). Declared on the voice
|
|
71
|
+
* session-create call (POST /session `persona`); the server sanitizes it
|
|
72
|
+
* (name/style only, length-capped) and layers it OVER the solution's authored
|
|
73
|
+
* persona for THIS call only — prompt-layer, so it can never touch caller
|
|
74
|
+
* verification or tool allow-lists. Set once at session start and constant
|
|
75
|
+
* for the call, so it adds no per-turn prompt pushes.
|
|
76
|
+
*
|
|
77
|
+
* Use it when a plugin needs its own voice persona without owning a skill
|
|
78
|
+
* (e.g. a guide HUD). A solution-wide persona belongs in solution.voice.
|
|
79
|
+
*/
|
|
80
|
+
persona?: VoicePersona;
|
|
81
|
+
/**
|
|
82
|
+
* `'none'` = a CLEAN call: the voice gets none of the workspace's skills — no
|
|
83
|
+
* skill manifest, no skill tools, no platform welcome line, no mid-call
|
|
84
|
+
* skill-state prompt refresh — only the voice basics, this call's persona and
|
|
85
|
+
* the CALLING plugin's own device tools (other solutions' tools registered on
|
|
86
|
+
* the same phone are neither offered nor run). For a plugin that owns its conversation (e.g.
|
|
87
|
+
* a guide HUD). Reduce-only: it can remove what the voice gets, never add.
|
|
88
|
+
* Absent = the workspace's skills, as today. Not combinable with `skillSlug`.
|
|
89
|
+
*/
|
|
90
|
+
skills?: 'none';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The authored persona shape (solution.voice.persona). */
|
|
94
|
+
export interface VoicePersona {
|
|
95
|
+
/** The name the voice uses for itself ("You are <name> …"). */
|
|
96
|
+
name?: string;
|
|
97
|
+
/** Free-text style/behaviour guidance rendered under the identity line. */
|
|
98
|
+
style?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What changed. Coarse on purpose: `state` covers connect/mute/session/partials,
|
|
103
|
+
* `transcript` fires when a finalized line was appended. A subscriber re-reads
|
|
104
|
+
* `getSnapshot()` / `transcript()` — the event is a nudge, not a payload, so the
|
|
105
|
+
* surface never has to keep two shapes of the same fact in sync.
|
|
106
|
+
*/
|
|
107
|
+
export type VoiceEvent = { type: 'state' } | { type: 'transcript' };
|
|
108
|
+
|
|
109
|
+
export interface VoiceSubscription {
|
|
110
|
+
unsubscribe(): void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* WHO is asking. The one physical session has an OWNER; access is scoped to it.
|
|
115
|
+
*
|
|
116
|
+
* A plugin's caller is its `pluginId` (a string), stamped by gateVoice so the
|
|
117
|
+
* plugin never supplies its own identity. The PLATFORM caller — the built-in
|
|
118
|
+
* voice screen, the user's own surface — is a Symbol, deliberately: a Symbol can
|
|
119
|
+
* never equal any pluginId string, so a plugin cannot forge platform access by
|
|
120
|
+
* naming itself `platform:voice`.
|
|
121
|
+
*/
|
|
122
|
+
export type VoiceCaller = string | symbol;
|
|
123
|
+
|
|
124
|
+
/** The privileged caller — the platform's own surfaces (the built-in sheet, the
|
|
125
|
+
* mic indicator, the session host). Sees and controls any live session, because
|
|
126
|
+
* it is the USER's direct surface onto their own conversation. Unforgeable. */
|
|
127
|
+
export const PLATFORM_VOICE_CALLER: unique symbol = Symbol('adas.voice.platform');
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The OWNER-AWARE session the HOST implements and hands to setVoiceProvider.
|
|
131
|
+
*
|
|
132
|
+
* This is NOT what a plugin receives — a plugin gets a `VoiceAPI` facade
|
|
133
|
+
* (gateVoice) that stamps its `pluginId` as the caller on every call, so the
|
|
134
|
+
* provider can enforce that a plugin only ever reads, controls, or attaches to a
|
|
135
|
+
* session IT owns. One physical session, isolated per authorized identity.
|
|
136
|
+
*/
|
|
137
|
+
export interface VoiceSessionProvider {
|
|
138
|
+
start(opts: VoiceStartOptions | undefined, caller: VoiceCaller): Promise<VoiceSnapshot>;
|
|
139
|
+
stop(caller: VoiceCaller): void;
|
|
140
|
+
setMuted(muted: boolean, caller: VoiceCaller): void;
|
|
141
|
+
prompt(input: string, caller: VoiceCaller): void;
|
|
142
|
+
observe(text: string, caller: VoiceCaller): void;
|
|
143
|
+
getSnapshot(caller: VoiceCaller): VoiceSnapshot;
|
|
144
|
+
subscribe(cb: (event: VoiceEvent) => void, caller: VoiceCaller): VoiceSubscription;
|
|
145
|
+
transcript(caller: VoiceCaller): VoiceTranscriptEntry[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The gated surface a plugin receives as `props.voice`. Present on EVERY plugin;
|
|
150
|
+
* a plugin that did not declare `capabilities: { voice: true }` gets a version
|
|
151
|
+
* whose reads say so and whose actions throw — never a silent no-op, never a
|
|
152
|
+
* fake `idle` (see gateVoice in voiceHost.ts).
|
|
153
|
+
*/
|
|
154
|
+
export interface VoiceAPI {
|
|
155
|
+
/** Open the live loop. Resolves once connected (or rejects on connect error).
|
|
156
|
+
* If a session is ALREADY live, ATTACHES to it and resolves immediately —
|
|
157
|
+
* never a second Realtime loop (one phone, one mic, one bill). */
|
|
158
|
+
start(opts?: VoiceStartOptions): Promise<VoiceSnapshot>;
|
|
159
|
+
/** End the live loop. Safe to call when idle. */
|
|
160
|
+
stop(): void;
|
|
161
|
+
/** Mute/unmute the microphone. The session stays live; the mic indicator
|
|
162
|
+
* reflects it. */
|
|
163
|
+
setMuted(muted: boolean): void;
|
|
164
|
+
/**
|
|
165
|
+
* Make the agent take a turn WITHOUT the user speaking — the guide speaking
|
|
166
|
+
* first. `input` is the instruction for that turn. This is an agent-initiated
|
|
167
|
+
* turn into the SAME session, NOT fabricated user speech: one session, two
|
|
168
|
+
* trigger sources. Throws if the session is not connected.
|
|
169
|
+
*/
|
|
170
|
+
prompt(input: string): void;
|
|
171
|
+
/**
|
|
172
|
+
* Stream a silent OBSERVATION into the session — tell the model something
|
|
173
|
+
* without asking it to speak (a `system` conversation item). Cheap and
|
|
174
|
+
* high-frequency (safe once or twice a second): this is the channel for what
|
|
175
|
+
* the camera sees while the user walks. The model answers with the
|
|
176
|
+
* observations in hand the next time it takes a turn (user VAD, or prompt()) —
|
|
177
|
+
* an observation never triggers a turn by itself. Throws if not connected.
|
|
178
|
+
*/
|
|
179
|
+
observe(text: string): void;
|
|
180
|
+
/** Point-in-time session state. */
|
|
181
|
+
getSnapshot(): VoiceSnapshot;
|
|
182
|
+
/** Subscribe to state + transcript changes. Returns an unsubscribe handle. */
|
|
183
|
+
subscribe(cb: (event: VoiceEvent) => void): VoiceSubscription;
|
|
184
|
+
/** The conversation so far. */
|
|
185
|
+
transcript(): VoiceTranscriptEntry[];
|
|
186
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device tool loading.
|
|
3
|
+
*
|
|
4
|
+
* A device tool is only callable once its plugin bundle has been evaluated —
|
|
5
|
+
* that is when the tools register (see `./registry`). Nothing else in the app
|
|
6
|
+
* guarantees that: bundle prewarm runs on the home screen and is best-effort, so
|
|
7
|
+
* a user who goes straight into a voice call can reach a tool the phone has
|
|
8
|
+
* never loaded. The model then hears "no such tool" and improvises, which looks
|
|
9
|
+
* exactly like a broken feature.
|
|
10
|
+
*
|
|
11
|
+
* So the voice path calls `ensureDeviceToolsLoaded()` before connecting.
|
|
12
|
+
*/
|
|
13
|
+
import { listPlugins, getPlugin, getNativeBundleUrl, getNativeBundleHash } from '../api/plugins';
|
|
14
|
+
import { bundleManager } from '../bundles/bundleManager';
|
|
15
|
+
import { listDeviceTools } from './registry';
|
|
16
|
+
import { getBundleStorage, selectOwnedCacheEntries } from '../bundles/storage';
|
|
17
|
+
import { resolvePluginGrants } from '../pluginRuntime';
|
|
18
|
+
import { getTenant } from '@ateam-ai-mobile/core';
|
|
19
|
+
|
|
20
|
+
let inflight: Promise<string[]> | null = null;
|
|
21
|
+
let loadedOnce = false;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load every plugin that declares `capabilities.device_tools`, so its tools are
|
|
25
|
+
* registered and callable. Best-effort and idempotent: safe to call on every
|
|
26
|
+
* call start. Returns the tool names currently registered.
|
|
27
|
+
*
|
|
28
|
+
* Pass `force` after a plugin catalog change (a newly deployed connector).
|
|
29
|
+
*/
|
|
30
|
+
export async function ensureDeviceToolsLoaded({ force = false } = {}): Promise<string[]> {
|
|
31
|
+
if (loadedOnce && !force) return listDeviceTools().map(t => t.name);
|
|
32
|
+
if (inflight) return inflight;
|
|
33
|
+
|
|
34
|
+
inflight = (async () => {
|
|
35
|
+
try {
|
|
36
|
+
const summaries = await listPlugins();
|
|
37
|
+
|
|
38
|
+
// The list response is slim, so the manifest is the authority on whether a
|
|
39
|
+
// plugin declares device tools. Fetched in parallel — a cold connector
|
|
40
|
+
// that answers slowly must not hold up a call.
|
|
41
|
+
const manifests = await Promise.all(
|
|
42
|
+
(summaries || []).map(s =>
|
|
43
|
+
getPlugin((s as any).id).catch(() => null),
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const targets = manifests.filter((m: any) => {
|
|
48
|
+
const declared = m?.capabilities?.device_tools;
|
|
49
|
+
return Array.isArray(declared) && declared.length > 0;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (targets.length === 0) {
|
|
53
|
+
// The catalog can go blind: a solution connector is lazy, and while it
|
|
54
|
+
// is not running its plugins are absent from the list entirely. That is
|
|
55
|
+
// a fact about a server process, not about this phone — the bundle we
|
|
56
|
+
// already downloaded still has the tools in it.
|
|
57
|
+
//
|
|
58
|
+
// So fall back to what is on disk: re-evaluate this tenant's cached
|
|
59
|
+
// bundles under the trusted declaration recorded when a manifest last
|
|
60
|
+
// loaded them (see loadFromDiskCache).
|
|
61
|
+
const recovered = await loadFromDiskCache();
|
|
62
|
+
if (recovered.length > 0) {
|
|
63
|
+
console.log(`[deviceTools] catalog listed none — recovered from cache: ${recovered.join(', ')}`);
|
|
64
|
+
return recovered;
|
|
65
|
+
}
|
|
66
|
+
// Do NOT latch: "nothing yet" is usually just "not yet".
|
|
67
|
+
console.log('[deviceTools] no device tools available yet — will look again next call');
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await Promise.all(
|
|
72
|
+
targets.map(async (m: any) => {
|
|
73
|
+
const bundleUrl = getNativeBundleUrl(m);
|
|
74
|
+
if (!bundleUrl) {
|
|
75
|
+
console.warn(`[deviceTools] ${m.id} declares device_tools but has no native bundle`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
// The manifest is the authority on what the tools may touch — the
|
|
80
|
+
// same grants its rendered surface gets, never the bundle's claim.
|
|
81
|
+
await bundleManager.loadTools(
|
|
82
|
+
m.id,
|
|
83
|
+
bundleUrl,
|
|
84
|
+
m.version || '1.0.0',
|
|
85
|
+
getNativeBundleHash(m) || undefined,
|
|
86
|
+
resolvePluginGrants({ pluginId: m.id, manifest: m }),
|
|
87
|
+
);
|
|
88
|
+
} catch (err: any) {
|
|
89
|
+
console.warn(`[deviceTools] ${m.id} failed to load:`, err?.message || String(err));
|
|
90
|
+
}
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const names = listDeviceTools().map(t => t.name);
|
|
95
|
+
// Only latch on success. An empty result is a cold read, not a fact —
|
|
96
|
+
// same reasoning as the server-side discovery cache.
|
|
97
|
+
loadedOnce = names.length > 0;
|
|
98
|
+
console.log(`[deviceTools] ready: ${names.length > 0 ? names.join(', ') : '(none registered — will retry next call)'}`);
|
|
99
|
+
return names;
|
|
100
|
+
} finally {
|
|
101
|
+
inflight = null;
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
|
|
105
|
+
return inflight;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Re-evaluate the CURRENT TENANT'S bundles already cached on disk.
|
|
110
|
+
*
|
|
111
|
+
* Used when the plugin catalog comes back empty — which happens whenever the
|
|
112
|
+
* owning connector is asleep. This is a local read, not a download.
|
|
113
|
+
*
|
|
114
|
+
* TENANT SCOPING IS LOAD-BEARING, NOT DEFENCE IN DEPTH.
|
|
115
|
+
*
|
|
116
|
+
* Every other load path is gated by the manifest hash: the expected hash comes
|
|
117
|
+
* from the current tenant's server, so another tenant's cached bytes simply
|
|
118
|
+
* fail to match and are re-downloaded. This path has no manifest — that is the
|
|
119
|
+
* whole reason it exists — so it passes each entry's OWN stored hash as the
|
|
120
|
+
* expected hash. That check compares an entry to itself and can never fail. It
|
|
121
|
+
* is an integrity check against disk corruption, and it is not, and cannot be,
|
|
122
|
+
* an authorisation check.
|
|
123
|
+
*
|
|
124
|
+
* Without the tenant filter below this loop re-evaluated EVERY cached bundle,
|
|
125
|
+
* so a plugin cached during a session on tenant A registered its device tools
|
|
126
|
+
* into a live session on tenant B, and subsequent tool calls ran tenant-A code
|
|
127
|
+
* holding tenant-B credentials. No id collision was required: bundle urls are
|
|
128
|
+
* identical across tenants and resolve per-tenant on the server, and the code
|
|
129
|
+
* cache deliberately survives logout.
|
|
130
|
+
*
|
|
131
|
+
* An entry with no recorded tenant predates the stamp. Its owner is unknown,
|
|
132
|
+
* which means untrusted — skip it and let the normal catalog path re-download
|
|
133
|
+
* it under a real hash gate.
|
|
134
|
+
*
|
|
135
|
+
* GRANTS COME FROM THE RECORDED MANIFEST, NEVER THE BUNDLE. With no manifest
|
|
136
|
+
* here, the tools register under the `trusted` declaration (pluginId +
|
|
137
|
+
* capabilities + stateDomains) the bundle manager recorded on the entry when a
|
|
138
|
+
* manifest last loaded these bytes. An entry without one — written before the
|
|
139
|
+
* record existed, or never loaded under a manifest — is skipped (fail closed):
|
|
140
|
+
* the only other source is the bundle's own claim, and the catalog path
|
|
141
|
+
* registers it properly once its connector answers.
|
|
142
|
+
*/
|
|
143
|
+
async function loadFromDiskCache(): Promise<string[]> {
|
|
144
|
+
try {
|
|
145
|
+
const index = await getBundleStorage().loadIndex();
|
|
146
|
+
const entries = Object.values(index || {});
|
|
147
|
+
if (entries.length === 0) return [];
|
|
148
|
+
|
|
149
|
+
const tenant = getTenant();
|
|
150
|
+
if (!tenant) {
|
|
151
|
+
// No active tenant: nothing can be proven to be ours.
|
|
152
|
+
console.warn('[deviceTools] disk-cache recovery skipped — no active tenant');
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
const mine = selectOwnedCacheEntries(entries, tenant);
|
|
156
|
+
const skipped = entries.length - mine.length;
|
|
157
|
+
if (skipped > 0) {
|
|
158
|
+
console.log(`[deviceTools] disk-cache recovery: ${mine.length} for "${tenant}", skipped ${skipped} not owned by it`);
|
|
159
|
+
}
|
|
160
|
+
const declared = mine.filter((e) => !!e.trusted?.pluginId);
|
|
161
|
+
if (declared.length < mine.length) {
|
|
162
|
+
console.log(
|
|
163
|
+
`[deviceTools] disk-cache recovery: skipped ${mine.length - declared.length} with no recorded manifest grant (fail closed)`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (declared.length === 0) return [];
|
|
167
|
+
|
|
168
|
+
await Promise.all(declared.map(async (e) => {
|
|
169
|
+
try {
|
|
170
|
+
await bundleManager.loadTools(e.trusted!.pluginId, e.bundleUrl, e.version, e.bundleHash, e.trusted!);
|
|
171
|
+
} catch {
|
|
172
|
+
// A cached bundle that no longer loads is not worth failing a call over.
|
|
173
|
+
}
|
|
174
|
+
}));
|
|
175
|
+
return listDeviceTools().map(t => t.name);
|
|
176
|
+
} catch (err: any) {
|
|
177
|
+
console.warn('[deviceTools] disk-cache recovery failed:', err?.message || String(err));
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Forget that we loaded, so the next call re-reads the catalog. */
|
|
183
|
+
export function resetDeviceToolLoader(): void {
|
|
184
|
+
loadedOnce = false;
|
|
185
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform device tools.
|
|
3
|
+
*
|
|
4
|
+
* These are not a solution's plugin tools — they ship with the SDK and every
|
|
5
|
+
* host has them, which is what makes a device tool testable without deploying
|
|
6
|
+
* anything to a tenant. Their names and contracts are declared once on the
|
|
7
|
+
* server (connectors/platform/device-tools-mcp.json); this is the device half.
|
|
8
|
+
*
|
|
9
|
+
* Registered at import, so a client answers them even before any plugin bundle
|
|
10
|
+
* has loaded — that is the point: proving the device path works should not
|
|
11
|
+
* depend on the thing being proved.
|
|
12
|
+
*/
|
|
13
|
+
import { registerDeviceTools } from './registry';
|
|
14
|
+
|
|
15
|
+
const PLATFORM_PLUGIN_ID = 'platform:device-tools';
|
|
16
|
+
|
|
17
|
+
export function registerPlatformDeviceTools(): void {
|
|
18
|
+
registerDeviceTools(PLATFORM_PLUGIN_ID, [
|
|
19
|
+
{
|
|
20
|
+
name: 'device.echo',
|
|
21
|
+
description: "Echo a string back from the user's own device.",
|
|
22
|
+
input_schema: {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: { text: { type: 'string' } },
|
|
25
|
+
required: ['text'],
|
|
26
|
+
},
|
|
27
|
+
run: (args: any) => ({
|
|
28
|
+
ok: true,
|
|
29
|
+
echoed: (args && args.text) || '',
|
|
30
|
+
// The proof: a server-side implementation could not say this truthfully.
|
|
31
|
+
ran_on: 'device',
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'device.ping',
|
|
36
|
+
description: 'Check that the device is reachable.',
|
|
37
|
+
input_schema: { type: 'object', properties: {} },
|
|
38
|
+
run: () => ({ ok: true, ran_on: 'device', at: new Date().toISOString() }),
|
|
39
|
+
},
|
|
40
|
+
]);
|
|
41
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device tool registry.
|
|
3
|
+
*
|
|
4
|
+
* A solution's RN plugin may ship a `tools/` section alongside (or instead of)
|
|
5
|
+
* its UI. Those tools are registered HERE, when the bundle is evaluated — not
|
|
6
|
+
* when a screen mounts — so a caller can invoke one with nothing of the plugin
|
|
7
|
+
* on screen.
|
|
8
|
+
*
|
|
9
|
+
* Lookup is by the tool's own domain name (e.g. "guide.observe"). Callers never
|
|
10
|
+
* route on a name prefix: whether a tool runs locally is declared by the server
|
|
11
|
+
* in the session payload, and resolved against this registry. The full LOCAL
|
|
12
|
+
* reasoning path (discovery → call → result, and its independence from
|
|
13
|
+
* relay/MCP) is documented in docs/architecture/LOCAL_REASONING_PATH.md (R13).
|
|
14
|
+
*/
|
|
15
|
+
import { createNativeBridge } from '../bridge/native';
|
|
16
|
+
import { getPluginNative, gatePluginNative } from '../host';
|
|
17
|
+
import { gateDeviceState, withoutSubscribe } from '../deviceStateHost';
|
|
18
|
+
import type { DeviceToolContext, DeviceToolDefinition, NativeCapabilities } from '../sdk/types';
|
|
19
|
+
import type { DeviceBridgeDomain } from '../sdk/device-state';
|
|
20
|
+
|
|
21
|
+
export interface DeviceToolSummary {
|
|
22
|
+
name: string;
|
|
23
|
+
pluginId: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
input_schema?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type DeviceToolOutcome =
|
|
29
|
+
| { ok: true; result: any }
|
|
30
|
+
| { ok: false; error: string; reason: 'not_found' | 'no_context' | 'timeout' | 'failed' };
|
|
31
|
+
|
|
32
|
+
interface Entry {
|
|
33
|
+
pluginId: string;
|
|
34
|
+
tool: DeviceToolDefinition;
|
|
35
|
+
capabilities?: NativeCapabilities;
|
|
36
|
+
/** L3 DeviceState domains this plugin declared — gates ctx.deviceState. */
|
|
37
|
+
stateDomains?: DeviceBridgeDomain[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** toolName → entry. One name, one implementation. */
|
|
41
|
+
const registry = new Map<string, Entry>();
|
|
42
|
+
|
|
43
|
+
/** Optional host override; the SDK supplies a working default (see below). */
|
|
44
|
+
let contextProvider:
|
|
45
|
+
| ((pluginId: string, capabilities?: NativeCapabilities, stateDomains?: DeviceBridgeDomain[]) => DeviceToolContext)
|
|
46
|
+
| null = null;
|
|
47
|
+
|
|
48
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Override how a device tool reaches the Device Bridge. Rarely needed — the
|
|
52
|
+
* default below builds the same bridge/native pair a plugin gets when it
|
|
53
|
+
* renders, gated by the capabilities the plugin declared.
|
|
54
|
+
*/
|
|
55
|
+
export function setDeviceToolContextProvider(
|
|
56
|
+
fn: (pluginId: string, capabilities?: NativeCapabilities, stateDomains?: DeviceBridgeDomain[]) => DeviceToolContext,
|
|
57
|
+
): void {
|
|
58
|
+
contextProvider = fn;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Default context: the plugin's own bridge + a capability-gated Device Bridge +
|
|
63
|
+
* the L3 DeviceState gated to the domains the plugin declared. All three are the
|
|
64
|
+
* SAME gated objects the plugin gets when it renders — a tool reaches nothing a
|
|
65
|
+
* UI plugin couldn't. (One thing less: `callDeviceTool` takes `subscribe` away,
|
|
66
|
+
* because a call has no lifetime to own one.)
|
|
67
|
+
*/
|
|
68
|
+
function defaultContext(
|
|
69
|
+
pluginId: string,
|
|
70
|
+
capabilities?: NativeCapabilities,
|
|
71
|
+
stateDomains?: DeviceBridgeDomain[],
|
|
72
|
+
): DeviceToolContext {
|
|
73
|
+
return {
|
|
74
|
+
bridge: createNativeBridge(pluginId),
|
|
75
|
+
native: gatePluginNative(getPluginNative(), capabilities, pluginId),
|
|
76
|
+
deviceState: gateDeviceState(stateDomains),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Register a plugin's tools. Called from the bundle pipeline at load time.
|
|
82
|
+
*
|
|
83
|
+
* A name already taken by ANOTHER plugin is refused — a bundle loaded later
|
|
84
|
+
* must not be able to take over "guide.observe" from the plugin that owns it.
|
|
85
|
+
* Re-registering the same plugin (bundle reload) replaces its own entries.
|
|
86
|
+
*/
|
|
87
|
+
export function registerDeviceTools(
|
|
88
|
+
pluginId: string,
|
|
89
|
+
tools: DeviceToolDefinition[],
|
|
90
|
+
capabilities?: NativeCapabilities,
|
|
91
|
+
stateDomains?: DeviceBridgeDomain[],
|
|
92
|
+
): string[] {
|
|
93
|
+
const accepted: string[] = [];
|
|
94
|
+
|
|
95
|
+
for (const tool of tools || []) {
|
|
96
|
+
if (!tool || typeof tool.name !== 'string' || typeof tool.run !== 'function') {
|
|
97
|
+
console.warn(`[deviceTools] ${pluginId}: skipping malformed tool entry`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const existing = registry.get(tool.name);
|
|
101
|
+
if (existing && existing.pluginId !== pluginId) {
|
|
102
|
+
console.warn(
|
|
103
|
+
`[deviceTools] ${pluginId}: "${tool.name}" already registered by ${existing.pluginId} — refusing to override`,
|
|
104
|
+
);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
registry.set(tool.name, { pluginId, tool, capabilities, stateDomains });
|
|
108
|
+
accepted.push(tool.name);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (accepted.length > 0) {
|
|
112
|
+
console.log(`[deviceTools] ${pluginId} registered: ${accepted.join(', ')}`);
|
|
113
|
+
}
|
|
114
|
+
return accepted;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Drop every tool owned by a plugin (bundle eviction / plugin revoked). */
|
|
118
|
+
export function unregisterDeviceTools(pluginId: string): void {
|
|
119
|
+
for (const [name, entry] of registry.entries()) {
|
|
120
|
+
if (entry.pluginId === pluginId) registry.delete(name);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Is `name` registered — and, given `pluginId`, registered BY that plugin?
|
|
125
|
+
* (A clean voice call only lets its owner's own tools run.) */
|
|
126
|
+
export function hasDeviceTool(name: string, pluginId?: string): boolean {
|
|
127
|
+
const entry = registry.get(name);
|
|
128
|
+
return !!entry && (pluginId === undefined || entry.pluginId === pluginId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function listDeviceTools(): DeviceToolSummary[] {
|
|
132
|
+
return Array.from(registry.entries()).map(([name, { pluginId, tool }]) => ({
|
|
133
|
+
name,
|
|
134
|
+
pluginId,
|
|
135
|
+
description: tool.description,
|
|
136
|
+
input_schema: tool.input_schema,
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Invoke a device tool. Never throws — every failure comes back as a typed
|
|
142
|
+
* outcome, because the caller is usually a reasoning loop that has to say
|
|
143
|
+
* something useful out loud rather than swallow an exception.
|
|
144
|
+
*/
|
|
145
|
+
export async function callDeviceTool(
|
|
146
|
+
name: string,
|
|
147
|
+
args: any = {},
|
|
148
|
+
opts: { timeoutMs?: number; signal?: AbortSignal } = {},
|
|
149
|
+
): Promise<DeviceToolOutcome> {
|
|
150
|
+
const entry = registry.get(name);
|
|
151
|
+
if (!entry) {
|
|
152
|
+
return { ok: false, reason: 'not_found', error: `No device tool registered as "${name}"` };
|
|
153
|
+
}
|
|
154
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
155
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const build = contextProvider || defaultContext;
|
|
159
|
+
const built = build(entry.pluginId, entry.capabilities, entry.stateDomains);
|
|
160
|
+
const ctx: DeviceToolContext = {
|
|
161
|
+
...built,
|
|
162
|
+
// Whoever built the context, a call cannot own a subscription.
|
|
163
|
+
deviceState: withoutSubscribe(built.deviceState),
|
|
164
|
+
signal: opts.signal,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const result = await Promise.race([
|
|
168
|
+
Promise.resolve(entry.tool.run(args, ctx)),
|
|
169
|
+
new Promise<never>((_, reject) => {
|
|
170
|
+
timer = setTimeout(
|
|
171
|
+
() => reject(new Error(`Device tool "${name}" timed out after ${timeoutMs}ms`)),
|
|
172
|
+
timeoutMs,
|
|
173
|
+
);
|
|
174
|
+
}),
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
return { ok: true, result };
|
|
178
|
+
} catch (err: any) {
|
|
179
|
+
const message = err?.message || String(err);
|
|
180
|
+
const timedOut = message.includes('timed out after');
|
|
181
|
+
return { ok: false, reason: timedOut ? 'timeout' : 'failed', error: message };
|
|
182
|
+
} finally {
|
|
183
|
+
if (timer) clearTimeout(timer);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Test/host reset. */
|
|
188
|
+
export function clearDeviceTools(): void {
|
|
189
|
+
registry.clear();
|
|
190
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device tools called BY THE SERVER.
|
|
3
|
+
*
|
|
4
|
+
* Two callers reach the same registered function on this phone:
|
|
5
|
+
*
|
|
6
|
+
* - the voice loop, which already runs here (no server hop at all), and
|
|
7
|
+
* - the server reasoning loop, which cannot execute a device tool itself and
|
|
8
|
+
* sends it down the client's stream instead.
|
|
9
|
+
*
|
|
10
|
+
* This is the second one. It rides the transport the `ui.*` plugin commands
|
|
11
|
+
* already use — a command arrives on the job/actor stream, we run it, and post
|
|
12
|
+
* the outcome back keyed by `correlationId`. Nothing new was invented; the
|
|
13
|
+
* payload just carries a tool call instead of a UI command.
|
|
14
|
+
*
|
|
15
|
+
* The host wires this once at startup:
|
|
16
|
+
*
|
|
17
|
+
* pluginCommandEmitter.on('plugin-command', handleServerDeviceToolCall);
|
|
18
|
+
*/
|
|
19
|
+
import { callDeviceTool } from './registry';
|
|
20
|
+
import { ensureDeviceToolsLoaded } from './loader';
|
|
21
|
+
|
|
22
|
+
type PostResult = (correlationId: string, result: any, error?: string) => Promise<void>;
|
|
23
|
+
|
|
24
|
+
let postResult: PostResult | null = null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Host DI: how to answer the server. `@ateam-ai-mobile/core` supplies
|
|
28
|
+
* postCommandResult; kept injectable so this package stays free of the REST
|
|
29
|
+
* client and so tests can observe the answer.
|
|
30
|
+
*/
|
|
31
|
+
export function setDeviceToolResultPoster(fn: PostResult): void {
|
|
32
|
+
postResult = fn;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Is this stream command a device tool call? */
|
|
36
|
+
export function isDeviceToolCommand(cmd: any): boolean {
|
|
37
|
+
return !!cmd && cmd.kind === 'device_tool' && typeof cmd.tool === 'string' && !!cmd.correlationId;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run a server-dispatched device tool and answer.
|
|
42
|
+
*
|
|
43
|
+
* Never throws — the server is blocked on the correlationId, so the one thing
|
|
44
|
+
* we must not do is stay silent. Every path posts something back.
|
|
45
|
+
*/
|
|
46
|
+
export async function handleServerDeviceToolCall(cmd: any): Promise<void> {
|
|
47
|
+
if (!isDeviceToolCommand(cmd)) return;
|
|
48
|
+
|
|
49
|
+
const { tool, args, correlationId } = cmd;
|
|
50
|
+
|
|
51
|
+
if (!postResult) {
|
|
52
|
+
console.warn('[deviceTools] server called a tool but no result poster is installed');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// The server may reach us before anything opened a voice call, so the
|
|
58
|
+
// plugins may not be loaded yet. Cheap and idempotent once warm.
|
|
59
|
+
await ensureDeviceToolsLoaded();
|
|
60
|
+
|
|
61
|
+
const outcome = await callDeviceTool(tool, args || {});
|
|
62
|
+
console.log(`[deviceTools] server-called ${tool} → ${outcome.ok ? 'ok' : outcome.reason}`);
|
|
63
|
+
|
|
64
|
+
await postResult(
|
|
65
|
+
correlationId,
|
|
66
|
+
outcome.ok ? { ok: true, ...unwrap(outcome.result) } : undefined,
|
|
67
|
+
outcome.ok ? undefined : outcome.error,
|
|
68
|
+
);
|
|
69
|
+
} catch (err: any) {
|
|
70
|
+
await postResult(correlationId, undefined, err?.message || String(err)).catch(() => {});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Keep an object result flat for the reasoning loop; wrap anything else. */
|
|
75
|
+
function unwrap(result: any): Record<string, any> {
|
|
76
|
+
if (result && typeof result === 'object' && !Array.isArray(result)) return result;
|
|
77
|
+
return { result };
|
|
78
|
+
}
|