@noodleseed/one 0.148.0 → 0.149.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 (59) 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 +14 -1
  5. package/dist/commands/assistant-surface-ops.d.ts.map +1 -1
  6. package/dist/commands/assistant-surface-ops.js +33 -2
  7. package/dist/commands/assistant-surface-ops.js.map +1 -1
  8. package/dist/dev-local-runtime.d.ts +1 -0
  9. package/dist/dev-local-runtime.d.ts.map +1 -1
  10. package/dist/dev-local-runtime.js +3 -0
  11. package/dist/dev-local-runtime.js.map +1 -1
  12. package/node_modules/@noodle-borg/admission-limits/dist/counter-store.d.ts +20 -0
  13. package/node_modules/@noodle-borg/admission-limits/dist/counter-store.js +12 -0
  14. package/node_modules/@noodle-borg/admission-limits/dist/envelope.d.ts +34 -2
  15. package/node_modules/@noodle-borg/admission-limits/dist/envelope.js +35 -11
  16. package/node_modules/@noodle-borg/admission-limits/dist/in-memory-counter-store.d.ts +1 -0
  17. package/node_modules/@noodle-borg/admission-limits/dist/in-memory-counter-store.js +14 -1
  18. package/node_modules/@noodle-borg/admission-limits/dist/portable.d.ts +1 -0
  19. package/node_modules/@noodle-borg/admission-limits/dist/portable.js +1 -0
  20. package/node_modules/@noodle-borg/admission-limits/dist/visitor-bucket.d.ts +20 -0
  21. package/node_modules/@noodle-borg/admission-limits/dist/visitor-bucket.js +30 -0
  22. package/node_modules/@noodle-borg/agent-kit/dist/skill-tool-design-ref.js +4 -0
  23. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  24. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.d.ts +12 -0
  25. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.js +15 -0
  26. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-operator-view.d.ts +71 -0
  27. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-operator-view.js +59 -0
  28. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-store.d.ts +27 -0
  29. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-store.js +35 -0
  30. package/node_modules/@noodle-borg/assistant-gateway/dist/in-memory-embed-store.js +6 -0
  31. package/node_modules/@noodle-borg/assistant-gateway/dist/managed-spend.d.ts +80 -0
  32. package/node_modules/@noodle-borg/assistant-gateway/dist/managed-spend.js +186 -0
  33. package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.d.ts +16 -0
  34. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +2 -0
  35. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +2 -0
  36. package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.d.ts +14 -1
  37. package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.js +23 -6
  38. package/node_modules/@noodle-borg/assistant-gateway/dist/public-turn.js +22 -0
  39. package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.d.ts +15 -1
  40. package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.js +14 -0
  41. package/node_modules/@noodle-borg/assistant-gateway/package.json +1 -1
  42. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-assistant.js +15 -1
  43. package/node_modules/@noodle-borg/module/dist/request-analytics.d.ts +19 -2
  44. package/node_modules/@noodle-borg/module/dist/request-analytics.js +15 -2
  45. package/node_modules/@noodle-borg/observability/dist/assistant-usage.js +68 -10
  46. package/node_modules/@noodle-borg/observability/dist/index.js +1 -0
  47. package/node_modules/@noodle-borg/observability/dist/request-event-query.js +48 -0
  48. package/node_modules/@noodle-borg/observability/dist/request-events-postgres.js +13 -2
  49. package/node_modules/@noodle-borg/observability/dist/request-events.js +3 -0
  50. package/node_modules/@noodle-borg/service/dist/routes/analytics.js +5 -29
  51. package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +57 -29
  52. package/node_modules/@noodle-borg/service/dist/routes/assistant-embeds.js +26 -38
  53. package/node_modules/@noodle-borg/service/dist/routes/assistant-model-binding.js +1 -4
  54. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-session.js +7 -3
  55. package/node_modules/@noodle-borg/service/dist/routes/assistant.js +14 -8
  56. package/node_modules/@noodle-borg/service/package.json +1 -1
  57. package/node_modules/@noodle-borg/transport-http/dist/request-capture.js +2 -0
  58. package/node_modules/@noodleseed/assistant/package.json +1 -1
  59. 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
