@noodleseed/one 0.148.0 → 0.150.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.
Files changed (81) hide show
  1. package/dist/commands/assistant-ops.d.ts.map +1 -1
  2. package/dist/commands/assistant-ops.js +34 -0
  3. package/dist/commands/assistant-ops.js.map +1 -1
  4. package/dist/commands/assistant-surface-ops.d.ts +17 -1
  5. package/dist/commands/assistant-surface-ops.d.ts.map +1 -1
  6. package/dist/commands/assistant-surface-ops.js +38 -2
  7. package/dist/commands/assistant-surface-ops.js.map +1 -1
  8. package/dist/commands/intents-ops.js +1 -1
  9. package/dist/commands/intents-ops.js.map +1 -1
  10. package/dist/dev-local-runtime.d.ts +1 -0
  11. package/dist/dev-local-runtime.d.ts.map +1 -1
  12. package/dist/dev-local-runtime.js +3 -0
  13. package/dist/dev-local-runtime.js.map +1 -1
  14. package/node_modules/@noodle-borg/admission-limits/dist/counter-store.d.ts +20 -0
  15. package/node_modules/@noodle-borg/admission-limits/dist/counter-store.js +12 -0
  16. package/node_modules/@noodle-borg/admission-limits/dist/envelope.d.ts +34 -2
  17. package/node_modules/@noodle-borg/admission-limits/dist/envelope.js +45 -15
  18. package/node_modules/@noodle-borg/admission-limits/dist/in-memory-counter-store.d.ts +1 -0
  19. package/node_modules/@noodle-borg/admission-limits/dist/in-memory-counter-store.js +14 -1
  20. package/node_modules/@noodle-borg/admission-limits/dist/portable.d.ts +1 -0
  21. package/node_modules/@noodle-borg/admission-limits/dist/portable.js +1 -0
  22. package/node_modules/@noodle-borg/admission-limits/dist/visitor-bucket.d.ts +20 -0
  23. package/node_modules/@noodle-borg/admission-limits/dist/visitor-bucket.js +30 -0
  24. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +1 -1
  25. package/node_modules/@noodle-borg/agent-kit/dist/skill-tool-design-ref.js +4 -0
  26. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  27. package/node_modules/@noodle-borg/assistant-gateway/dist/app-tool-call.d.ts +42 -0
  28. package/node_modules/@noodle-borg/assistant-gateway/dist/app-tool-call.js +45 -0
  29. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-configuration.d.ts +2 -2
  30. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-configuration.js +17 -3
  31. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-context.d.ts +13 -1
  32. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-context.js +53 -0
  33. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.d.ts +12 -0
  34. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.js +15 -0
  35. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-operator-view.d.ts +79 -0
  36. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-operator-view.js +67 -0
  37. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-store.d.ts +37 -0
  38. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-store.js +37 -0
  39. package/node_modules/@noodle-borg/assistant-gateway/dist/in-memory-embed-store.js +12 -0
  40. package/node_modules/@noodle-borg/assistant-gateway/dist/managed-spend.d.ts +80 -0
  41. package/node_modules/@noodle-borg/assistant-gateway/dist/managed-spend.js +186 -0
  42. package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.d.ts +16 -0
  43. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +3 -0
  44. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +3 -0
  45. package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.d.ts +14 -1
  46. package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.js +23 -6
  47. package/node_modules/@noodle-borg/assistant-gateway/dist/public-turn.js +22 -0
  48. package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.d.ts +15 -1
  49. package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.js +24 -0
  50. package/node_modules/@noodle-borg/assistant-gateway/package.json +1 -1
  51. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +31 -3
  52. package/node_modules/@noodle-borg/authoring/dist/assistant.js +3 -0
  53. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-assistant.js +29 -1
  54. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-hosted-observability.js +1 -1
  55. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +12 -0
  56. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +8 -3
  57. package/node_modules/@noodle-borg/module/dist/request-analytics.d.ts +19 -2
  58. package/node_modules/@noodle-borg/module/dist/request-analytics.js +15 -2
  59. package/node_modules/@noodle-borg/observability/dist/assistant-app-tool-usage.js +25 -0
  60. package/node_modules/@noodle-borg/observability/dist/assistant-usage.js +68 -10
  61. package/node_modules/@noodle-borg/observability/dist/index.js +2 -0
  62. package/node_modules/@noodle-borg/observability/dist/request-event-query.js +48 -0
  63. package/node_modules/@noodle-borg/observability/dist/request-events-postgres.js +13 -2
  64. package/node_modules/@noodle-borg/observability/dist/request-events.js +3 -0
  65. package/node_modules/@noodle-borg/service/dist/invocation-context.js +1 -53
  66. package/node_modules/@noodle-borg/service/dist/observability-runtime.js +4 -6
  67. package/node_modules/@noodle-borg/service/dist/routes/analytics.js +5 -29
  68. package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +57 -29
  69. package/node_modules/@noodle-borg/service/dist/routes/assistant-embeds.js +26 -38
  70. package/node_modules/@noodle-borg/service/dist/routes/assistant-interaction-resolution.js +1 -2
  71. package/node_modules/@noodle-borg/service/dist/routes/assistant-model-binding.js +1 -4
  72. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-configuration.js +1 -1
  73. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-session.js +8 -4
  74. package/node_modules/@noodle-borg/service/dist/routes/assistant-suggestions.js +1 -2
  75. package/node_modules/@noodle-borg/service/dist/routes/assistant.js +64 -49
  76. package/node_modules/@noodle-borg/service/dist/routes/intent-capture.js +0 -12
  77. package/node_modules/@noodle-borg/service/dist/service.js +2 -3
  78. package/node_modules/@noodle-borg/service/package.json +1 -1
  79. package/node_modules/@noodle-borg/transport-http/dist/request-capture.js +2 -0
  80. package/node_modules/@noodleseed/assistant/package.json +1 -1
  81. package/package.json +2 -2
