From 2310bf04bd4a929a493812dcc5330c263683bd6e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 06:03:31 +0000 Subject: [PATCH 1/3] feat(training-agent): add /si tenant for Sponsored Intelligence lifecycle (#3940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `si` training-agent tenant at `/si/mcp` serving the full SI session lifecycle (si_get_offering, si_initiate_session, si_send_message, si_terminate_session). Fixes the S5 capstone and C3 exercises that returned `Unknown tool: si_initiate_session` due to no tenant serving si_* tools. Stub handlers simulate the BRAND-AGENT side of the protocol so a learner can practice as the HOST. Nova Brands training fixture. In-memory session state (same pattern as other training-agent state). Also bundles education-expert findings from issue review: - Migration 465: backfills tenant_ids for A3, C3, S5; fixes C3 c3_ex2 phantom tool (connect_to_si_agent → si_initiate_session); appends stable criterion IDs to S5 s3_ex1 for recertification. - Updates tool-catalog drift test to include si tenant. Storyboard floors in .github/workflows/training-agent-storyboards.yml left for human follow-up (agent infra, off-limits per policy). https://claude.ai/code/session_01SAU73ibxYgk3pdvmMJNjcF --- .changeset/add-si-training-tenant.md | 28 ++ .../migrations/465_si_tenant_module_pins.sql | 150 +++++++++++ server/src/training-agent/tenants/registry.ts | 5 +- server/src/training-agent/tenants/si.ts | 245 ++++++++++++++++++ .../training-agent/tenants/tool-catalog.ts | 6 + server/src/training-agent/v6-si-platform.ts | 76 ++++++ .../training-agent-tool-catalog-drift.test.ts | 2 +- 7 files changed, 510 insertions(+), 2 deletions(-) create mode 100644 .changeset/add-si-training-tenant.md create mode 100644 server/src/db/migrations/465_si_tenant_module_pins.sql create mode 100644 server/src/training-agent/tenants/si.ts create mode 100644 server/src/training-agent/v6-si-platform.ts diff --git a/.changeset/add-si-training-tenant.md b/.changeset/add-si-training-tenant.md new file mode 100644 index 0000000000..14bfc37559 --- /dev/null +++ b/.changeset/add-si-training-tenant.md @@ -0,0 +1,28 @@ +--- +--- + +feat(training-agent): add `/si` tenant serving SI lifecycle tools (#3940) + +Adds a new `si` training-agent tenant at `/si/mcp` that simulates the +brand-agent side of the Sponsored Intelligence session lifecycle. Learners +can now complete the S5 specialist capstone and C3 creative-SI exercises +without hitting `Unknown tool: si_initiate_session`. + +**What ships:** +- `v6-si-platform.ts` — minimal `DecisioningPlatform` (no specialism + methods; all four SI tools ride `customTools` pending SDK SI interface). +- `tenants/si.ts` — stub handlers for `si_get_offering`, + `si_initiate_session`, `si_send_message`, `si_terminate_session` + (Nova Brands training fixture, in-memory session state). +- `tenants/registry.ts` — registers the `si` tenant alongside the + existing six. +- `tenants/tool-catalog.ts` — adds `si_*` → `['si']` discovery hints. +- Migration 465 — backfills `tenant_ids` for A3, C3, S5; fixes the C3 + `c3_ex2` phantom tool (`connect_to_si_agent` → `si_initiate_session`); + appends stable recertification criterion IDs to S5's `s5_ex1`. + +**Not included (human task):** +- Storyboard floors in `.github/workflows/training-agent-storyboards.yml` + for the `si` tenant — add to smoke matrix after this merges. + +Refs #3940. diff --git a/server/src/db/migrations/465_si_tenant_module_pins.sql b/server/src/db/migrations/465_si_tenant_module_pins.sql new file mode 100644 index 0000000000..458c960755 --- /dev/null +++ b/server/src/db/migrations/465_si_tenant_module_pins.sql @@ -0,0 +1,150 @@ +-- Pin A3, C3, and S5 to the new `si` training-agent tenant, which was +-- explicitly left NULL in migration 464 because no `si_*`-serving tenant +-- existed at the time. This migration follows once the tenant ships. +-- +-- Additional curriculum fixes bundled here (education-expert review of #3940): +-- +-- 1. C3 c3_ex2 phantom tool: `connect_to_si_agent` is an Addie-specific host +-- tool (server/src/addie/mcp/si-host-tools.ts), not a protocol-defined +-- AdCP task. The training agent has never served it. Replace with +-- `si_initiate_session` — the correct entry point for the SI lifecycle. +-- +-- 2. S5 criterion IDs: The s5_ex1 success_criteria were defined as plain text +-- strings (migration 270 seed, overwritten verbatim by migrations 303/298). +-- The _append_criterion helper (migration 407) adds stable IDs the +-- recertification engine can target when SI experimental surfaces change. +-- Adds s5_ex1_sc_session_lifecycle and s5_ex1_sc_offering_integration — +-- the two criteria covering the graded competencies at highest assessment +-- weight in S5's protocol_mastery dimension. + +-- ── 1. Pin tenant_ids ─────────────────────────────────────────────────────── + +-- A3: add si to the landscape tour (the tour already lists the other six +-- tenants from migration 464; si is the missing stop). +UPDATE certification_modules + SET tenant_ids = ARRAY['sales', 'signals', 'governance', 'creative', 'brand', 'si'] + WHERE id = 'A3' AND tenant_ids IS NULL; + +-- C3: creative + sponsored intelligence — si is the primary SI surface; +-- creative and brand remain for sync_creatives / creative_approval exercises. +-- Drops creative-builder (per #3930 review: creative-builder is S2-specific). +UPDATE certification_modules + SET tenant_ids = ARRAY['creative', 'brand', 'si'] + WHERE id = 'C3' AND tenant_ids IS NULL; + +-- S5: specialist deep-dive — si is the sole primary tenant for the full +-- si_* session lifecycle capstone. +UPDATE certification_modules + SET tenant_ids = ARRAY['si'] + WHERE id = 'S5' AND tenant_ids IS NULL; + +-- ── 2. Fix C3 c3_ex2 phantom tool ────────────────────────────────────────── +-- +-- Replace `connect_to_si_agent` in sandbox_actions with `si_initiate_session`. +-- The exercise intent (connecting to a brand SI agent) is preserved; only the +-- tool name and guidance text change to match the actual protocol tool. +-- Safe to replay: the CASE condition matches only the phantom name. + +UPDATE certification_modules + SET exercise_definitions = ( + SELECT jsonb_agg( + CASE + WHEN ex->>'id' = 'c3_ex2' + THEN jsonb_set( + ex, + '{sandbox_actions}', + ( + SELECT jsonb_agg( + CASE + WHEN act->>'tool' = 'connect_to_si_agent' + THEN jsonb_build_object( + 'tool', 'si_initiate_session', + 'guidance', 'Initiate a session with the training SI brand agent. Provide an intent describing what the user is looking for. Examine the session_id, negotiated_capabilities, and the brand''s opening message — the host-side entry point to the SI Chat Protocol.' + ) + ELSE act + END + ) + FROM jsonb_array_elements(ex->'sandbox_actions') act + ) + ) + ELSE ex + END + ) + FROM jsonb_array_elements(exercise_definitions) ex + ) + WHERE id = 'C3' + AND exercise_definitions::text LIKE '%connect_to_si_agent%'; + +-- ── 3. S5 stable criterion IDs ────────────────────────────────────────────── +-- +-- Re-declare the _append_criterion helper (CREATE OR REPLACE — idempotent) +-- then stamp two semantic IDs onto s5_ex1. These IDs let the recertification +-- engine identify credential holders who need re-assessment when the SI +-- experimental surface changes. + +CREATE OR REPLACE FUNCTION _append_criterion( + p_module_id text, + p_exercise_id text, + p_criterion_id text, + p_text text +) RETURNS void AS $$ +DECLARE + defs jsonb; + updated jsonb := '[]'::jsonb; + ex jsonb; + criteria jsonb; + already_present boolean; + exercise_matched boolean := false; +BEGIN + SELECT exercise_definitions INTO defs + FROM certification_modules + WHERE id = p_module_id; + + IF defs IS NULL OR jsonb_typeof(defs) <> 'array' THEN + RAISE EXCEPTION 'Module % not found or has no exercise_definitions array', p_module_id; + END IF; + + FOR ex IN SELECT * FROM jsonb_array_elements(defs) + LOOP + IF ex->>'id' = p_exercise_id THEN + exercise_matched := true; + criteria := COALESCE(ex->'success_criteria', '[]'::jsonb); + + SELECT EXISTS ( + SELECT 1 FROM jsonb_array_elements(criteria) c + WHERE c->>'id' = p_criterion_id + ) INTO already_present; + + IF NOT already_present THEN + criteria := criteria || jsonb_build_array( + jsonb_build_object('id', p_criterion_id, 'text', p_text) + ); + ex := jsonb_set(ex, '{success_criteria}', criteria); + END IF; + END IF; + updated := updated || jsonb_build_array(ex); + END LOOP; + + IF NOT exercise_matched THEN + RAISE EXCEPTION 'Exercise % not found in module %', p_exercise_id, p_module_id; + END IF; + + UPDATE certification_modules + SET exercise_definitions = updated + WHERE id = p_module_id; +END; +$$ LANGUAGE plpgsql; + +SELECT _append_criterion( + 'S5', + 's5_ex1', + 's5_ex1_sc_session_lifecycle', + 'Demonstrates the full SI session lifecycle: calls si_get_offering, si_initiate_session, si_send_message (at least one turn), and si_terminate_session in correct protocol order.' +); + +SELECT _append_criterion( + 'S5', + 's5_ex1', + 's5_ex1_sc_offering_integration', + 'Uses si_get_offering before session initiation and passes the returned offering_token to si_initiate_session, demonstrating the session-continuity handoff.' +); diff --git a/server/src/training-agent/tenants/registry.ts b/server/src/training-agent/tenants/registry.ts index 88b9ac7967..7452061b13 100644 --- a/server/src/training-agent/tenants/registry.ts +++ b/server/src/training-agent/tenants/registry.ts @@ -41,6 +41,7 @@ import { buildGovernanceTenantConfig } from './governance.js'; import { buildCreativeTenantConfig } from './creative.js'; import { buildCreativeBuilderTenantConfig } from './creative-builder.js'; import { buildBrandTenantConfig } from './brand.js'; +import { buildSiTenantConfig } from './si.js'; import { createLogger } from '../../logger.js'; const logger = createLogger('training-agent-tenants'); @@ -212,6 +213,7 @@ export function createRegistryHolder(): RegistryHolder { const creative = buildCreativeTenantConfig(hostBase); const creativeBuilder = buildCreativeBuilderTenantConfig(hostBase); const brand = buildBrandTenantConfig(hostBase); + const si = buildSiTenantConfig(hostBase); // awaitFirstValidation:true blocks until the no-op validator // promotes the tenant to 'healthy'. Without it the first request // would race the background validation and see 'pending' (refused @@ -223,9 +225,10 @@ export function createRegistryHolder(): RegistryHolder { reg.register(creative.tenantId, creative.config, { awaitFirstValidation: true }), reg.register(creativeBuilder.tenantId, creativeBuilder.config, { awaitFirstValidation: true }), reg.register(brand.tenantId, brand.config, { awaitFirstValidation: true }), + reg.register(si.tenantId, si.config, { awaitFirstValidation: true }), ]); logger.info( - { hostBase, tenants: ['signals', 'sales', 'governance', 'creative', 'creative-builder', 'brand'] }, + { hostBase, tenants: ['signals', 'sales', 'governance', 'creative', 'creative-builder', 'brand', 'si'] }, 'Tenant registry initialized', ); registry = reg; diff --git a/server/src/training-agent/tenants/si.ts b/server/src/training-agent/tenants/si.ts new file mode 100644 index 0000000000..25bcc9b490 --- /dev/null +++ b/server/src/training-agent/tenants/si.ts @@ -0,0 +1,245 @@ +/** + * /si tenant — Sponsored Intelligence specialism. + * + * Simulates the BRAND-AGENT side of the SI lifecycle so a learner can + * practice as the HOST: get an offering, initiate a session, exchange + * messages, and terminate. The training brand is Nova Brands (character + * bible, summer collection fixture). + * + * All four SI tools ride customTools because the SDK's DecisioningPlatform + * interface has no `sponsoredIntelligence` field yet. They ARE in AdcpToolMap + * (area: 'si') — the merge-seam comment in brand.ts describes the same + * pattern for update_rights / creative_approval. + * + * Session state is in-memory per process. Acceptable for the shared sandbox: + * session IDs are training-scoped and not persisted across restarts. + */ + +import { z } from 'zod'; +import type { TenantConfig } from '@adcp/sdk/server'; +import { TrainingSiPlatform } from '../v6-si-platform.js'; +import { getTenantSigningMaterial } from './signing.js'; +import { customToolFor } from './custom-tool-helper.js'; +import type { ToolArgs, TrainingContext } from '../types.js'; + +const TENANT_ID = 'si'; + +const TRAINING_BRAND = { + name: 'Nova Brands', + domain: 'novabrands.example', + offering_id: 'nova-summer-2026', + product_title: 'Nova Summer Collection', +} as const; + +const activeSessions = new Map(); + +const CONTEXT_REF = z.any().optional(); + +const SI_GET_OFFERING_SCHEMA = { + offering_id: z.string(), + intent: z.string().optional(), + include_products: z.boolean().optional(), + product_limit: z.number().int().min(1).max(50).optional(), + context: CONTEXT_REF, + ext: z.any().optional(), +}; + +const SI_INITIATE_SESSION_SCHEMA = { + idempotency_key: z.string().min(16).max(255).regex(/^[A-Za-z0-9_.:-]{16,255}$/), + intent: z.string(), + identity: z.object({}).passthrough(), + media_buy_id: z.string().optional(), + placement: z.string().optional(), + offering_id: z.string().optional(), + supported_capabilities: z.object({}).passthrough().optional(), + offering_token: z.string().optional(), + context: CONTEXT_REF, + ext: z.any().optional(), +}; + +const SI_SEND_MESSAGE_SCHEMA = { + idempotency_key: z.string().min(16).max(255).regex(/^[A-Za-z0-9_.:-]{16,255}$/), + session_id: z.string(), + message: z.string().optional(), + action_response: z.object({}).passthrough().optional(), + context: CONTEXT_REF, + ext: z.any().optional(), +}; + +const SI_TERMINATE_SESSION_SCHEMA = { + session_id: z.string(), + reason: z.enum([ + 'handoff_transaction', + 'handoff_complete', + 'user_exit', + 'session_timeout', + 'host_terminated', + ]), + termination_context: z.object({}).passthrough().optional(), + context: CONTEXT_REF, + ext: z.any().optional(), +}; + +function makeSessionId(): string { + return `si_sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +export function buildSiTenantConfig(host: string): { + tenantId: string; + config: TenantConfig; +} { + const material = getTenantSigningMaterial(TENANT_ID); + return { + tenantId: TENANT_ID, + config: { + agentUrl: `${host}/${TENANT_ID}`, + signingKey: material.signingKey, + label: 'Training agent — sponsored intelligence', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + platform: new TrainingSiPlatform() as any, + serverOptions: { + customTools: { + si_get_offering: customToolFor( + 'si_get_offering', + 'Get offering details and availability before initiating a session.', + SI_GET_OFFERING_SCHEMA, + async (args: ToolArgs, _ctx: TrainingContext) => { + const p = args as Record; + const includeProducts = Boolean(p['include_products'] ?? false); + const offeringId = String(p['offering_id'] ?? TRAINING_BRAND.offering_id); + return { + offering_id: offeringId, + available: true, + brand_name: TRAINING_BRAND.name, + brand_domain: TRAINING_BRAND.domain, + title: TRAINING_BRAND.product_title, + description: + 'Discover the latest Nova summer styles — conversational shopping powered by Sponsored Intelligence.', + offering_token: `otk_${Date.now()}_${offeringId.replace(/[^a-z0-9]/gi, '_')}`, + ...(includeProducts && { + products: [ + { + product_id: 'nova-sun-dress-001', + title: 'Nova Sun Dress', + price: '79.00', + currency: 'USD', + }, + { + product_id: 'nova-linen-blazer-002', + title: 'Nova Linen Blazer', + price: '129.00', + currency: 'USD', + }, + ], + }), + }; + }, + ), + + si_initiate_session: customToolFor( + 'si_initiate_session', + 'Start a conversational session with a brand agent.', + SI_INITIATE_SESSION_SCHEMA, + async (args: ToolArgs, _ctx: TrainingContext) => { + const p = args as Record; + const sessionId = makeSessionId(); + activeSessions.set(sessionId, { turnCount: 0 }); + const intent = String(p['intent'] ?? 'browse').slice(0, 200); + return { + session_id: sessionId, + session_status: 'active', + session_ttl_seconds: 300, + negotiated_capabilities: { + modalities: { conversational: true }, + components: { + standard: ['text', 'link', 'product_card', 'action_button'], + }, + }, + response: { + message: `Hi! I'm ${TRAINING_BRAND.name}'s AI. You mentioned: "${intent}". I'd love to help you find the perfect piece from our summer collection — what style are you looking for?`, + ui_elements: [ + { + type: 'product_card', + product_id: 'nova-sun-dress-001', + title: 'Nova Sun Dress', + price: '79.00', + currency: 'USD', + }, + ], + }, + }; + }, + ), + + si_send_message: customToolFor( + 'si_send_message', + 'Send a message to an active brand agent session.', + SI_SEND_MESSAGE_SCHEMA, + async (args: ToolArgs, _ctx: TrainingContext) => { + const p = args as Record; + const sessionId = String(p['session_id'] ?? ''); + const session = activeSessions.get(sessionId); + if (!session) { + return { + errors: [ + { + code: 'SESSION_NOT_FOUND', + message: + 'SI session not found or expired. Initiate a new session via si_initiate_session.', + recovery: 'correctable', + }, + ], + }; + } + session.turnCount++; + const msg = String(p['message'] ?? '').slice(0, 200); + return { + session_id: sessionId, + session_status: 'active', + response: { + message: + session.turnCount === 1 + ? `Great question! Our ${TRAINING_BRAND.product_title} has something for every style. Here are some popular picks — would you like to see more or go straight to checkout?` + : `Thanks for sharing more context: "${msg || '(no message)'}". Based on that, I'd recommend our Nova Linen Blazer — it's our most versatile summer piece. Want details or would you like to proceed to checkout?`, + ui_elements: [ + { + type: 'action_button', + label: 'View full collection', + action: 'view_collection', + }, + { + type: 'action_button', + label: 'Proceed to checkout', + action: 'checkout', + }, + ], + }, + }; + }, + ), + + si_terminate_session: customToolFor( + 'si_terminate_session', + 'End an active brand agent session.', + SI_TERMINATE_SESSION_SCHEMA, + async (args: ToolArgs, _ctx: TrainingContext) => { + const p = args as Record; + const sessionId = String(p['session_id'] ?? ''); + const session = activeSessions.get(sessionId); + const turns = session?.turnCount ?? 0; + activeSessions.delete(sessionId); + return { + session_id: sessionId, + session_status: 'terminated', + termination_summary: { + reason: String(p['reason'] ?? 'user_exit'), + turns_completed: turns, + }, + }; + }, + ), + }, + }, + }, + }; +} diff --git a/server/src/training-agent/tenants/tool-catalog.ts b/server/src/training-agent/tenants/tool-catalog.ts index 9ae3533b14..59b3962fb2 100644 --- a/server/src/training-agent/tenants/tool-catalog.ts +++ b/server/src/training-agent/tenants/tool-catalog.ts @@ -76,6 +76,12 @@ export const TOOL_CATALOG: Readonly> = { acquire_rights: ['brand'], update_rights: ['brand'], creative_approval: ['brand'], + + // sponsored intelligence + si_get_offering: ['si'], + si_initiate_session: ['si'], + si_send_message: ['si'], + si_terminate_session: ['si'], }; /** Build the tool list a given tenant serves — inverse view of TOOL_CATALOG. */ diff --git a/server/src/training-agent/v6-si-platform.ts b/server/src/training-agent/v6-si-platform.ts new file mode 100644 index 0000000000..0d73f334ed --- /dev/null +++ b/server/src/training-agent/v6-si-platform.ts @@ -0,0 +1,76 @@ +/** + * v6 platform for the `/si` tenant — Sponsored Intelligence. + * + * Minimal platform: no specialism methods. All four SI lifecycle tools + * (si_get_offering, si_initiate_session, si_send_message, si_terminate_session) + * ride the customTools merge seam in tenants/si.ts — they're in AdcpToolMap + * (area: 'si') but the SDK's DecisioningPlatform interface has no SI field + * yet. + * + * This tenant simulates the BRAND-AGENT side of the SI lifecycle. A learner + * practices as the HOST — they initiate sessions, exchange messages, retrieve + * offerings, and terminate sessions against a deterministic training brand + * (Nova Brands). The host-outbound `connect_to_si_agent` tool that Addie uses + * to call real brand SI agents lives in addie/mcp/si-host-tools.ts and is a + * different surface entirely. + */ + +import { + type DecisioningPlatform, + type AccountStore, +} from '@adcp/sdk/server'; + +export interface TrainingSiConfig { + strict: boolean; +} + +export interface TrainingSiMeta { + [key: string]: unknown; +} + +const trainingSiAccounts: AccountStore = { + resolution: 'explicit', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolve: async (ref: any, _ctx: any) => { + if (ref == null) { + return { + id: 'public_sandbox', + name: 'Public Sandbox', + status: 'active', + ctx_metadata: {}, + authInfo: { kind: 'public' }, + }; + } + const brandDomain = + 'brand' in ref && ref.brand && typeof ref.brand === 'object' && 'domain' in ref.brand + ? (ref.brand.domain as string | undefined) + : undefined; + const accountId = + 'account_id' in ref && typeof ref.account_id === 'string' ? ref.account_id : undefined; + const id = accountId ?? `synthetic_${brandDomain ?? 'anon'}`; + return { + id, + name: brandDomain ?? id, + status: 'active', + ...(brandDomain != null && { brand: { domain: brandDomain } }), + ctx_metadata: {}, + authInfo: { kind: 'api_key' }, + }; + }, +}; + +export class TrainingSiPlatform + implements DecisioningPlatform +{ + capabilities = { + specialisms: [] as const, + creative_agents: [] as const, + channels: [] as const, + pricingModels: [] as const, + supportedBillings: ['agent', 'operator'] as const, + config: { strict: false }, + }; + + statusMappers = {}; + accounts: AccountStore = trainingSiAccounts; +} diff --git a/server/tests/integration/training-agent-tool-catalog-drift.test.ts b/server/tests/integration/training-agent-tool-catalog-drift.test.ts index 4f076b4581..0e3437d1e0 100644 --- a/server/tests/integration/training-agent-tool-catalog-drift.test.ts +++ b/server/tests/integration/training-agent-tool-catalog-drift.test.ts @@ -36,7 +36,7 @@ const { createTrainingAgentRouter } = await import('../../src/training-agent/ind const { stopSessionCleanup } = await import('../../src/training-agent/state.js'); const { toolsForTenant, TOOL_CATALOG } = await import('../../src/training-agent/tenants/tool-catalog.js'); -const TENANT_IDS = ['signals', 'sales', 'governance', 'creative', 'creative-builder', 'brand'] as const; +const TENANT_IDS = ['signals', 'sales', 'governance', 'creative', 'creative-builder', 'brand', 'si'] as const; const AUTH = 'Bearer tool-catalog-drift-token'; interface ToolListResponse { From c67a843210885c75e738940af15cafbed119faba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 06:05:55 +0000 Subject: [PATCH 2/3] fix(training-agent): address education-expert pre-PR blockers in migration 465 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add si_get_offering and si_terminate_session to S5 s5_ex1 sandbox_actions before the _append_criterion calls — ASTM E3416-24 requires all criterion referenced behaviors to be prompted actions in the assessment. - Clarify S5 tenant_ids comment: explains why brand was excluded. - Improve C3 c3_ex2 guidance text to connect response inspection to the pedagogical point (SI personalization vs impression-based formats). https://claude.ai/code/session_01SAU73ibxYgk3pdvmMJNjcF --- .../migrations/465_si_tenant_module_pins.sql | 64 ++++++++++++++++--- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/server/src/db/migrations/465_si_tenant_module_pins.sql b/server/src/db/migrations/465_si_tenant_module_pins.sql index 458c960755..1d07c56520 100644 --- a/server/src/db/migrations/465_si_tenant_module_pins.sql +++ b/server/src/db/migrations/465_si_tenant_module_pins.sql @@ -9,7 +9,15 @@ -- AdCP task. The training agent has never served it. Replace with -- `si_initiate_session` — the correct entry point for the SI lifecycle. -- --- 2. S5 criterion IDs: The s5_ex1 success_criteria were defined as plain text +-- 2. S5 s5_ex1 sandbox_actions gap: migration 303 rewrote S5's sandbox_actions +-- to include si_initiate_session and si_send_message but omitted +-- si_get_offering and si_terminate_session. The two new criterion IDs +-- (s5_ex1_sc_session_lifecycle, s5_ex1_sc_offering_integration) require +-- both tools to be prompted actions — adding criterion IDs for behaviors +-- the exercise never asks the learner to perform fails the ASTM E3416-24 +-- required-demonstration test. Append both tools to sandbox_actions first. +-- +-- 3. S5 criterion IDs: The s5_ex1 success_criteria were defined as plain text -- strings (migration 270 seed, overwritten verbatim by migrations 303/298). -- The _append_criterion helper (migration 407) adds stable IDs the -- recertification engine can target when SI experimental surfaces change. @@ -32,8 +40,12 @@ UPDATE certification_modules SET tenant_ids = ARRAY['creative', 'brand', 'si'] WHERE id = 'C3' AND tenant_ids IS NULL; --- S5: specialist deep-dive — si is the sole primary tenant for the full --- si_* session lifecycle capstone. +-- S5: specialist deep-dive — si is the sole primary tenant. brand was +-- considered (creative_approval for commerce handoff) but excluded: the +-- si_terminate_session reason enum already covers handoff_transaction and +-- handoff_complete as first-class termination reasons, and creative_approval +-- does not appear in S5's sandbox_actions. The ACP checkout handoff is +-- post-session and out of scope for the SI lifecycle exercise. UPDATE certification_modules SET tenant_ids = ARRAY['si'] WHERE id = 'S5' AND tenant_ids IS NULL; @@ -43,7 +55,7 @@ UPDATE certification_modules -- Replace `connect_to_si_agent` in sandbox_actions with `si_initiate_session`. -- The exercise intent (connecting to a brand SI agent) is preserved; only the -- tool name and guidance text change to match the actual protocol tool. --- Safe to replay: the CASE condition matches only the phantom name. +-- Safe to replay: the WHERE guard matches only when the phantom name is present. UPDATE certification_modules SET exercise_definitions = ( @@ -59,7 +71,7 @@ UPDATE certification_modules WHEN act->>'tool' = 'connect_to_si_agent' THEN jsonb_build_object( 'tool', 'si_initiate_session', - 'guidance', 'Initiate a session with the training SI brand agent. Provide an intent describing what the user is looking for. Examine the session_id, negotiated_capabilities, and the brand''s opening message — the host-side entry point to the SI Chat Protocol.' + 'guidance', 'Initiate a session with the training SI brand agent. Provide an intent describing what the user is looking for. Examine the session_id, negotiated_capabilities, and the brand''s opening message — note how the brand already incorporates your intent into its response. That bidirectional personalization is what distinguishes SI from impression-based formats.' ) ELSE act END @@ -75,7 +87,43 @@ UPDATE certification_modules WHERE id = 'C3' AND exercise_definitions::text LIKE '%connect_to_si_agent%'; --- ── 3. S5 stable criterion IDs ────────────────────────────────────────────── +-- ── 3. S5 s5_ex1 sandbox_actions: add missing si_get_offering and si_terminate_session +-- +-- Migration 303 wrote si_initiate_session and si_send_message but omitted the +-- bookend tools. Both must be present as prompted actions before the criterion +-- IDs in section 4 can be treated as demonstrated competencies under +-- ASTM E3416-24. Safe to replay: WHERE guard requires the current actions list +-- to lack si_get_offering (idempotent). + +UPDATE certification_modules + SET exercise_definitions = ( + SELECT jsonb_agg( + CASE + WHEN ex->>'id' = 's5_ex1' + THEN jsonb_set( + ex, + '{sandbox_actions}', + ex->'sandbox_actions' + || jsonb_build_array( + jsonb_build_object( + 'tool', 'si_get_offering', + 'guidance', 'Call si_get_offering with the training brand''s offering_id. Examine the returned offering_token — you will pass it to si_initiate_session to demonstrate session-continuity handoff.' + ), + jsonb_build_object( + 'tool', 'si_terminate_session', + 'guidance', 'Terminate the session with an appropriate reason code (user_exit, handoff_transaction, or handoff_complete). Verify turns_completed in the response.' + ) + ) + ) + ELSE ex + END + ) + FROM jsonb_array_elements(exercise_definitions) ex + ) + WHERE id = 'S5' + AND exercise_definitions::text NOT LIKE '%si_get_offering%'; + +-- ── 4. S5 stable criterion IDs ────────────────────────────────────────────── -- -- Re-declare the _append_criterion helper (CREATE OR REPLACE — idempotent) -- then stamp two semantic IDs onto s5_ex1. These IDs let the recertification @@ -130,8 +178,8 @@ BEGIN END IF; UPDATE certification_modules - SET exercise_definitions = updated - WHERE id = p_module_id; + SET exercise_definitions = updated + WHERE id = p_module_id; END; $$ LANGUAGE plpgsql; From 424607e894a4cd398883124b49a4f3a28abd0ce3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 06:13:32 +0000 Subject: [PATCH 3/3] fix(training-agent): address code-reviewer pre-PR blockers in si tenant - tenant-smoke.test.ts: add 'si' to expected agent IDs list - si_terminate_session: add required `terminated: true` field per schema - si_get_offering: restructure response to schema shape (offering object, matching_products array with name/product_id, offering_token at top level) - si_send_message: return SESSION_TERMINATED (not SESSION_NOT_FOUND) after terminate by marking sessions terminated rather than deleting from map - registry.ts: update module comment from "Six" to "Seven" tenants - makeSessionId: use crypto.randomUUID() instead of Date.now()+Math.random() https://claude.ai/code/session_01SAU73ibxYgk3pdvmMJNjcF --- server/src/training-agent/tenants/registry.ts | 4 +- server/src/training-agent/tenants/si.ts | 46 +++++++++++++------ .../tenants/tenant-smoke.test.ts | 2 +- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/server/src/training-agent/tenants/registry.ts b/server/src/training-agent/tenants/registry.ts index 7452061b13..097dde1c92 100644 --- a/server/src/training-agent/tenants/registry.ts +++ b/server/src/training-agent/tenants/registry.ts @@ -1,8 +1,8 @@ /** * Multi-tenant TenantRegistry setup. * - * Six per-specialism tenants — `/sales`, `/signals`, `/governance`, - * `/creative`, `/creative-builder`, `/brand` — each with its own + * Seven per-specialism tenants — `/sales`, `/signals`, `/governance`, + * `/creative`, `/creative-builder`, `/brand`, `/si` — each with its own * `DecisioningPlatform` impl, ephemeral signing key, and specialism * declarations. Path-routed: tenants register with `agentUrl` like * `${CANONICAL_BASE}/`, the router binds tenantId at route diff --git a/server/src/training-agent/tenants/si.ts b/server/src/training-agent/tenants/si.ts index 25bcc9b490..631064b73b 100644 --- a/server/src/training-agent/tenants/si.ts +++ b/server/src/training-agent/tenants/si.ts @@ -31,7 +31,7 @@ const TRAINING_BRAND = { product_title: 'Nova Summer Collection', } as const; -const activeSessions = new Map(); +const activeSessions = new Map(); const CONTEXT_REF = z.any().optional(); @@ -81,7 +81,7 @@ const SI_TERMINATE_SESSION_SCHEMA = { }; function makeSessionId(): string { - return `si_sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + return `si_sess_${crypto.randomUUID()}`; } export function buildSiTenantConfig(host: string): { @@ -106,27 +106,28 @@ export function buildSiTenantConfig(host: string): { async (args: ToolArgs, _ctx: TrainingContext) => { const p = args as Record; const includeProducts = Boolean(p['include_products'] ?? false); - const offeringId = String(p['offering_id'] ?? TRAINING_BRAND.offering_id); + const offeringId = String(p['offering_id'] ?? TRAINING_BRAND.offering_id).slice(0, 128); + const tokenSuffix = offeringId.replace(/[^a-z0-9]/gi, '_'); return { - offering_id: offeringId, available: true, - brand_name: TRAINING_BRAND.name, - brand_domain: TRAINING_BRAND.domain, - title: TRAINING_BRAND.product_title, - description: - 'Discover the latest Nova summer styles — conversational shopping powered by Sponsored Intelligence.', - offering_token: `otk_${Date.now()}_${offeringId.replace(/[^a-z0-9]/gi, '_')}`, + offering_token: `otk_${crypto.randomUUID()}_${tokenSuffix}`, + offering: { + offering_id: offeringId, + title: TRAINING_BRAND.product_title, + summary: + 'Discover the latest Nova summer styles — conversational shopping powered by Sponsored Intelligence.', + }, ...(includeProducts && { - products: [ + matching_products: [ { product_id: 'nova-sun-dress-001', - title: 'Nova Sun Dress', + name: 'Nova Sun Dress', price: '79.00', currency: 'USD', }, { product_id: 'nova-linen-blazer-002', - title: 'Nova Linen Blazer', + name: 'Nova Linen Blazer', price: '129.00', currency: 'USD', }, @@ -143,7 +144,7 @@ export function buildSiTenantConfig(host: string): { async (args: ToolArgs, _ctx: TrainingContext) => { const p = args as Record; const sessionId = makeSessionId(); - activeSessions.set(sessionId, { turnCount: 0 }); + activeSessions.set(sessionId, { turnCount: 0, terminated: false }); const intent = String(p['intent'] ?? 'browse').slice(0, 200); return { session_id: sessionId, @@ -191,6 +192,18 @@ export function buildSiTenantConfig(host: string): { ], }; } + if (session.terminated) { + return { + errors: [ + { + code: 'SESSION_TERMINATED', + message: + 'SI session has already been terminated. Initiate a new session via si_initiate_session.', + recovery: 'correctable', + }, + ], + }; + } session.turnCount++; const msg = String(p['message'] ?? '').slice(0, 200); return { @@ -227,9 +240,12 @@ export function buildSiTenantConfig(host: string): { const sessionId = String(p['session_id'] ?? ''); const session = activeSessions.get(sessionId); const turns = session?.turnCount ?? 0; - activeSessions.delete(sessionId); + if (session) { + session.terminated = true; + } return { session_id: sessionId, + terminated: true, session_status: 'terminated', termination_summary: { reason: String(p['reason'] ?? 'user_exit'), diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index 4399b951ef..9691eba532 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -78,7 +78,7 @@ describe('tenant routing smoke', () => { expect(body.version).toBe('1.0'); expect(Array.isArray(body.agents)).toBe(true); const ids = body.agents.map(a => a.agent_id).sort(); - expect(ids).toEqual(['brand', 'creative', 'creative-builder', 'governance', 'sales', 'signals']); + expect(ids).toEqual(['brand', 'creative', 'creative-builder', 'governance', 'sales', 'si', 'signals']); const sales = body.agents.find(a => a.agent_id === 'sales'); expect(sales?.transport).toBe('mcp'); expect(sales?.url).toMatch(/\/sales\/mcp$/);