-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathagent-runtime.ts
More file actions
316 lines (289 loc) · 13 KB
/
Copy pathagent-runtime.ts
File metadata and controls
316 lines (289 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import type {
ModelMessage,
AIRequestOptions,
AIToolDefinition,
IMetadataService,
} from '@objectstack/spec/contracts';
import type { Agent, Skill } from '@objectstack/spec/ai';
import { AgentSchema } from '@objectstack/spec/ai';
import { SkillRegistry, type SkillContext } from './skill-registry.js';
import { DEFAULT_DATA_AGENT_NAME } from './agents/index.js';
/**
* Context passed alongside a user message when chatting with an agent.
*
* UI clients set these fields to tell the agent which object, record,
* or view the user is currently looking at so it can provide contextual
* answers without additional tool calls.
*
* Extends {@link SkillContext} so the same context object can drive
* skill activation in the {@link SkillRegistry}.
*/
export interface AgentChatContext extends SkillContext {
/** Current object the user is viewing (e.g. "account") */
objectName?: string;
/** Currently selected record ID */
recordId?: string;
/** Current view name */
viewName?: string;
/**
* Whether this environment auto-publishes whole-app builds. When the client
* passes `true`, the agent is told a finished build is live immediately, so
* its narration matches reality ("your app is live") instead of defaulting to
* "publish it to make it live". Absent/false keeps the conservative framing.
*/
autoPublishAiBuilds?: boolean;
}
/**
* AgentRuntime — Resolves an agent definition into runnable chat parameters.
*
* Responsibilities:
* 1. Load & validate agent metadata from the metadata service.
* 2. Build the system prompt from agent `instructions` + UI context
* + active skill instructions.
* 3. Derive {@link AIRequestOptions} from agent `model`, `tools`, and
* resolved skills.
* 4. Map agent tool references to concrete {@link AIToolDefinition}s
* registered in the {@link ToolRegistry}.
*
* When constructed with a {@link SkillRegistry} the runtime supports
* the Agent → Skill → Tool composition model. When the registry is
* omitted (legacy / test mode) only the agent's inline `tools[]` are
* used.
*/
export class AgentRuntime {
constructor(
private readonly metadataService: IMetadataService,
private readonly skillRegistry?: SkillRegistry,
) {}
// ── Public API ────────────────────────────────────────────────
/**
* List all active agents registered in the metadata service.
*
* Returns a summary for each agent (name, label, role) suitable
* for populating an agent selector dropdown in the UI.
*/
async listAgents(): Promise<Array<{ name: string; label: string; role: string }>> {
const rawItems = await this.metadataService.list('agent');
const agents: Array<{ name: string; label: string; role: string }> = [];
for (const raw of rawItems) {
const result = AgentSchema.safeParse(raw);
if (result.success && result.data.active) {
agents.push({
name: result.data.name,
label: result.data.label,
role: result.data.role,
});
}
}
return agents;
}
/**
* Load and validate an agent definition by name.
*
* The raw metadata is validated through {@link AgentSchema} to ensure
* required fields (`instructions`, `name`, `role`, etc.) are present
* and well-typed. Returns `undefined` when the agent does not exist
* or validation fails.
*/
async loadAgent(agentName: string): Promise<Agent | undefined> {
const raw = await this.metadataService.get('agent', agentName);
if (!raw) return undefined;
const result = AgentSchema.safeParse(raw);
if (!result.success) {
return undefined;
}
return result.data;
}
/**
* Build the system message(s) that should be prepended to the
* conversation when chatting with the given agent.
*
* The composed prompt has up to three sections:
* 1. The agent's base `instructions` (its persona / prime directives).
* 2. UI context hints from {@link AgentChatContext} (current object,
* record, view) so the agent can tailor responses without extra
* tool calls.
* 3. An "Active Skills" block describing the capabilities currently
* available — only populated when `activeSkills` is provided.
*/
buildSystemMessages(
agent: Agent,
context?: AgentChatContext,
activeSkills?: readonly Skill[],
): ModelMessage[] {
const parts: string[] = [];
// Base instructions
parts.push(agent.instructions);
// Contextual hints from the user's current UI state
if (context) {
const ctx: string[] = [];
if (context.appName) ctx.push(`Current app: ${context.appName}`);
if (context.objectName) ctx.push(`Current object: ${context.objectName}`);
if (context.recordId) ctx.push(`Selected record ID: ${context.recordId}`);
if (context.viewName) ctx.push(`Current view: ${context.viewName}`);
if (ctx.length > 0) {
parts.push('\n--- Current Context ---\n' + ctx.join('\n'));
}
// Environment publishing posture. When auto-publish is on, a finished
// whole-app build is already live, so the agent should say so rather than
// ask the user to publish. Authoring skills stay neutral by default (they
// can't see this runtime setting); this is what makes the narration match
// the UI's published state instead of hedging.
if (context.autoPublishAiBuilds === true) {
parts.push(
'\n--- Publishing in this environment ---\n' +
'Whole-app builds publish AUTOMATICALLY here: the moment you finish building an app, ' +
'it is live and ready to use — there is no manual publish step for the build. Tell the ' +
'user their app is built and live (e.g. "your app is ready — open it from the launcher"). ' +
'Do NOT tell them to "publish it to make it live" for a whole-app build. Smaller ' +
'incremental edits are still staged for review — the chat panel shows each change\'s status.',
);
}
}
// Active skill bundle
if (activeSkills && activeSkills.length > 0 && this.skillRegistry) {
const block = this.skillRegistry.composeInstructionsBlock(activeSkills);
if (block) parts.push(block);
}
// Authoring (build) register availability. The unified `data_chat` persona
// (ADR-0040) advertises that it can BUILD or CHANGE the application, but
// that capability is supplied ENTIRELY by the cloud AI Studio plugin's
// `metadata_authoring` / `solution_design` skills (and their tools). On the
// open single-env framework those skills are not registered, so the
// authoring tools never resolve — yet the LLM, still reading the "you can
// build" persona, will role-play designing a whole system (emitting design
// docs it has no tools to execute). When the build register is absent,
// constrain the assistant to data/query and have it decline build requests
// instead of pretending. Keyed off actual skill presence so cloud/EE
// (AI Studio loaded) keeps the full build UX with no extra wiring.
const buildRegisterActive = !!activeSkills?.some(
(s) => s.name === 'metadata_authoring' || s.name === 'solution_design',
);
if (!buildRegisterActive) {
parts.push(
'\n--- Capabilities in this deployment ---\n' +
'Application BUILDING / AUTHORING is NOT available here. You can ONLY answer questions ' +
"about the user's existing data (query, list, count, aggregate, search) and run actions " +
'the application already exposes. You CANNOT create, change, or design objects, fields, ' +
'views, dashboards, pages, or whole apps — and you have no tools to do so. If the user ' +
'asks you to build, create, develop, or modify the application itself, do NOT design or ' +
'outline a system and do NOT pretend to build one: briefly say that AI app-building is ' +
'not available in this edition, then offer to help explore or report on existing data ' +
"instead. Answer in the user's language.",
);
}
return [{ role: 'system' as const, content: parts.join('\n') }];
}
/**
* Derive {@link AIRequestOptions} from an agent definition.
*
* Tool references declared in `agent.tools` are resolved by name against
* `availableTools` (i.e. the full set of ToolRegistry definitions).
* Tools belonging to `activeSkills` are also resolved and merged into
* the final tool list (deduplicated by name).
*
* Any unresolved references (tools the agent or skill declares but
* that are not registered) are silently skipped — this is intentional
* so that agents/skills can be defined before all tools are available.
*
* @param agent - The agent definition to derive options from
* @param availableTools - All tool definitions currently registered in the ToolRegistry
* @param activeSkills - Skills resolved from agent.skills[] + context filtering
* @returns Request options with model config and resolved tool definitions
*/
buildRequestOptions(
agent: Agent,
availableTools: readonly AIToolDefinition[],
activeSkills?: readonly Skill[],
): AIRequestOptions {
const options: AIRequestOptions = {};
// Model config
if (agent.model) {
options.model = agent.model.model;
options.temperature = agent.model.temperature;
options.maxTokens = agent.model.maxTokens;
}
// Resolve agent tool references → concrete tool definitions
const toolMap = new Map(availableTools.map((t) => [t.name, t]));
const seen = new Set<string>();
const resolved: AIToolDefinition[] = [];
if (agent.tools && agent.tools.length > 0) {
for (const ref of agent.tools) {
if (seen.has(ref.name)) continue;
const def = toolMap.get(ref.name);
if (def) {
resolved.push(def);
seen.add(ref.name);
}
}
}
// Merge skill tools (deduplicated)
if (activeSkills && activeSkills.length > 0 && this.skillRegistry) {
const skillTools = this.skillRegistry.flattenToTools(activeSkills, availableTools);
for (const def of skillTools) {
if (seen.has(def.name)) continue;
resolved.push(def);
seen.add(def.name);
}
}
if (resolved.length > 0) {
options.tools = resolved;
options.toolChoice = 'auto';
}
return options;
}
// ── Skill resolution helpers ─────────────────────────────────
/**
* Resolve the set of skills active for a given agent in a given
* context. Combines:
*
* 1. The agent's declared `skills[]` whitelist (if any).
* 2. Filtering by `triggerConditions` against the runtime context.
*
* When the agent declares no skills, returns the empty list (i.e.
* the agent only uses its inline `tools[]`).
*
* Returns an empty array if no SkillRegistry was provided to the
* runtime (legacy mode).
*/
async resolveActiveSkills(agent: Agent, context?: AgentChatContext): Promise<Skill[]> {
if (!this.skillRegistry) return [];
if (!agent.skills || agent.skills.length === 0) return [];
return this.skillRegistry.listActiveSkills(context ?? {}, agent.skills);
}
/**
* Pick a default agent for the given context, used by the ambient
* chat endpoint when the client doesn't specify an `agentName`.
*
* Resolution order:
* 1. The `defaultAgent` of the app named by `context.appName`
* (e.g. Studio → `metadata_assistant`).
* 2. The platform data-query agent (`data_chat`) — the implicit
* copilot bound to every app that doesn't pin its own. This is
* what end users get by default, so they never have to choose.
* 3. The first active agent in the registry (last-resort fallback,
* e.g. in stripped-down deployments without the data agent).
* 4. `undefined` if no agents are registered.
*/
async resolveDefaultAgent(context?: AgentChatContext): Promise<Agent | undefined> {
if (context?.appName) {
const rawApp = await this.metadataService.get('app', context.appName).catch(() => undefined);
const defaultAgentName = (rawApp as { defaultAgent?: string } | undefined)?.defaultAgent;
if (defaultAgentName) {
const agent = await this.loadAgent(defaultAgentName);
if (agent && agent.active !== false) return agent;
}
}
// Platform default: the data-query agent is the implicit copilot for
// every app without an explicit `defaultAgent`. Resolve it by name so
// the fallback is deterministic rather than registration-order
// dependent.
const dataAgent = await this.loadAgent(DEFAULT_DATA_AGENT_NAME);
if (dataAgent && dataAgent.active !== false) return dataAgent;
// Last resort: first active agent in declaration order.
const summaries = await this.listAgents();
if (summaries.length === 0) return undefined;
return this.loadAgent(summaries[0].name);
}
}