@@ -1,6 +1,6 @@
1
1
  import { ADMISSION_DEFAULTS } from '@noodle-borg/admission-limits/portable';
2
2
  import { assistantCoreModelMessages, assistantTurnModelContextMessages, requestAssistantSuggestedPrompts, requestModelCompletion, resolveAssistantContextProviderModelResult, } from '@noodle-borg/assistant-gateway/model-runtime';
3
- import { assistantGuideModelContext, assistantModelToolOncePerSession, assistantModelToolRequiredWhenVisible, assistantViewAvailableData, dispatchAssistantTool, projectAssistantGuide, publicSurfaceOf, selectAssistantModelTools, toolTouchesDelegatedAuth, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
3
+ import { assistantGuideModelContext, assistantModelToolOncePerSession, assistantModelToolRequiredWhenVisible, assistantOmittedToolResult, assistantViewAvailableData, dispatchAssistantTool, projectAssistantGuide, publicSurfaceOf, selectAssistantModelTools, toolTouchesDelegatedAuth, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
4
4
  import { validateJsonSchemaWithDefaults } from '@noodle-borg/compiler';
5
5
  import { guardedFetch } from '@noodle-borg/connector-http';
6
6
  import { evaluateToolAuthorization } from '@noodle-borg/protocol';
@@ -8,6 +8,19 @@ import { interceptForElevation, offersSignIn } from './assistant-elevation.js';
8
8
  import { assistantKnowledgeModelTools, executeAssistantKnowledgeSearch, findAssistantKnowledgeComponent, KNOWLEDGE_CITATION_GUIDANCE, resolveAssistantKnowledge, } from './assistant-knowledge.js';
9
9
  import { resolveAssistantModelBinding } from './assistant-model-binding.js';
10
10
  const MAX_MODEL_RESPONSE = 1 << 20;
11
+ /**
12
+ * The agent-loop bounds come from the admission envelope so an operator lowering them changes
13
+ * behaviour. They used to be a constant here that happened to equal the envelope's default, which
14
+ * meant the envelope documented a limit it did not control.
15
+ */
16
+ function agentBounds(deps, policy) {
17
+ const { modelStepsPerTurn, toolCallsPerTurn } = deps.admissionEnvelope ?? ADMISSION_DEFAULTS;
18
+ // A per-tenant policy narrows a turn; it can never widen one, so the envelope wins when lower.
19
+ return {
20
+ steps: Math.min(modelStepsPerTurn, policy?.maxModelStepsPerTurn ?? modelStepsPerTurn),
21
+ toolCalls: Math.min(toolCallsPerTurn, policy?.maxToolCallsPerTurn ?? toolCallsPerTurn),
22
+ };
23
+ }
11
24
  export function createAssistantTurnStats() {
12
25
  return {
13
26
  modelRequests: 0,
@@ -21,9 +34,12 @@ export function createAssistantTurnStats() {
21
34
  }
22
35
  /** Run one model/tool loop against the immutable invocation-context snapshot for this turn. */
23
36
  export async function runAgentTurn(target, session, message, context, deps, emit, modelContext, pageContext, stats = createAssistantTurnStats(), suggestions = false) {
37
+ // Every terminal failure in this loop is one shape: a code, and the turn ends. Naming it keeps the
38
+ // dozen sites readable and stops a new one inventing a different envelope.
39
+ const fail = (code) => emit({ event: 'error', data: { code } });
24
40
  const assistant = target.served.artifact.server.assistant;
25
41
  if (!assistant)
26
- return emit({ event: 'error', data: { code: 'assistant_unavailable' } });
42
+ return fail('assistant_unavailable');
27
43
  const binding = await resolveAssistantModelBinding(target, session.tenant, session.deploymentId, deps);
28
44
  if (!binding) {
29
45
  return emit({
@@ -35,12 +51,18 @@ export async function runAgentTurn(target, session, message, context, deps, emit
35
51
  },
36
52
  });
37
53
  }
38
- const knowledge = await resolveAssistantKnowledge(target.served);
39
- let modelTools = selectTurnModelTools(target, session.caller, message, session.modelToolUses);
54
+ // Zero means offer none, from every source — not "call one and meet `tool_call_budget_exhausted`".
55
+ // A required tool and a knowledge tool each reach the model by their own path, so silencing only
56
+ // the ordinary ones would still advertise a tool the turn can never run.
57
+ const bounds = agentBounds(deps, binding.requestPolicy);
58
+ const toolless = bounds.toolCalls === 0;
59
+ const knowledge = toolless ? undefined : await resolveAssistantKnowledge(target.served);
60
+ let modelTools = toolless
61
+ ? []
62
+ : selectTurnModelTools(target, session.caller, message, session.modelToolUses);
40
63
  const requiredTools = modelTools.filter(assistantModelToolRequiredWhenVisible);
41
- if (requiredTools.length > 1) {
42
- return emit({ event: 'error', data: { code: 'multiple_required_model_tools' } });
43
- }
64
+ if (requiredTools.length > 1)
65
+ return fail('multiple_required_model_tools');
44
66
  let requiredTool = requiredTools[0];
45
67
  const guideProjection = projectAssistantGuide({
46
68
  appPackage: target.served.appPackageSnapshot?.artifact,
@@ -73,19 +95,17 @@ export async function runAgentTurn(target, session, message, context, deps, emit
73
95
  ...session.history,
74
96
  { role: 'user', content: message },
75
97
  ];
76
- const envelope = deps.admissionEnvelope ?? ADMISSION_DEFAULTS;
77
- const bounds = { steps: envelope.modelStepsPerTurn, toolCalls: envelope.toolCallsPerTurn };
78
98
  // Across every step of this turn, not per step: eight tool calls is eight, however the model splits
79
99
  // them, or a model that loops one call per step would spend the budget a step at a time.
80
100
  let toolCallsThisTurn = 0;
101
+ let omittedToolRecoveries = 0;
81
102
  let remainingTokens = binding.requestPolicy?.maxTokensPerTurn;
82
103
  const turnSignal = binding.requestPolicy?.maxTurnMs === undefined
83
104
  ? undefined
84
105
  : AbortSignal.timeout(binding.requestPolicy.maxTurnMs);
85
106
  for (let step = 0; step < bounds.steps; step += 1) {
86
- if (remainingTokens !== undefined && remainingTokens <= 0) {
87
- return emit({ event: 'error', data: { code: 'model_token_budget_exhausted' } });
88
- }
107
+ if (remainingTokens !== undefined && remainingTokens <= 0)
108
+ return fail('model_token_budget_exhausted');
89
109
  const requestTokenLimit = Math.min(binding.requestPolicy?.maxCompletionTokens ?? Number.MAX_SAFE_INTEGER, remainingTokens ?? Number.MAX_SAFE_INTEGER);
90
110
  const requiredToolForStep = requiredTool;
91
111
  const stepModelTools = requiredToolForStep === undefined ? modelTools : [requiredToolForStep];
@@ -106,12 +126,11 @@ export async function runAgentTurn(target, session, message, context, deps, emit
106
126
  }
107
127
  const response = completion.choices[0]?.message;
108
128
  if (!response)
109
- return emit({ event: 'error', data: { code: 'invalid_model_response' } });
129
+ return fail('invalid_model_response');
110
130
  if (requiredToolForStep !== undefined &&
111
131
  (response.tool_calls?.length !== 1 ||
112
- response.tool_calls[0]?.function.name !== requiredToolForStep.name)) {
113
- return emit({ event: 'error', data: { code: 'required_model_tool_missing' } });
114
- }
132
+ response.tool_calls[0]?.function.name !== requiredToolForStep.name))
133
+ return fail('required_model_tool_missing');
115
134
  if (!response.tool_calls?.length) {
116
135
  const assistantContent = response.content || streamedContent;
117
136
  if (assistantContent)
@@ -141,9 +160,8 @@ export async function runAgentTurn(target, session, message, context, deps, emit
141
160
  for (const call of response.tool_calls) {
142
161
  toolCallsThisTurn += 1;
143
162
  stats.toolCalls += 1;
144
- if (toolCallsThisTurn > bounds.toolCalls) {
145
- return emit({ event: 'error', data: { code: 'tool_call_budget_exhausted' } });
146
- }
163
+ if (toolCallsThisTurn > bounds.toolCalls)
164
+ return fail('tool_call_budget_exhausted');
147
165
  const knowledgeComponent = findAssistantKnowledgeComponent(knowledge, call.function.name);
148
166
  if (knowledge !== undefined && knowledgeComponent !== undefined) {
149
167
  let knowledgeArgs;
@@ -151,7 +169,7 @@ export async function runAgentTurn(target, session, message, context, deps, emit
151
169
  knowledgeArgs = JSON.parse(call.function.arguments);
152
170
  }
153
171
  catch {
154
- return emit({ event: 'error', data: { code: 'invalid_tool_arguments' } });
172
+ return fail('invalid_tool_arguments');
155
173
  }
156
174
  emit({ event: 'tool_started', data: { id: call.id, tool: call.function.name } });
157
175
  messages.push({
@@ -163,7 +181,19 @@ export async function runAgentTurn(target, session, message, context, deps, emit
163
181
  }
164
182
  const tool = stepModelTools.find((candidate) => candidate.name === call.function.name);
165
183
  if (!tool) {
166
- return emit({ event: 'error', data: { code: 'invalid_model_tool_call' } });
184
+ // Tell the model rather than ending the turn on a bare error; `assistantOmittedToolResult`
185
+ // carries the reasoning. Once per turn: a model that insists after being told is the
186
+ // protocol violation the error code exists for, and it stops paying for extra steps.
187
+ if (omittedToolRecoveries > 0)
188
+ return fail('invalid_model_tool_call');
189
+ omittedToolRecoveries += 1;
190
+ modelTools = [];
191
+ messages.push({
192
+ role: 'tool',
193
+ tool_call_id: call.id,
194
+ content: assistantOmittedToolResult(call.function.name),
195
+ });
196
+ continue;
167
197
  }
168
198
  // A mixed surface turns "denied" into "sign in first". The intercept runs BEFORE the
169
199
  // authorization denial — an ADR 0185-gated tool is exactly what an anonymous visitor signs
@@ -195,22 +225,20 @@ export async function runAgentTurn(target, session, message, context, deps, emit
195
225
  });
196
226
  continue;
197
227
  }
198
- if (!evaluateToolAuthorization(tool.authorization, session.caller).allow) {
199
- return emit({ event: 'error', data: { code: 'invalid_model_tool_call' } });
200
- }
228
+ if (!evaluateToolAuthorization(tool.authorization, session.caller).allow)
229
+ return fail('invalid_model_tool_call');
201
230
  let args;
202
231
  try {
203
232
  args = JSON.parse(call.function.arguments);
204
233
  }
205
234
  catch {
206
- return emit({ event: 'error', data: { code: 'invalid_tool_arguments' } });
235
+ return fail('invalid_tool_arguments');
207
236
  }
208
237
  // Validate AND apply schema defaults; the pending record and the execution below both use
209
238
  // the coerced copy, so the confirmed call equals the executed call (roadmap S5).
210
239
  const coerced = validateJsonSchemaWithDefaults(tool.inputSchema, args);
211
- if (coerced.issues.length > 0) {
212
- return emit({ event: 'error', data: { code: 'invalid_tool_arguments' } });
213
- }
240
+ if (coerced.issues.length > 0)
241
+ return fail('invalid_tool_arguments');
214
242
  args = coerced.value;
215
243
  const dispatch = await dispatchAssistantTool({
216
244
  artifact: target.served.artifact,
@@ -250,7 +278,7 @@ export async function runAgentTurn(target, session, message, context, deps, emit
250
278
  });
251
279
  }
252
280
  }
253
- emit({ event: 'error', data: { code: 'step_limit' } });
281
+ fail('step_limit');
254
282
  }
255
283
  /** Generate an initial prompt set from the same exact surface/context assembly as an ordinary turn. */
256
284
  export async function generateInitialAssistantSuggestions(target, session, context, deps, modelContext, pageContext) {
@@ -1,8 +1,19 @@
1
1
  import { ADMISSION_DEFAULTS, clamp } from '@noodle-borg/admission-limits/portable';
2
- import { publicSurfaceOf, surfaceEnvelope } from '@noodle-borg/assistant-gateway/portable';
2
+ import { assistantEmbedOperatorView, parsePublicEmbedBudget, publicSurfaceOf, surfaceEnvelope, } from '@noodle-borg/assistant-gateway/portable';
3
3
  import { readJsonBody, sendForbidden, sendJson } from '../http-util.js';
4
4
  import { now } from './assistant-route-http.js';
5
5
  import { authorizeControlPlane } from './control-plane.js';
6
+ /**
7
+ * The operator's view of a tenant's public surfaces, and the one control over them.
8
+ *
9
+ * `PATCH` sets the daily caps; `--turns-per-day 0` is the kill switch, and it is deliberately the only
10
+ * documented one. Revoking an embed would also stop a surface, but it destroys the paste-once id and
11
+ * every page carrying it, so it stays an exceptional action rather than the routine off-switch.
12
+ *
13
+ * Origins, capabilities, and surfaceMode are read from the *active* deployment rather than the embed
14
+ * row, so what an operator sees here is what a browser will actually be held to at mint time (the row
15
+ * keeps its provisioning-time mode by design — `ensure` never overwrites a live row).
16
+ */
6
17
  export async function handleAssistantEmbeds(req, res, tenant, embedId, deps) {
7
18
  const identity = await authorizeControlPlane(req, res, deps.gate, { requireIdentity: false });
8
19
  if (identity === false)
@@ -30,7 +41,7 @@ export async function handleAssistantEmbeds(req, res, tenant, embedId, deps) {
30
41
  const body = await readJsonBody(req, deps.maxBody);
31
42
  if (!body.ok)
32
43
  return sendJson(res, body.status, { error: body.error });
33
- const budget = parseBudget(body.value);
44
+ const budget = parsePublicEmbedBudget(body.value);
34
45
  if (!budget.ok)
35
46
  return sendJson(res, 400, { error: budget.error });
36
47
  const updated = await embeds.setBudget(embedId, budget.value, now(deps));
@@ -46,44 +57,21 @@ export async function handleAssistantEmbeds(req, res, tenant, embedId, deps) {
46
57
  res.setHeader('Allow', embedId === undefined ? 'GET' : 'PATCH');
47
58
  return sendJson(res, 405, { error: 'method not allowed' });
48
59
  }
49
- function parseBudget(body) {
50
- const { turnsPerDay, mintsPerDay } = body;
51
- const value = {};
52
- for (const [name, raw] of [
53
- ['turnsPerDay', turnsPerDay],
54
- ['mintsPerDay', mintsPerDay],
55
- ]) {
56
- if (raw === undefined)
57
- continue;
58
- // Zero is valid and is the point; anything negative or fractional is a mistake worth saying out
59
- // loud rather than silently flooring, because an operator setting a cap is being deliberate.
60
- if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 0) {
61
- return { ok: false, error: `"${name}" must be a non-negative integer` };
62
- }
63
- value[name] = raw;
64
- }
65
- if (Object.keys(value).length === 0) {
66
- return { ok: false, error: 'set at least one of "turnsPerDay" or "mintsPerDay"' };
67
- }
68
- return { ok: true, value };
69
- }
70
60
  async function view(record, tenant, defaults, deps, counters) {
71
61
  const target = await deps.registry.getActiveByTenant(tenant);
72
- const surface = publicSurfaceOf(target?.served.artifact.server.assistant);
73
- // Clamped even for the pure-default case, so what an operator reads is what admission will enforce.
74
- const envelope = surfaceEnvelope(clamp(defaults), record);
75
62
  const at = now(deps);
76
- return {
77
- embedId: record.embedId,
78
- surfaceMode: surface?.mode ?? record.surfaceMode,
79
- origins: surface?.origins ?? [],
80
- capabilities: surface?.capabilities.map((entry) => entry.name) ?? [],
81
- turnsPerDay: envelope.turnsPerDay,
82
- mintsPerDay: envelope.mintsPerDay,
83
- budgetIsDefault: record.turnsPerDay === undefined && record.mintsPerDay === undefined,
84
- turnsToday: await counters.peek(`turns:${record.embedId}`, at),
85
- mintsToday: await counters.peek(`mints:${record.embedId}`, at),
86
- createdAt: record.createdAt.toISOString(),
87
- };
63
+ // The same bounds admission resolves, not the bare defaults: a sponsored surface enforces the
64
+ // hosted envelope, and reporting the unsponsored one told an operator a number nothing honours.
65
+ const deploymentId = target?.deploymentId ?? '';
66
+ const bounds = (await deps.managedModelResolver?.resolve({ tenant, deploymentId }))
67
+ ?.publicAdmission;
68
+ return assistantEmbedOperatorView({
69
+ record,
70
+ surface: publicSurfaceOf(target?.served.artifact.server.assistant),
71
+ // Clamped even for the pure-default case, so what an operator reads is what admission enforces.
72
+ envelope: surfaceEnvelope(clamp(defaults), record, bounds),
73
+ peek: (key) => counters.peek(key, at),
74
+ ...(bounds?.spend === undefined ? {} : { spend: bounds.spend }),
75
+ });
88
76
  }
89
77
  //# sourceMappingURL=assistant-embeds.js.map
@@ -1,5 +1,4 @@
1
- import { withAssistantSessionExecutionAuthority } from '@noodle-borg/assistant-gateway/portable';
2
- import { resolveInvocationContextSnapshot } from '../invocation-context.js';
1
+ import { resolveInvocationContextSnapshot, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
3
2
  import { now } from './assistant.js';
4
3
  export function assistantInteractionScope(id, session, instant) {
5
4
  return {
@@ -5,10 +5,7 @@ export async function resolveAssistantModelBinding(target, tenant, deploymentId,
5
5
  if (declaration === undefined)
6
6
  return undefined;
7
7
  if (declaration.kind === 'noodle-managed') {
8
- return deps.managedModelResolver?.resolve({
9
- tenant,
10
- deploymentId,
11
- });
8
+ return deps.managedModelResolver?.resolve({ tenant, deploymentId });
12
9
  }
13
10
  const scope = resolveConfigScope(tenant);
14
11
  const [variables, secrets] = await Promise.all([
@@ -29,7 +29,7 @@ export async function handlePublicAssistantConfiguration(req, res, embedId, deps
29
29
  if (target === undefined) {
30
30
  return sendJson(res, 409, { error: 'assistant deployment is unavailable' });
31
31
  }
32
- const resolved = await effectiveAssistantBrowserConfiguration(target.served.artifact.server, publicEmbedTenant(authorized.embed), deps.appearance);
32
+ const resolved = await effectiveAssistantBrowserConfiguration(target.served.artifact.server, publicEmbedTenant(authorized.embed), deps.appearance, 'public');
33
33
  applyBrowserCors(req, res, origin);
34
34
  res.setHeader('cache-control', 'private, no-cache');
35
35
  return sendJson(res, 200, {
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { ADMISSION_DEFAULTS, clientAddressBucket } from '@noodle-borg/admission-limits/portable';
3
3
  import { effectiveAssistantBrowserConfiguration, mintPublicSession, publicEmbedTenant, publicSurfaceOf, } from '@noodle-borg/assistant-gateway/portable';
4
- import { assistantSessionUsageRequestEvent, captureAssistantUsage, } from '@noodle-borg/observability';
4
+ import { assistantSessionUsageRequestEvent, captureAssistantUsage, captureRefusedAssistantSession, } from '@noodle-borg/observability';
5
5
  import { assistantSessionResponseSchema } from '@noodle-borg/wire-contracts';
6
6
  import { readJsonBody, sendJson } from '../http-util.js';
7
7
  import { applyBrowserCors, assistantSessionEndpoints, now } from './assistant-route-http.js';
@@ -25,7 +25,7 @@ export async function handlePublicAssistantSession(req, res, deps) {
25
25
  const body = await readJsonBody(req, deps.maxBody);
26
26
  if (!body.ok)
27
27
  return sendJson(res, body.status, { error: body.error });
28
- const { embedId } = body.value;
28
+ const { embedId, visitorId } = body.value;
29
29
  // The browser's Origin header is the claim under test. The body never asserts its own origin.
30
30
  const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
31
31
  // Admission tier 3. Hashed here so no raw address crosses into the decision, a counter key, or an
@@ -36,7 +36,7 @@ export async function handlePublicAssistantSession(req, res, deps) {
36
36
  let configuration;
37
37
  let sponsoredBudget;
38
38
  let usageSession;
39
- const result = await mintPublicSession({ embedId, origin, ...(addressBucket ? { addressBucket } : {}) }, deps.admissionEnvelope ?? ADMISSION_DEFAULTS, {
39
+ const result = await mintPublicSession({ embedId, visitorId, origin, ...(addressBucket ? { addressBucket } : {}) }, deps.admissionEnvelope ?? ADMISSION_DEFAULTS, {
40
40
  embeds,
41
41
  counters,
42
42
  resolveActiveSurface: async (embed) => {
@@ -59,7 +59,7 @@ export async function handlePublicAssistantSession(req, res, deps) {
59
59
  if (!target?.deploymentId)
60
60
  throw new Error('assistant deployment vanished mid-mint');
61
61
  const current = now(deps);
62
- configuration = (await effectiveAssistantBrowserConfiguration(target.served.artifact.server, publicEmbedTenant(input.embed), deps.appearance)).effective;
62
+ configuration = (await effectiveAssistantBrowserConfiguration(target.served.artifact.server, publicEmbedTenant(input.embed), deps.appearance, 'public')).effective;
63
63
  const created = await deps.store.createSession({
64
64
  // A public surface has no client credential, so the embed id stands in as the minting
65
65
  // authority. It is not a secret and nothing downstream may begin treating it as one.
@@ -86,6 +86,10 @@ export async function handlePublicAssistantSession(req, res, deps) {
86
86
  now: () => now(deps),
87
87
  });
88
88
  if (!result.ok) {
89
+ // A refusal the surface owns is usage. Without it an operator whose visitors are being turned
90
+ // away sees a healthy-looking page; which refusals count is the helper's decision, not this one's.
91
+ const refusedIn = performance.now() - usageStartedAt;
92
+ captureRefusedAssistantSession(deps.captureRequestEvent, result, refusedIn);
89
93
  return sendJson(res, result.status, { error: result.message, code: result.code });
90
94
  }
91
95
  // Only here, with the origin proven against the live surface, may the page read the response.
@@ -1,8 +1,7 @@
1
1
  import { ADMISSION_DEFAULTS } from '@noodle-borg/admission-limits/portable';
2
- import { parseAssistantContextPreferences, surfaceEnvelope, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
2
+ import { parseAssistantContextPreferences, resolveInvocationContextSnapshot, surfaceEnvelope, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
3
3
  import { assistantSuggestionsRequestSchema } from '@noodle-borg/wire-contracts';
4
4
  import { readJsonBody, sendJson } from '../http-util.js';
5
- import { resolveInvocationContextSnapshot } from '../invocation-context.js';
6
5
  import { generateInitialAssistantSuggestions } from './assistant-agent.js';
7
6
  import { applyBrowserCors, authenticateSession, now } from './assistant-route-http.js';
8
7
  import { sessionScopedTarget } from './assistant-session-target.js';
@@ -1,15 +1,13 @@
1
1
  import { clientAddressBucket, } from '@noodle-borg/admission-limits/portable';
2
2
  import { assistantModelFailure, assistantModelSource, assistantModelTransport, } from '@noodle-borg/assistant-gateway/model-runtime';
3
- import { ASSISTANT_SESSION_IDLE_MS, dispatchAssistantTool, effectiveAssistantBrowserConfiguration, isAssistantPageContext, isAssistantVerifiedClaims, parseAssistantContextPreferences, parseAssistantCustomerRouting, refuseBridgeToolCall, refusePublicTurn, resumeTurnMessage, resumeUnavailableMessage, shouldAutoResume, surfaceBindingForOrigin, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
3
+ import { ASSISTANT_SESSION_IDLE_MS, effectiveAssistantBrowserConfiguration, executeAssistantAppToolCall, isAssistantPageContext, isAssistantVerifiedClaims, parseAssistantContextPreferences, parseAssistantCustomerRouting, refuseBridgeToolCall, refusePublicTurn, resolveInvocationContextSnapshot, resumeTurnMessage, resumeUnavailableMessage, shouldAutoResume, surfaceBindingForOrigin, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
4
4
  import { canonicalizeAuthorizationClaimValues } from '@noodle-borg/auth';
5
- import { validateJsonSchemaWithDefaults } from '@noodle-borg/compiler';
6
5
  import { tenantMcpUrl } from '@noodle-borg/module';
7
- import { assistantRefusedTurnUsageRequestEvent, assistantSessionUsageRequestEvent, assistantTurnNumber, assistantTurnUsageRequestEvent, captureAssistantUsage, } from '@noodle-borg/observability';
8
- import { evaluateToolAuthorization, mapExecutionError, mapResourceContents, mapResourcesList, mapToolOutput, mapToolsList, } from '@noodle-borg/protocol';
6
+ import { assistantAppToolCallRequestEvent, assistantRefusedTurnUsageRequestEvent, assistantSessionUsageRequestEvent, assistantTurnNumber, assistantTurnUsageRequestEvent, captureAssistantUsage, } from '@noodle-borg/observability';
7
+ import { mapExecutionError, mapResourceContents, mapResourcesList, mapToolOutput, mapToolsList, } from '@noodle-borg/protocol';
9
8
  import { executeResource } from '@noodle-borg/runtime';
10
9
  import { assistantMessageTurnRequestSchema, assistantResumeTurnRequestSchema, assistantSessionResponseSchema, } from '@noodle-borg/wire-contracts';
11
10
  import { readJsonBody, sendForbidden, sendJson, sendUnauthorized } from '../http-util.js';
12
- import { resolveInvocationContextSnapshot } from '../invocation-context.js';
13
11
  import { createAssistantTurnStats, runAgentTurn } from './assistant-agent.js';
14
12
  import { elevateAssistantSession } from './assistant-elevation.js';
15
13
  import { executeAssistantKnowledgeSearch, findAssistantKnowledgeComponent, resolveAssistantKnowledge, } from './assistant-knowledge.js';
@@ -99,7 +97,7 @@ export async function handleAssistantSession(req, res, deps) {
99
97
  const declaredClaims = assistant.sessionClaims ?? {};
100
98
  const claims = Object.fromEntries(Object.entries(isAssistantVerifiedClaims(parsed.claims) ? parsed.claims : {}).filter(([key]) => key in declaredClaims));
101
99
  const current = now(deps);
102
- const configuration = (await effectiveAssistantBrowserConfiguration(target.served.artifact.server, client.tenant, deps.appearance)).effective;
100
+ const configuration = (await effectiveAssistantBrowserConfiguration(target.served.artifact.server, client.tenant, deps.appearance, surfaceBinding.kind === 'pre-surfaces' ? undefined : surfaceBinding.kind)).effective;
103
101
  const roles = canonicalizeAuthorizationClaimValues(parsed.user.roles, 'role');
104
102
  const scopes = canonicalizeAuthorizationClaimValues(parsed.user.scopes, 'scope');
105
103
  // Hoisted so an elevation and a fresh exchange bind the *same* principal. Two constructions would be
@@ -174,6 +172,16 @@ export async function handleAssistantSession(req, res, deps) {
174
172
  captureAssistantUsage(deps.captureRequestEvent, assistantSessionUsageRequestEvent(session.session, performance.now() - usageStartedAt));
175
173
  return sendJson(res, 201, sessionBody);
176
174
  }
175
+ /**
176
+ * Both turn entries — a resume and a typed message — refuse identically: the refusal is spent
177
+ * budget, so it is recorded against the surface before the caller is told no. Kept in one place so
178
+ * the two entries cannot drift into recording it differently.
179
+ */
180
+ function refuseTurn(deps, res, session, refusal, usageStartedAt) {
181
+ const durationMs = performance.now() - usageStartedAt;
182
+ captureAssistantUsage(deps.captureRequestEvent, assistantRefusedTurnUsageRequestEvent(session, refusal.code, durationMs));
183
+ sendJson(res, refusal.status, { error: refusal.message, code: refusal.code });
184
+ }
177
185
  export async function handleAssistantTurn(req, res, deps) {
178
186
  const usageStartedAt = performance.now();
179
187
  const session = await authenticateSession(req, res, deps);
@@ -201,10 +209,8 @@ export async function handleAssistantTurn(req, res, deps) {
201
209
  }
202
210
  suggestionsRequested = resumeResult.data.suggestions === true;
203
211
  const refusal = await refusePublicTurn(deps, session, 'resume', addressBucket);
204
- if (refusal) {
205
- captureAssistantUsage(deps.captureRequestEvent, assistantRefusedTurnUsageRequestEvent(session, refusal.code, performance.now() - usageStartedAt));
206
- return sendJson(res, refusal.status, { error: refusal.message, code: refusal.code });
207
- }
212
+ if (refusal)
213
+ return refuseTurn(deps, res, session, refusal, usageStartedAt);
208
214
  const pending = await deps.store.consumePendingResume(session.id);
209
215
  if (!pending) {
210
216
  return sendJson(res, 409, {
@@ -252,10 +258,8 @@ export async function handleAssistantTurn(req, res, deps) {
252
258
  // Admission before any metered work (ADR 0201 §8): ahead of the deployment read and well ahead
253
259
  // of the model, so a refused turn costs the customer nothing.
254
260
  const refusal = await refusePublicTurn(deps, session, message, addressBucket);
255
- if (refusal) {
256
- captureAssistantUsage(deps.captureRequestEvent, assistantRefusedTurnUsageRequestEvent(session, refusal.code, performance.now() - usageStartedAt));
257
- return sendJson(res, refusal.status, { error: refusal.message, code: refusal.code });
258
- }
261
+ if (refusal)
262
+ return refuseTurn(deps, res, session, refusal, usageStartedAt);
259
263
  // The visitor typed first: any pending post-sign-in resume is moot, and must not fire later.
260
264
  if (session.pendingResume)
261
265
  await deps.store.consumePendingResume(session.id);
@@ -399,15 +403,18 @@ export async function handleAssistantAppRequest(req, res, deps) {
399
403
  const resource = target.served.artifact.resources?.find((candidate) => candidate.uri === params.uri);
400
404
  if (!resource)
401
405
  return sendJson(res, 404, { error: 'resource not found' });
406
+ // One projection for both the snapshot and the execution: they must agree, and building it
407
+ // twice was two chances for them not to.
408
+ const executeDeps = withAssistantSessionExecutionAuthority(target.served.deps, target.served.artifact, session);
402
409
  const context = await resolveInvocationContextSnapshot({
403
410
  artifact: target.served.artifact,
404
- executeDeps: withAssistantSessionExecutionAuthority(target.served.deps, target.served.artifact, session),
411
+ executeDeps,
405
412
  caller: session.caller,
406
413
  instant: now(deps),
407
414
  ...(session.preferences ? { applicationPreference: session.preferences } : {}),
408
415
  });
409
416
  const execution = await executeResource(target.served.artifact, resource.name, {}, {
410
- ...withAssistantSessionExecutionAuthority(target.served.deps, target.served.artifact, session),
417
+ ...executeDeps,
411
418
  caller: session.caller,
412
419
  context,
413
420
  });
@@ -418,56 +425,64 @@ export async function handleAssistantAppRequest(req, res, deps) {
418
425
  if (method === 'tools/call') {
419
426
  if (typeof params.name !== 'string')
420
427
  return sendJson(res, 400, { error: 'tool name required' });
421
- const knowledgeComponent = findAssistantKnowledgeComponent(knowledge, params.name);
428
+ const startedAt = performance.now();
429
+ const toolName = params.name;
430
+ // Recorded on every exit below, including the refusals: a rejected browser-agent call is
431
+ // exactly the traffic an operator watching one needs to see (ADR 0220 decision 6).
432
+ const record = (outcome, errorKind) => captureAssistantUsage(deps.captureRequestEvent, assistantAppToolCallRequestEvent(session, {
433
+ toolName,
434
+ // Attribution only. The marker labels the row; the session decided authority above.
435
+ bridged,
436
+ outcome,
437
+ ...(errorKind === undefined ? {} : { errorKind }),
438
+ durationMs: performance.now() - startedAt,
439
+ }));
440
+ const knowledgeComponent = findAssistantKnowledgeComponent(knowledge, toolName);
422
441
  if (knowledge !== undefined && knowledgeComponent !== undefined) {
423
442
  const content = await executeAssistantKnowledgeSearch(knowledge, knowledgeComponent, params.arguments ?? {});
443
+ record('ok');
424
444
  return sendJson(res, 200, mapToolOutput(JSON.parse(content)));
425
445
  }
426
- const tool = target.served.artifact.tools.find((candidate) => candidate.name === params.name);
427
- if (!tool || tool._meta?.ui?.visibility?.includes('app') === false) {
446
+ const call = await executeAssistantAppToolCall({
447
+ artifact: target.served.artifact,
448
+ deps: target.served.deps,
449
+ session,
450
+ toolName,
451
+ arguments: params.arguments,
452
+ now: () => now(deps),
453
+ store: deps.store,
454
+ audit: deps.audit,
455
+ });
456
+ if (call.kind === 'not-found') {
457
+ record('refused', 'app_tool_not_found');
428
458
  return sendJson(res, 404, { error: 'app tool not found' });
429
459
  }
430
- if (!evaluateToolAuthorization(tool.authorization, session.caller).allow) {
460
+ if (call.kind === 'forbidden') {
461
+ record('refused', 'tool_forbidden');
431
462
  return sendJson(res, 403, { error: 'tool forbidden' });
432
463
  }
433
- const context = await resolveInvocationContextSnapshot({
434
- artifact: target.served.artifact,
435
- executeDeps: withAssistantSessionExecutionAuthority(target.served.deps, target.served.artifact, session),
436
- caller: session.caller,
437
- instant: now(deps),
438
- ...(session.preferences ? { applicationPreference: session.preferences } : {}),
439
- });
440
- const validated = validateJsonSchemaWithDefaults(tool.inputSchema, params.arguments ?? {});
441
- if (validated.issues.length > 0) {
464
+ if (call.kind === 'invalid-arguments') {
465
+ record('refused', 'invalid_tool_arguments');
442
466
  return sendJson(res, 200, {
443
467
  content: [{ type: 'text', text: 'invalid tool arguments' }],
444
468
  isError: true,
445
469
  });
446
470
  }
447
- const dispatched = await dispatchAssistantTool({
448
- artifact: target.served.artifact,
449
- tool,
450
- arguments: validated.value,
451
- executeDeps: withAssistantSessionExecutionAuthority(target.served.deps, target.served.artifact, session),
452
- caller: session.caller,
453
- context,
454
- session,
455
- store: deps.store,
456
- audit: deps.audit,
457
- now: () => now(deps),
458
- });
459
- if (dispatched.kind === 'event') {
460
- if (dispatched.event === 'tool_proposed' || dispatched.event === 'input_requested') {
461
- return sendJson(res, 200, {
462
- interaction: { event: dispatched.event, data: dispatched.data },
463
- });
464
- }
471
+ if (call.kind === 'interaction') {
472
+ // A raised confirmation is not a failure and not yet an execution: the bridge did what it
473
+ // should, and the interaction is answered on its own route.
474
+ record('ok');
475
+ return sendJson(res, 200, { interaction: { event: call.event, data: call.data } });
476
+ }
477
+ if (call.kind === 'error') {
478
+ record('failed', call.code);
465
479
  return sendJson(res, 200, {
466
- content: [{ type: 'text', text: String(dispatched.data.code ?? dispatched.event) }],
480
+ content: [{ type: 'text', text: call.code }],
467
481
  isError: true,
468
482
  });
469
483
  }
470
- return sendJson(res, 200, mapToolOutput(dispatched.output));
484
+ record('ok');
485
+ return sendJson(res, 200, mapToolOutput(call.output));
471
486
  }
472
487
  return sendJson(res, 400, { error: 'unsupported MCP App method' });
473
488
  }
@@ -14,9 +14,6 @@ export async function handleIntentCaptureSettings(req, res, ref, deps) {
14
14
  const identity = await authorizeTenantControl(req, res, deps.gate, deps.controlPlane, ref.org);
15
15
  if (identity === false)
16
16
  return;
17
- if (!deps.previewOrgs.has(ref.org)) {
18
- return sendJson(res, 403, { ok: false, error: 'intent capture preview is unavailable' });
19
- }
20
17
  const setting = await deps.settings.get(ref);
21
18
  return sendJson(res, 200, {
22
19
  ok: true,
@@ -32,9 +29,6 @@ export async function handleIntentCaptureSettings(req, res, ref, deps) {
32
29
  const identity = await authorizeOwner(req, res, ref, deps);
33
30
  if (identity === undefined)
34
31
  return;
35
- if (!deps.previewOrgs.has(ref.org)) {
36
- return sendJson(res, 403, { ok: false, error: 'intent capture preview is unavailable' });
37
- }
38
32
  const body = await readJsonBody(req, deps.maxBody);
39
33
  if (!body.ok)
40
34
  return sendJson(res, body.status, { error: body.error });
@@ -74,9 +68,6 @@ export async function handleIntentCaptureSettings(req, res, ref, deps) {
74
68
  const identity = await authorizeOwner(req, res, ref, deps);
75
69
  if (identity === undefined)
76
70
  return;
77
- if (!deps.previewOrgs.has(ref.org)) {
78
- return sendJson(res, 403, { ok: false, error: 'intent capture preview is unavailable' });
79
- }
80
71
  await deps.settings.delete(ref);
81
72
  const purged = await deps.intents.purge(ref);
82
73
  await deps.audit.emit({
@@ -98,9 +89,6 @@ export async function handleIntentInsights(req, res, ref, url, deps) {
98
89
  const identity = await authorizeTenantControl(req, res, deps.gate, deps.controlPlane, ref.org);
99
90
  if (identity === false)
100
91
  return;
101
- if (!deps.previewOrgs.has(ref.org)) {
102
- return sendJson(res, 403, { ok: false, error: 'intent capture preview is unavailable' });
103
- }
104
92
  const windowName = url.searchParams.get('window') ?? '7d';
105
93
  const windowMs = WINDOWS_MS.get(windowName);
106
94
  if (windowMs === undefined) {
@@ -57,7 +57,7 @@ export function createServiceHandler(registry, options = {}) {
57
57
  let activeAudit;
58
58
  const configStore = options.configStore ?? registry.configStore;
59
59
  const alertRuleStore = options.alertRuleStore ?? new InMemoryAlertRuleStore();
60
- const [intentSettings, intentEvents, requestEvents, intentPreviewOrgs] = createObservabilityStores(options);
60
+ const [intentSettings, intentEvents, requestEvents] = createObservabilityStores(options);
61
61
  const assistantStore = options.assistantStore ?? new InMemoryAssistantStore();
62
62
  const assistantAppearance = options.assistantAppearance ?? new InMemoryAssistantAppearanceSettingsStore();
63
63
  const anonymousLimiter = new AnonymousConsumerLimiter();
@@ -76,7 +76,7 @@ export function createServiceHandler(registry, options = {}) {
76
76
  activeAudit = audit;
77
77
  const { publicTenantRouting } = mcp.mcpRoutingOptions(options, controlPlane);
78
78
  const resolveEndpointOptions = (org) => mcp.endpointUrlOptionsForOrg(options, controlPlane, org);
79
- const withIntentMode = createIntentTargetResolver(intentSettings, intentPreviewOrgs);
79
+ const withIntentMode = createIntentTargetResolver(intentSettings);
80
80
  const router = createMcpRouter(async (id) => withIntentMode(await registry.getServing(id)), {
81
81
  logger,
82
82
  tls,
@@ -122,7 +122,6 @@ export function createServiceHandler(registry, options = {}) {
122
122
  requestEvents,
123
123
  intentEvents,
124
124
  intentSettings,
125
- previewOrgs: intentPreviewOrgs,
126
125
  audit,
127
126
  maxBody,
128
127
  tls,
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@modelcontextprotocol/sdk": "^1.29.0",
41
41
  "@noodle-borg/admission-limits": "0.0.0",
42
- "@noodle-borg/agent-kit": "0.87.1",
42
+ "@noodle-borg/agent-kit": "0.89.0",
43
43
  "@noodle-borg/app-package": "0.0.0",
44
44
  "@noodle-borg/assistant-gateway": "0.0.0",
45
45
  "@noodle-borg/auth": "0.0.0",