@@ -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([
@@ -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) => {
@@ -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.
@@ -174,6 +174,16 @@ export async function handleAssistantSession(req, res, deps) {
174
174
  captureAssistantUsage(deps.captureRequestEvent, assistantSessionUsageRequestEvent(session.session, performance.now() - usageStartedAt));
175
175
  return sendJson(res, 201, sessionBody);
176
176
  }
177
+ /**
178
+ * Both turn entries — a resume and a typed message — refuse identically: the refusal is spent
179
+ * budget, so it is recorded against the surface before the caller is told no. Kept in one place so
180
+ * the two entries cannot drift into recording it differently.
181
+ */
182
+ function refuseTurn(deps, res, session, refusal, usageStartedAt) {
183
+ const durationMs = performance.now() - usageStartedAt;
184
+ captureAssistantUsage(deps.captureRequestEvent, assistantRefusedTurnUsageRequestEvent(session, refusal.code, durationMs));
185
+ sendJson(res, refusal.status, { error: refusal.message, code: refusal.code });
186
+ }
177
187
  export async function handleAssistantTurn(req, res, deps) {
178
188
  const usageStartedAt = performance.now();
179
189
  const session = await authenticateSession(req, res, deps);
@@ -201,10 +211,8 @@ export async function handleAssistantTurn(req, res, deps) {
201
211
  }
202
212
  suggestionsRequested = resumeResult.data.suggestions === true;
203
213
  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
- }
214
+ if (refusal)
215
+ return refuseTurn(deps, res, session, refusal, usageStartedAt);
208
216
  const pending = await deps.store.consumePendingResume(session.id);
209
217
  if (!pending) {
210
218
  return sendJson(res, 409, {
@@ -252,10 +260,8 @@ export async function handleAssistantTurn(req, res, deps) {
252
260
  // Admission before any metered work (ADR 0201 §8): ahead of the deployment read and well ahead
253
261
  // of the model, so a refused turn costs the customer nothing.
254
262
  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
- }
263
+ if (refusal)
264
+ return refuseTurn(deps, res, session, refusal, usageStartedAt);
259
265
  // The visitor typed first: any pending post-sign-in resume is moot, and must not fire later.
260
266
  if (session.pendingResume)
261
267
  await deps.store.consumePendingResume(session.id);
@@ -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.88.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",
@@ -77,6 +77,7 @@ export function emitRequestEvent(input) {
77
77
  ...(clientVersion !== undefined ? { clientVersion } : {}),
78
78
  clientFamily: family,
79
79
  ...(auth.accessMode !== undefined ? { accessMode: auth.accessMode } : {}),
80
+ surface: 'mcp',
80
81
  subjectKind: subject !== undefined ? 'authenticated' : 'anonymous',
81
82
  method,
82
83
  kind: DISCOVERY_METHODS.has(method) ? 'discovery' : 'usage',
@@ -147,6 +148,7 @@ export function emitDeniedRequestEvent(input) {
147
148
  sessionSource: 'none',
148
149
  clientFamily: clientFamily(undefined, header(req, 'user-agent')),
149
150
  ...(auth.accessMode !== undefined ? { accessMode: auth.accessMode } : {}),
151
+ surface: 'mcp',
150
152
  subjectKind: 'anonymous',
151
153
  method,
152
154
  kind: 'usage',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noodleseed/assistant",
3
- "version": "1.28.0",
3
+ "version": "1.29.0",
4
4
  "description": "Embed the Noodle Seed customer-branded assistant in your web app with managed or framework-owned UI, a framework-neutral MCP App host, a DOM-free client, and a backend session helper. Authoring and deploying the server is @noodleseed/one.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noodleseed/one",
3
- "version": "0.148.0",
3
+ "version": "0.149.0",
4
4
  "private": false,
5
5
  "description": "Noodle CLI by Noodle Seed — author, run, and deploy declarative MCP servers. Embedding the assistant in your own web app is @noodleseed/assistant.",
6
6
  "license": "Apache-2.0",
@@ -236,7 +236,7 @@
236
236
  "@modelcontextprotocol/client": "2.0.0",
237
237
  "@modelcontextprotocol/server": "2.0.0",
238
238
  "@noodle-borg/admission-limits": "0.0.0",
239
- "@noodle-borg/agent-kit": "0.87.1",
239
+ "@noodle-borg/agent-kit": "0.88.0",
240
240
  "@noodle-borg/app-audit": "0.0.0",
241
241
  "@noodle-borg/app-package": "0.0.0",
242
242
  "@noodle-borg/assistant-gateway": "0.0.0",