diff --git a/apps/website/content/docs/ag-ui/api/api-docs.json b/apps/website/content/docs/ag-ui/api/api-docs.json index a4d772c6f..7388984fe 100644 --- a/apps/website/content/docs/ag-ui/api/api-docs.json +++ b/apps/website/content/docs/ag-ui/api/api-docs.json @@ -463,7 +463,7 @@ }, { "name": "state", - "type": "Signal>", + "type": "Signal", "description": "", "optional": false }, @@ -562,11 +562,11 @@ { "name": "injectAgent", "kind": "function", - "description": "Injects the AG-UI agent from Angular's dependency injection container.\nUse this in components or services provided via `provideAgent()` (or\n`provideFakeAgent()`).\n\nReturns an `AgUiAgent` — the runtime-neutral `Agent` contract plus the\nAG-UI-specific `customEvents` signal — so `customEvents` is reachable\ndirectly, without casting.", - "signature": "injectAgent(): AgUiAgent", + "description": "Injects the AG-UI agent from Angular's dependency injection container.\nUse this in components or services provided via `provideAgent()` (or\n`provideFakeAgent()`).\n\nReturns an `AgUiAgent` — the runtime-neutral `Agent` contract plus the\nAG-UI-specific `customEvents` signal — so `customEvents` is reachable\ndirectly, without casting.\n\n**Typed state via AgentRef.** Pass the same ref that was supplied to\n`provideAgent(ref, …)` to carry the state type through DI without repeating\nthe generic at every call site:\n\n```ts\nconst agent = injectAgent(TRIP); // AgUiAgent\n```\n\nThe no-arg form defaults to `AgUiAgent>`.", + "signature": "injectAgent(): AgUiAgent<>", "params": [], "returns": { - "type": "AgUiAgent", + "type": "AgUiAgent<>", "description": "" }, "examples": [] @@ -574,9 +574,15 @@ { "name": "provideAgent", "kind": "function", - "description": "Provides an Agent instance wired through HttpAgent and toAgent.\nConstructs an HttpAgent from config and wraps it in the runtime-neutral\nAgent contract via toAgent(). Returns a provider array suitable for\nbootstrapApplication or TestBed.configureTestingModule().\n\n**Static vs factory config.** Pass a plain `AgentConfig` object when the\nconfig is known up front. Pass a `() => AgentConfig` factory when the config\ndepends on runtime/DI state — the factory runs inside an Angular injection\ncontext, so it may call `inject()` to read services or route params.", - "signature": "provideAgent(configOrFactory: AgentConfig | () => AgentConfig): Provider[]", + "description": "Provides an Agent instance wired through HttpAgent and toAgent.\nConstructs an HttpAgent from config and wraps it in the runtime-neutral\nAgent contract via toAgent(). Returns a provider array suitable for\nbootstrapApplication or TestBed.configureTestingModule().\n\n**Static vs factory config.** Pass a plain `AgentConfig` object when the\nconfig is known up front. Pass a `() => AgentConfig` factory when the config\ndepends on runtime/DI state — the factory runs inside an Angular injection\ncontext, so it may call `inject()` to read services or route params.\n\n**Typed state via AgentRef.** Pass a typed ref as the first argument to flow\nthe state shape from `provideAgent` to `injectAgent` without repeating the\ngeneric at every call site:\n\n```ts\ninterface TripState { day: number; places: string[]; }\nexport const TRIP = createAgentRef('trip');\n// app.config.ts:\nproviders: [provideAgent(TRIP, { url: 'http://localhost:8000/agent' })]\n// component:\nconst agent = injectAgent(TRIP); // AgUiAgent\n```", + "signature": "provideAgent(ref: AgentRef, configOrFactory: AgentConfig | () => AgentConfig): Provider[]", "params": [ + { + "name": "ref", + "type": "AgentRef", + "description": "", + "optional": false + }, { "name": "configOrFactory", "type": "AgentConfig | () => AgentConfig", @@ -613,7 +619,7 @@ "name": "toAgent", "kind": "function", "description": "Wraps an AG-UI AbstractAgent into the runtime-neutral Agent contract.\n\nThe adapter subscribes to source.subscribe({ onEvent }) and reduces every\nevent into the produced Agent's signals. submit() optimistically appends the\nuser message to both our signals and the source agent's internal message\nlist, then calls source.runAgent(). stop() calls source.abortRun().\n\nSubscription cleanup: the returned Agent does NOT manage its own lifetime.\nCallers using DI should rely on the provider's destroy hook; direct callers\nof toAgent() should treat the returned object's lifecycle as tied to the\nagent instance they constructed. The subscriber registered via\nsource.subscribe() will fire for the lifetime of source.", - "signature": "toAgent(source: AbstractAgent<>, options: ToAgentOptions): AgUiAgent", + "signature": "toAgent(source: AbstractAgent<>, options: ToAgentOptions): AgUiAgent<>", "params": [ { "name": "source", @@ -629,7 +635,7 @@ } ], "returns": { - "type": "AgUiAgent", + "type": "AgUiAgent<>", "description": "" }, "examples": [] diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index e6736866d..c78dbe8ac 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1502,7 +1502,7 @@ }, { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -1653,7 +1653,7 @@ }, { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -2176,7 +2176,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false } @@ -2376,7 +2376,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -2503,7 +2503,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -2551,7 +2551,7 @@ }, { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -2725,7 +2725,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -2852,7 +2852,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -3353,7 +3353,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -3448,7 +3448,7 @@ }, { "name": "agent", - "type": "InputSignal", + "type": "InputSignal> | AgentWithHistory> | null>", "description": "", "optional": false }, @@ -3728,7 +3728,7 @@ }, { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -4280,7 +4280,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -4328,7 +4328,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -4461,7 +4461,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -4593,7 +4593,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -4692,7 +4692,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -5451,7 +5451,7 @@ }, { "name": "state", - "type": "Signal>", + "type": "Signal", "description": "", "optional": false }, @@ -5566,6 +5566,20 @@ ], "examples": [] }, + { + "name": "AgentRef", + "kind": "interface", + "description": "A typed handle that threads a state shape through Angular DI from\n `provideAgent(ref, …)` to `injectAgent(ref)` without per-call-site\n restatement of the generic.", + "properties": [ + { + "name": "token", + "type": "InjectionToken>", + "description": "", + "optional": false + } + ], + "examples": [] + }, { "name": "AgentRuntimeTelemetryPayload", "kind": "interface", @@ -5763,7 +5777,7 @@ }, { "name": "state", - "type": "Signal>", + "type": "Signal", "description": "", "optional": false }, @@ -5800,6 +5814,38 @@ ], "examples": [] }, + { + "name": "AnyFunctionToolDef", + "kind": "interface", + "description": "Bivariant union member used only for registry storage/iteration. The handler\n param is `any` (NOT `never`): `any` is simultaneously a supertype any precise\n `FunctionToolDef` is assignable to under `strictFunctionTypes`, AND\n callable by internal code that has narrowed by `kind` and parsed runtime args.\n A `never` param would satisfy the former but break the latter.", + "properties": [ + { + "name": "description", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "handler", + "type": "(args: any) => unknown", + "description": "", + "optional": false + }, + { + "name": "kind", + "type": "\"function\"", + "description": "", + "optional": false + }, + { + "name": "schema", + "type": "StandardSchemaV1<>", + "description": "", + "optional": false + } + ], + "examples": [] + }, { "name": "AskToolDef", "kind": "interface", @@ -5807,7 +5853,7 @@ "properties": [ { "name": "component", - "type": "Type", + "type": "Type", "description": "", "optional": false }, @@ -5825,7 +5871,7 @@ }, { "name": "schema", - "type": "StandardSchemaV1<>", + "type": "S", "description": "", "optional": false } @@ -6087,12 +6133,12 @@ "methods": [ { "name": "connect", - "signature": "connect(agent: Agent): void", + "signature": "connect(agent: Agent<>): void", "description": "Wire the coordinator to an agent: ship the catalog, run function tools, auto-ack view tools.\n MUST be called inside an injection context (sets up effects). Safe no-op if the agent lacks\n the clientTools capability.", "params": [ { "name": "agent", - "type": "Agent", + "type": "Agent<>", "description": "", "optional": false } @@ -6100,12 +6146,12 @@ }, { "name": "handleRenderEvent", - "signature": "handleRenderEvent(agent: Agent, event: RenderEvent): void", + "signature": "handleRenderEvent(agent: Agent<>, event: RenderEvent): void", "description": "Handle a render event bubbled up from a mounted view/ask component (resolves `ask` results).", "params": [ { "name": "agent", - "type": "Agent", + "type": "Agent<>", "description": "", "optional": false }, @@ -6258,7 +6304,7 @@ { "name": "FunctionToolDef", "kind": "interface", - "description": "", + "description": "Precise authored function tool — what `action()` returns. Carries the schema\n `S` and the handler's resolved return type `R`.", "properties": [ { "name": "description", @@ -6268,7 +6314,7 @@ }, { "name": "handler", - "type": "(args: StandardSchemaInferOutput) => unknown", + "type": "(args: StandardSchemaInferOutput) => R | Promise", "description": "", "optional": false }, @@ -6727,6 +6773,20 @@ ], "examples": [] }, + { + "name": "StandardSchemaV1", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "~standard", + "type": "StandardSchemaProps", + "description": "", + "optional": false + } + ], + "examples": [] + }, { "name": "Subagent", "kind": "interface", @@ -7055,7 +7115,7 @@ "properties": [ { "name": "component", - "type": "Type", + "type": "Type", "description": "", "optional": false }, @@ -7073,7 +7133,7 @@ }, { "name": "schema", - "type": "StandardSchemaV1<>", + "type": "S", "description": "", "optional": false } @@ -7168,7 +7228,7 @@ "name": "ClientToolDef", "kind": "type", "description": "A client tool the model can call; executed in the browser.", - "signature": "FunctionToolDef | ViewToolDef | AskToolDef", + "signature": "AnyFunctionToolDef | ViewToolDef | AskToolDef", "examples": [] }, { @@ -7227,6 +7287,20 @@ "signature": "\"user\" | \"assistant\" | \"system\" | \"tool\"", "examples": [] }, + { + "name": "StandardSchemaInferInput", + "kind": "type", + "description": "", + "signature": "NonNullable[\"input\"]", + "examples": [] + }, + { + "name": "StandardSchemaInferOutput", + "kind": "type", + "description": "", + "signature": "NonNullable[\"output\"]", + "examples": [] + }, { "name": "SubagentStatus", "kind": "type", @@ -7241,6 +7315,13 @@ "signature": "unknown", "examples": [] }, + { + "name": "ToolArgs", + "kind": "type", + "description": "Inferred argument type for a schema (alias of StandardSchemaInferOutput).", + "signature": "StandardSchemaInferOutput", + "examples": [] + }, { "name": "ToolCallStatus", "kind": "type", @@ -7255,6 +7336,13 @@ "signature": "\"pending\" | \"running\" | \"done\" | \"error\"", "examples": [] }, + { + "name": "ViewProps", + "kind": "type", + "description": "Reverse helper: derive a component's input prop types FROM a schema, so a\n component authored straight from the schema is guaranteed compatible.", + "signature": "Prettify>", + "examples": [] + }, { "name": "ViewRegistry", "kind": "type", @@ -7375,64 +7463,68 @@ { "name": "action", "kind": "function", - "description": "Async function tool — its resolved return value becomes the tool result.", - "signature": "action(description: string, schema: S, handler: (args: StandardSchemaInferOutput) => unknown): FunctionToolDef", + "description": "Declare an async function tool the model can call; its resolved return value\nbecomes the tool result shipped back to the model.", + "signature": "action(description: string, schema: S, handler: (args: StandardSchemaInferOutput) => R | Promise): FunctionToolDef", "params": [ { "name": "description", "type": "string", - "description": "", + "description": "Natural-language description the model sees.", "optional": false }, { "name": "schema", "type": "S", - "description": "", + "description": "Standard Schema (e.g. a Zod object) for the arguments; the\n handler's argument type is inferred from it.", "optional": false }, { "name": "handler", - "type": "(args: StandardSchemaInferOutput) => unknown", - "description": "", + "type": "(args: StandardSchemaInferOutput) => R | Promise", + "description": "Runs in the browser when the model calls the tool; its return\n type `R` is carried on the resulting FunctionToolDef.", "optional": false } ], "returns": { - "type": "FunctionToolDef", + "type": "FunctionToolDef", "description": "" }, - "examples": [] + "examples": [ + "```ts\nconst move = action('Move a stop', z.object({ fromDay: z.number() }), (a) => a.fromDay);\nconst registry = tools({ move_stop: move });\n```" + ] }, { "name": "ask", "kind": "function", - "description": "Interactive (HITL) component tool — the value it emits becomes the result.", - "signature": "ask(description: string, schema: StandardSchemaV1<>, component: Type): ClientToolDef", + "description": "Interactive (human-in-the-loop) component tool — the model fills the\ncomponent's props from the schema's output; the value the component emits\nback to the framework becomes the tool result sent to the model.\n\nThe component's signal inputs are checked against the schema output type\n(strict-but-flexible: every schema key must be a declared input with an\nassignable type; the component may declare extra inputs the schema doesn't\nfill). Author the component with `ViewProps` to derive input\nprop types directly from the schema.", + "signature": "ask(description: string, schema: S, component: AcceptComponent): AskToolDef", "params": [ { "name": "description", "type": "string", - "description": "", + "description": "Natural-language description the model sees.", "optional": false }, { "name": "schema", - "type": "StandardSchemaV1<>", - "description": "", + "type": "S", + "description": "Standard Schema defining the props the model must supply.", "optional": false }, { "name": "component", - "type": "Type", - "description": "", + "type": "AcceptComponent", + "description": "Angular component whose signal inputs must be compatible with\n the schema output. A type-level error is reported here when they diverge.", "optional": false } ], "returns": { - "type": "ClientToolDef", + "type": "AskToolDef", "description": "" }, - "examples": [] + "examples": [ + "```ts\nconst schema = z.object({ question: z.string(), options: z.array(z.string()) });\ntype Inputs = ViewProps;\n\n\\@Component({ ... })\nclass ChoiceCardComponent {\n question = input.required();\n options = input.required();\n // Emits the chosen option back to the model.\n}\n\nconst choice = ask('Ask the user to choose', schema, ChoiceCardComponent);\nconst registry = tools({ pick_option: choice });\n```" + ] }, { "name": "buildA2uiActionMessage", @@ -7471,6 +7563,27 @@ }, "examples": [] }, + { + "name": "createAgentRef", + "kind": "function", + "description": "Create a typed agent handle.", + "signature": "createAgentRef(debugName: string): AgentRef", + "params": [ + { + "name": "debugName", + "type": "string", + "description": "Optional name shown in Angular DI error messages.", + "optional": true + } + ], + "returns": { + "type": "AgentRef", + "description": "" + }, + "examples": [ + "```ts\ninterface TripState { day: number; places: string[]; }\nexport const TRIP = createAgentRef('trip');\n// app.config.ts: provideAgent(TRIP, { assistantId: 'trip' })\n// component: const agent = injectAgent(TRIP); // LangGraphAgent\n```" + ] + }, { "name": "createClientToolsCoordinator", "kind": "function", @@ -7606,11 +7719,11 @@ "name": "executeFunctionTool", "kind": "function", "description": "Validate args, run the handler, and normalize the outcome to a ClientToolResult.", - "signature": "executeFunctionTool(def: FunctionToolDef<>, rawArgs: unknown): Promise", + "signature": "executeFunctionTool(def: AnyFunctionToolDef, rawArgs: unknown): Promise", "params": [ { "name": "def", - "type": "FunctionToolDef<>", + "type": "AnyFunctionToolDef", "description": "", "optional": false }, @@ -7669,11 +7782,11 @@ "name": "getInterrupt", "kind": "function", "description": "", - "signature": "getInterrupt(agent: Agent): AgentInterrupt | undefined", + "signature": "getInterrupt(agent: Agent<>): AgentInterrupt | undefined", "params": [ { "name": "agent", - "type": "Agent", + "type": "Agent<>", "description": "", "optional": false } @@ -7804,11 +7917,11 @@ "name": "isTyping", "kind": "function", "description": "", - "signature": "isTyping(agent: Agent): boolean", + "signature": "isTyping(agent: Agent<>): boolean", "params": [ { "name": "agent", - "type": "Agent", + "type": "Agent<>", "description": "", "optional": false } @@ -7861,7 +7974,7 @@ "name": "mockAgent", "kind": "function", "description": "", - "signature": "mockAgent(opts: MockAgentOptions): MockAgent", + "signature": "mockAgent(opts: MockAgentOptions): MockAgent<>", "params": [ { "name": "opts", @@ -7871,7 +7984,7 @@ } ], "returns": { - "type": "MockAgent", + "type": "MockAgent<>", "description": "" }, "examples": [] @@ -7917,13 +8030,13 @@ { "name": "provideChat", "kind": "function", - "description": "", + "description": "Bootstrap `@threadplane/chat` in an Angular application or standalone\ncomponent tree.\n\nCall this once inside `bootstrapApplication` (or the `providers` array of a\nroot `ApplicationConfig`). It registers the shared ChatConfig token\nso every chat component in the tree can read the render registry, avatar\nlabel, and assistant display name without explicit prop threading.\n\nA license check is fired asynchronously on every call (it never throws; a\nwatermark is shown in non-commercial builds when no valid token is supplied).", "signature": "provideChat(config: ChatConfig): EnvironmentProviders", "params": [ { "name": "config", "type": "ChatConfig", - "description": "", + "description": "Options bag that controls the chat feature set:\n - `renderRegistry` — shared AngularRegistry wiring tool-view\n components to their names; pass the value returned by\n `defineAngularRegistry` from `\\@threadplane/render`.\n - `avatarLabel` — short label shown in the AI avatar bubble (default `\"A\"`).\n - `assistantName` — display name shown above assistant messages\n (default `\"Assistant\"`).\n - `license` — signed token from threadplane.ai; omit in development.", "optional": false } ], @@ -7931,7 +8044,9 @@ "type": "EnvironmentProviders", "description": "" }, - "examples": [] + "examples": [ + "```ts\n// main.ts\nimport { bootstrapApplication } from '@angular/platform-browser';\nimport { provideChat } from '@threadplane/chat';\nimport { defineAngularRegistry, provideRender } from '@threadplane/render';\nimport { DayCardComponent } from './day-card.component';\n\nconst registry = defineAngularRegistry({ day_card: DayCardComponent });\n\nbootstrapApplication(AppComponent, {\n providers: [\n provideChat({ renderRegistry: registry, avatarLabel: 'AI' }),\n provideRender({ registry }),\n ],\n});\n```" + ] }, { "name": "renderMarkdown", @@ -7962,11 +8077,11 @@ "name": "startClientToolExecutor", "kind": "function", "description": "Watches the agent's pending client tool calls and auto-runs FUNCTION tools,\nresolving each with its result. View/ask (component) tools are handled by the\nrendering layer, not here. No-op if the agent lacks the clientTools\ncapability. MUST be called in an injection context (sets up an effect).", - "signature": "startClientToolExecutor(agent: Agent, registry: ClientToolRegistry): void", + "signature": "startClientToolExecutor(agent: Agent<>, registry: ClientToolRegistry): void", "params": [ { "name": "agent", - "type": "Agent", + "type": "Agent<>", "description": "", "optional": false }, @@ -8006,11 +8121,11 @@ "name": "submitMessage", "kind": "function", "description": "Submits a trimmed message to the agent.\nReturns the trimmed string on success, or `null` if the input was empty.", - "signature": "submitMessage(agent: Agent, text: string): string | null", + "signature": "submitMessage(agent: Agent<>, text: string): string | null", "params": [ { "name": "agent", - "type": "Agent", + "type": "Agent<>", "description": "", "optional": false }, @@ -8089,21 +8204,23 @@ { "name": "tools", "kind": "function", - "description": "Collect named client tools into a frozen registry (the key is the tool name).", - "signature": "tools(map: Record): ClientToolRegistry", + "description": "Collect named client tools into a frozen, name-keyed registry.\n\nThe overload is generic over the entire map (`const M`) so that each tool's\nprecise type (FunctionToolDef``, ViewToolDef``, or\nAskToolDef``) and every literal key are preserved in the\nClientToolRegistry passed to `provideChat`. This lets downstream\nconsumers look up individual tools without losing generic information.", + "signature": "tools(map: M): Readonly", "params": [ { "name": "map", - "type": "Record", - "description": "", + "type": "M", + "description": "An object literal mapping tool names to tool definitions created\n by action, view, or ask.", "optional": false } ], "returns": { - "type": "ClientToolRegistry", + "type": "Readonly", "description": "" }, - "examples": [] + "examples": [ + "```ts\nconst move = action('Move a stop', z.object({ fromDay: z.number() }), (a) => a.fromDay);\nconst dayCard = view('Show a day card', z.object({ label: z.string() }), DayCardComponent);\n\nconst registry = tools({ move_stop: move, day_card: dayCard });\n// registry.move_stop is FunctionToolDef<...>\n// registry.day_card is ViewToolDef<...>\n```" + ] }, { "name": "toRenderRegistry", @@ -8152,33 +8269,35 @@ { "name": "view", "kind": "function", - "description": "Render-only component tool — the model fills its props; auto-acknowledged.", - "signature": "view(description: string, schema: StandardSchemaV1<>, component: Type): ClientToolDef", + "description": "Render-only component tool — the model fills the component's props from the\nschema's output; the tool call is auto-acknowledged once the component mounts.\n\nThe component's signal inputs are checked against the schema output type\n(strict-but-flexible: every schema key must be a declared input with an\nassignable type; the component may declare extra inputs the schema doesn't fill).\nAuthor the component with `ViewProps` as the input type set to\nguarantee the shapes stay aligned.", + "signature": "view(description: string, schema: S, component: AcceptComponent): ViewToolDef", "params": [ { "name": "description", "type": "string", - "description": "", + "description": "Natural-language description the model sees.", "optional": false }, { "name": "schema", - "type": "StandardSchemaV1<>", - "description": "", + "type": "S", + "description": "Standard Schema defining the props the model must supply.", "optional": false }, { "name": "component", - "type": "Type", - "description": "", + "type": "AcceptComponent", + "description": "Angular component whose signal inputs must be compatible with\n the schema output. A type-level error is reported here when they diverge.", "optional": false } ], "returns": { - "type": "ClientToolDef", + "type": "ViewToolDef", "description": "" }, - "examples": [] + "examples": [ + "```ts\nconst schema = z.object({ label: z.string(), day: z.number() });\ntype Inputs = ViewProps; // { label: string; day: number }\n\n\\@Component({ ... })\nclass DayCardComponent {\n label = input.required();\n day = input.required();\n}\n\nconst dayCard = view('Show a day card', schema, DayCardComponent);\nconst registry = tools({ day_card: dayCard });\n```" + ] }, { "name": "views", @@ -8346,7 +8465,7 @@ "name": "runAgentConformance", "kind": "function", "description": "Runs a suite of contract conformance assertions against a factory that\nproduces a fresh Agent. Adapter packages should call this in their\nown test suites to verify the contract is satisfied.", - "signature": "runAgentConformance(label: string, factory: () => Agent): void", + "signature": "runAgentConformance(label: string, factory: () => Agent<>): void", "params": [ { "name": "label", @@ -8356,7 +8475,7 @@ }, { "name": "factory", - "type": "() => Agent", + "type": "() => Agent<>", "description": "", "optional": false } @@ -8371,7 +8490,7 @@ "name": "runAgentWithHistoryConformance", "kind": "function", "description": "Conformance suite for AgentWithHistory implementations.\n\nRuns the base Agent conformance suite, then verifies the history\nsignal is present and returns an array of AgentCheckpoint-shaped entries.", - "signature": "runAgentWithHistoryConformance(label: string, factory: (seed: object) => AgentWithHistory): void", + "signature": "runAgentWithHistoryConformance(label: string, factory: (seed: object) => AgentWithHistory<>): void", "params": [ { "name": "label", @@ -8381,7 +8500,7 @@ }, { "name": "factory", - "type": "(seed: object) => AgentWithHistory", + "type": "(seed: object) => AgentWithHistory<>", "description": "", "optional": false } diff --git a/apps/website/content/docs/langgraph/api/api-docs.json b/apps/website/content/docs/langgraph/api/api-docs.json index 927195a4f..945b87b8c 100644 --- a/apps/website/content/docs/langgraph/api/api-docs.json +++ b/apps/website/content/docs/langgraph/api/api-docs.json @@ -1595,7 +1595,7 @@ }, { "name": "state", - "type": "Signal>", + "type": "Signal", "description": "", "optional": false }, @@ -2023,7 +2023,7 @@ }, { "name": "state", - "type": "Signal>", + "type": "Signal", "description": "", "optional": false }, @@ -2444,11 +2444,11 @@ { "name": "injectAgent", "kind": "function", - "description": "Retrieve the LangGraph-backed Agent from the current Angular injection context.\n\nMirrors `@threadplane/ag-ui`'s `injectAgent()` so consumer code is identical\nregardless of which adapter is wired in `app.config.ts`. The agent is a\nsingleton scoped to the injector that called `provideAgent()` — re-provide\nin a child component's `providers: []` to scope a different agent to that\nsubtree (Angular's hierarchical DI handles the rest).", - "signature": "injectAgent(): LangGraphAgent", + "description": "Retrieve the LangGraph-backed Agent from the current Angular injection context.\n\nMirrors `@threadplane/ag-ui`'s `injectAgent()` so consumer code is identical\nregardless of which adapter is wired in `app.config.ts`. The agent is a\nsingleton scoped to the injector that called `provideAgent()` — re-provide\nin a child component's `providers: []` to scope a different agent to that\nsubtree (Angular's hierarchical DI handles the rest).\n\n**Typed state via AgentRef.** Pass the same ref that was supplied to\n`provideAgent(ref, …)` to carry the state type through DI without repeating\nthe generic at every call site:\n\n```ts\nconst agent = injectAgent(TRIP); // LangGraphAgent\n```\n\nThe no-arg form defaults to `LangGraphAgent>`.", + "signature": "injectAgent(): LangGraphAgent>", "params": [], "returns": { - "type": "LangGraphAgent", + "type": "LangGraphAgent>", "description": "" }, "examples": [] @@ -2475,9 +2475,15 @@ { "name": "provideAgent", "kind": "function", - "description": "Wire the LangGraph adapter into Angular's dependency injection.\n\nRegisters a singleton `LangGraphAgent` constructed from `config`. Retrieve it\nin any component with `injectAgent()`. Provide this at the application root\n(`app.config.ts`) for an app-wide agent.\n\nTo use a different agent in a component subtree, re-provide\n`provideAgent({...})` in that component's `providers: []` array —\nAngular's hierarchical DI scopes the singleton accordingly.\n\n**Static vs factory config.** Pass a plain `AgentConfig` object when the\nconfig is known up front. Pass a `() => AgentConfig` factory when the config\ndepends on runtime/DI state — the factory runs inside an Angular injection\ncontext, so it may call `inject()` to read services, route params, or\ncomponent-scoped signals:\n\n```ts\nproviders: [\n provideAgent(() => {\n const route = inject(ActivatedRoute);\n return { assistantId: 'chat', threadId: toSignal(route.paramMap) };\n }),\n];\n```", - "signature": "provideAgent(configOrFactory: AgentConfig | () => AgentConfig): Provider[]", + "description": "Wire the LangGraph adapter into Angular's dependency injection.\n\nRegisters a singleton `LangGraphAgent` constructed from `config`. Retrieve it\nin any component with `injectAgent()`. Provide this at the application root\n(`app.config.ts`) for an app-wide agent.\n\nTo use a different agent in a component subtree, re-provide\n`provideAgent({...})` in that component's `providers: []` array —\nAngular's hierarchical DI scopes the singleton accordingly.\n\n**Static vs factory config.** Pass a plain `AgentConfig` object when the\nconfig is known up front. Pass a `() => AgentConfig` factory when the config\ndepends on runtime/DI state — the factory runs inside an Angular injection\ncontext, so it may call `inject()` to read services, route params, or\ncomponent-scoped signals:\n\n```ts\nproviders: [\n provideAgent(() => {\n const route = inject(ActivatedRoute);\n return { assistantId: 'chat', threadId: toSignal(route.paramMap) };\n }),\n];\n```\n\n**Typed state via AgentRef.** Pass a typed ref as the first argument to flow\nthe state shape from `provideAgent` to `injectAgent` without repeating the\ngeneric at every call site:\n\n```ts\nexport const TRIP = createAgentRef('trip');\n// app.config.ts:\nproviders: [provideAgent(TRIP, { assistantId: 'trip-graph' })]\n// component:\nconst agent = injectAgent(TRIP); // LangGraphAgent\n```", + "signature": "provideAgent(ref: AgentRef, configOrFactory: AgentConfig | () => AgentConfig): Provider[]", "params": [ + { + "name": "ref", + "type": "AgentRef", + "description": "", + "optional": false + }, { "name": "configOrFactory", "type": "AgentConfig | () => AgentConfig", diff --git a/apps/website/content/docs/langgraph/api/inject-agent.mdx b/apps/website/content/docs/langgraph/api/inject-agent.mdx index cded775c0..b18c26ffc 100644 --- a/apps/website/content/docs/langgraph/api/inject-agent.mdx +++ b/apps/website/content/docs/langgraph/api/inject-agent.mdx @@ -4,12 +4,16 @@ Call it in an Angular injection context, usually as a component field initializer. The returned object exposes Angular Signals for UI state and async methods for user actions. -Configuration is supplied globally via `provideAgent({...})` — `injectAgent()` itself takes no arguments. For typed state, supply its generics: +## Overloads + +### No-arg form ```ts -injectAgent(): LangGraphAgent +injectAgent(): LangGraphAgent ``` +Returns the default-typed agent. `state()` and `value()` are `Record`. Use this form when your component does not need to read typed state fields. + ```ts import { injectAgent } from '@threadplane/langgraph'; @@ -18,18 +22,57 @@ readonly chat = injectAgent(); await this.chat.submit({ message: 'Hello' }); ``` +### Typed ref form (recommended for typed state) + +```ts +injectAgent(ref: AgentRef): LangGraphAgent +``` + +Pass a typed ref handle created with `createAgentRef()` from `@threadplane/chat`. Returns a `LangGraphAgent` where `state()` and `value()` are typed as `T`. The same ref is passed to `provideAgent()` to bind the configuration. + +```ts +import { createAgentRef } from '@threadplane/chat'; +import { injectAgent, provideAgent } from '@threadplane/langgraph'; +import type { BaseMessage } from '@langchain/core/messages'; + +// Declare the ref once (e.g. in agent.ts or app.config.ts): +export interface MyState { + messages: BaseMessage[]; + summary: string; +} +export const MY_AGENT = createAgentRef('my-agent'); + +// Register in app.config.ts providers: +provideAgent(MY_AGENT, { + apiUrl: 'http://localhost:2024', + assistantId: 'my-graph', + threadId: () => localStorage.getItem('threadId') ?? undefined, + onThreadId: (id) => localStorage.setItem('threadId', id), +}); + +// Inject in a component or service: +readonly chat = injectAgent(MY_AGENT); +// chat.value() → MyState +// chat.state() → MyState +``` + +`createAgentRef(debugName?)` is exported from `@threadplane/chat`; the ref carries the state shape `T`. For typed interrupt payloads, pass the `BagTemplate` as the second generic at the inject site — `injectAgent(ref)` — as shown in the [Interrupts guide](/docs/langgraph/guides/interrupts). + Pair it with `provideAgent()` at bootstrap to configure the API URL, assistant id, thread persistence, and transport: ```ts import { bootstrapApplication } from '@angular/platform-browser'; import { provideAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; import { AppComponent } from './app/app.component'; +export const MY_AGENT = createAgentRef('my-agent'); + bootstrapApplication(AppComponent, { providers: [ - provideAgent({ + provideAgent(MY_AGENT, { apiUrl: 'http://localhost:2024', - assistantId: 'my-agent', + assistantId: 'my-graph', threadId: () => localStorage.getItem('threadId') ?? undefined, onThreadId: (id) => localStorage.setItem('threadId', id), }), diff --git a/apps/website/content/docs/langgraph/concepts/agent-architecture.mdx b/apps/website/content/docs/langgraph/concepts/agent-architecture.mdx index 5ed8dae18..e199170ea 100644 --- a/apps/website/content/docs/langgraph/concepts/agent-architecture.mdx +++ b/apps/website/content/docs/langgraph/concepts/agent-architecture.mdx @@ -173,7 +173,7 @@ interface AgentState { `, }) export class ReactAgentComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(); messages = this.agent.messages; @@ -242,7 +242,7 @@ When the agent calls a tool, `injectAgent()` exposes the execution lifecycle thr // toolCalls() — tool calls with status, args, and results // Updates in real time as tools start and complete -const agent = injectAgent(); +const agent = injectAgent(); // Each entry has: id, name, args, status, and optional result const activeTools = computed(() => @@ -435,7 +435,7 @@ interface OrchestratorState { `, }) export class MultiAgentComponent { - protected readonly orchestrator = injectAgent(); + protected readonly orchestrator = injectAgent(); messages = this.orchestrator.messages; @@ -488,7 +488,7 @@ When `handle_tool_error=True` is set, LangGraph catches `ToolException` and feed ### How Errors Surface in Angular ```typescript -const agent = injectAgent(); +const agent = injectAgent(); // The error() signal captures both transport and agent errors const error = computed(() => agent.error()); @@ -555,7 +555,7 @@ provideAgent({ }); // component.ts -const agent = injectAgent(); +const agent = injectAgent(); // Full checkpoint timeline — every state snapshot const timeline = computed(() => agent.history()); diff --git a/apps/website/content/docs/langgraph/concepts/angular-signals.mdx b/apps/website/content/docs/langgraph/concepts/angular-signals.mdx index cad0a8051..e48666869 100644 --- a/apps/website/content/docs/langgraph/concepts/angular-signals.mdx +++ b/apps/website/content/docs/langgraph/concepts/angular-signals.mdx @@ -61,6 +61,7 @@ const status = toSignal(status$, { initialValue: 'idle' }); ```typescript import { injectAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; import type { BaseMessage } from '@langchain/core/messages'; // The type parameter mirrors your graph's state schema — see State Management. @@ -68,9 +69,11 @@ interface ChatState { messages: BaseMessage[]; } +export const CHAT_AGENT = createAgentRef('chat'); + // You never touch BehaviorSubjects or toSignal() yourself. -// injectAgent() hands you clean Signals (config comes from provideAgent): -protected readonly chat = injectAgent(); +// injectAgent(ref) hands you clean Signals (config comes from provideAgent): +protected readonly chat = injectAgent(CHAT_AGENT); chat.messages(); // Signal chat.status(); // Signal<'idle' | 'running' | 'error'> @@ -101,7 +104,7 @@ Every `injectAgent()` instance moves through a lifecycle: **idle**, **running** The resource has been created but no request has been submitted yet. All Signals hold their initial values. ```typescript -protected readonly chat = injectAgent(); +protected readonly chat = injectAgent(); console.log(chat.status()); // 'idle' console.log(chat.messages()); // [] @@ -164,7 +167,7 @@ console.log(chat.isLoading()); // false import { computed } from '@angular/core'; import { injectAgent } from '@threadplane/langgraph'; -protected readonly chat = injectAgent(); +protected readonly chat = injectAgent(); // Count all messages in the conversation const messageCount = computed(() => chat.messages().length); @@ -332,7 +335,7 @@ import { injectAgent } from '@threadplane/langgraph'; export class ChatComponent { @ViewChild('chatContainer') chatContainer!: ElementRef; - protected readonly chat = injectAgent(); + protected readonly chat = injectAgent(); errorDisplay = computed(() => { const err = this.chat.error(); @@ -396,7 +399,7 @@ Since `injectAgent()` exposes Signals, condition 3 handles everything. When a ne `, }) export class ChatComponent { - protected readonly chat = injectAgent(); + protected readonly chat = injectAgent(); } ``` @@ -483,7 +486,7 @@ import { injectAgent } from '@threadplane/langgraph'; `, }) export class ChatComponent { - protected readonly chat = injectAgent(); + protected readonly chat = injectAgent(); // Derived state from the Python agent's output toolsUsed = computed(() => diff --git a/apps/website/content/docs/langgraph/concepts/langgraph-basics.mdx b/apps/website/content/docs/langgraph/concepts/langgraph-basics.mdx index fa5f7e7b0..24718c8b1 100644 --- a/apps/website/content/docs/langgraph/concepts/langgraph-basics.mdx +++ b/apps/website/content/docs/langgraph/concepts/langgraph-basics.mdx @@ -163,7 +163,7 @@ graph = builder.compile() **Angular connection:** Track tool execution in real-time: ```typescript -const agent = injectAgent(); +const agent = injectAgent(); // Watch tools execute const activeTools = computed(() => agent.toolCalls()); @@ -200,7 +200,7 @@ def execute_action(state: State) -> dict: **Angular connection:** The interrupt surfaces automatically: ```typescript -const agent = injectAgent(); +const agent = injectAgent(); // Show approval UI when agent pauses const pendingAction = computed(() => agent.interrupt()); @@ -242,7 +242,7 @@ provideAgent({ Then consume from any component: ```typescript -const orchestrator = injectAgent(); +const orchestrator = injectAgent(); const workers = computed(() => [...orchestrator.subagents().values()]); const workerCount = computed(() => workers().length); @@ -276,7 +276,7 @@ provideAgent({ }); // component.ts -const chat = injectAgent(); +const chat = injectAgent(); // User returns tomorrow — same thread, full history restored // No code needed — the adapter handles it diff --git a/apps/website/content/docs/langgraph/concepts/state-management.mdx b/apps/website/content/docs/langgraph/concepts/state-management.mdx index 92b29d5b2..e18a3086e 100644 --- a/apps/website/content/docs/langgraph/concepts/state-management.mdx +++ b/apps/website/content/docs/langgraph/concepts/state-management.mdx @@ -154,6 +154,8 @@ class ProjectState(MessagesState): ```typescript import { BaseMessage } from '@langchain/core/messages'; +import { injectAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; interface ProjectState { // Maps from MessagesState.messages @@ -167,7 +169,12 @@ interface ProjectState { error: string | null; } -const agent = injectAgent(); +export const PROJECT_AGENT = createAgentRef('project_agent'); + +// Configure in app.config.ts: +// provideAgent(PROJECT_AGENT, { apiUrl: '...', assistantId: 'project_agent' }); + +const agent = injectAgent(PROJECT_AGENT); ``` @@ -212,7 +219,7 @@ The agent doesn't wait until it's finished to send state updates. It streams par `injectAgent()` requests the stream modes it needs to populate its public signals by default: `['values', 'messages-tuple', 'updates', 'custom']`. Those feed state snapshots, streamed message tokens, per-node update events, and custom events respectively. Passing your own `streamMode` *replaces* that default set, so narrowing to a single mode means giving up the signals the others populate. Use `values` when you only need state snapshots, and `messages-tuple` when you only need individual message tokens. ```typescript -const agent = injectAgent(); +const agent = injectAgent(PROJECT_AGENT); agent.submit( { message: 'Update the project plan.' }, @@ -246,7 +253,7 @@ Because every state update is a new signal value, your templates reflect the age ` }) export class ProjectComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(PROJECT_AGENT); } ``` @@ -283,12 +290,15 @@ There are two kinds of state in a LangGraph Angular app, and keeping them separa **Application state** is owned by your Angular component or service. It's UI-only: sidebar visibility, active tab, selected message, form input values. It has nothing to do with the agent. ```typescript +// Defined once (e.g. in agent.ts): +// export const CHAT_AGENT = createAgentRef('chat'); + @Component({ changeDetection: ChangeDetectionStrategy.OnPush, }) export class ChatComponent { // --- Thread state (from injectAgent(), read-only) --- - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(CHAT_AGENT); // Convenience computed values from thread state readonly messages = this.agent.messages; // Signal @@ -342,9 +352,9 @@ Thread: "user_123_session" ```typescript // app.config.ts — configure threadId/onThreadId on the provider -provideAgent({ +// (CHAT_AGENT = createAgentRef('chat'), declared in agent.ts) +provideAgent(CHAT_AGENT, { apiUrl: '...', - assistantId: 'chat', // Same threadId = restored conversation history threadId: signal(this.route.snapshot.params['threadId']), @@ -354,7 +364,7 @@ provideAgent({ }); // component.ts -const agent = injectAgent(); +const agent = injectAgent(CHAT_AGENT); // Read checkpoint history for time-travel UI const history = agent.history(); // Signal @@ -424,6 +434,7 @@ def researcher_node(state: ResearchState) -> dict: ```typescript import { BaseMessage } from '@langchain/core/messages'; import { injectAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; interface ResearchState { messages: BaseMessage[]; @@ -440,11 +451,13 @@ interface ResearchState { model_used: string; } +export const RESEARCH_AGENT = createAgentRef('research_agent'); + // Configured globally in app.config.ts: -// provideAgent({ apiUrl: '...', assistantId: 'research_agent' }); +// provideAgent(RESEARCH_AGENT, { apiUrl: '...' }); // In your component: -protected readonly agent = injectAgent(); +protected readonly agent = injectAgent(RESEARCH_AGENT); ``` @@ -490,7 +503,7 @@ protected readonly agent = injectAgent(); ` }) export class ResearchComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(RESEARCH_AGENT); startResearch(query: string) { this.agent.submit({ message: query }); @@ -506,7 +519,7 @@ export class ResearchComponent { You rarely need to consume `agent.value()` raw in your template. Use `computed()` to derive clean, focused values: ```typescript -protected readonly agent = injectAgent(); +protected readonly agent = injectAgent(RESEARCH_AGENT); // Derived signals — recalculate only when their dependencies change readonly progress = computed(() => this.agent.value().progress); diff --git a/apps/website/content/docs/langgraph/guides/deployment.mdx b/apps/website/content/docs/langgraph/guides/deployment.mdx index ca8a4a643..7600f0ba5 100644 --- a/apps/website/content/docs/langgraph/guides/deployment.mdx +++ b/apps/website/content/docs/langgraph/guides/deployment.mdx @@ -215,7 +215,9 @@ function httpStatus(err: unknown): number | undefined { `, }) export class ChatComponent { - protected readonly chat = injectAgent(); + // injectAgent() without a ref returns the default-typed agent (state/value are Record). + // This component only uses status() and error(), so the no-arg form is sufficient. + protected readonly chat = injectAgent(); hasError = computed(() => this.chat.status() === 'error'); diff --git a/apps/website/content/docs/langgraph/guides/interrupts.mdx b/apps/website/content/docs/langgraph/guides/interrupts.mdx index 982d3f2e9..485461cf7 100644 --- a/apps/website/content/docs/langgraph/guides/interrupts.mdx +++ b/apps/website/content/docs/langgraph/guides/interrupts.mdx @@ -145,6 +145,7 @@ import { ChangeDetectionStrategy, } from '@angular/core'; import { injectAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; import type { BaseMessage } from '@langchain/core/messages'; interface ApprovalPayload { @@ -160,13 +161,18 @@ interface AgentState { approval_result: { approved: boolean; reason?: string }; } +export const APPROVAL_AGENT = createAgentRef('approval_agent'); + +// Configure in app.config.ts: +// provideAgent(APPROVAL_AGENT, { apiUrl: '...' }); + @Component({ selector: 'app-approval', templateUrl: './approval.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ApprovalComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(APPROVAL_AGENT); messages = computed(() => this.agent.messages()); pendingApproval = computed(() => this.agent.interrupt()); @@ -362,6 +368,7 @@ import { ChangeDetectionStrategy, } from '@angular/core'; import { injectAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; import type { BaseMessage } from '@langchain/core/messages'; interface StepApproval { @@ -380,13 +387,18 @@ type DeployState = { approval_result: { approved: boolean; reason?: string }; }; +export const DEPLOY_AGENT = createAgentRef('approval_agent'); + +// Configure in app.config.ts: +// provideAgent(DEPLOY_AGENT, { apiUrl: '...' }); + @Component({ selector: 'app-deploy-approval', templateUrl: './approval.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class DeployApprovalComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(DEPLOY_AGENT); currentStep = computed(() => { const interrupt = this.agent.interrupt(); @@ -464,6 +476,7 @@ By default, `interrupt()` returns an untyped value. The `BagTemplate` generic pa ```typescript import { injectAgent, BagTemplate } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; // Define the exact shape of your interrupt payload interface DeployApproval { @@ -478,7 +491,10 @@ type DeployBag = BagTemplate & { InterruptType: DeployApproval; }; -const agent = injectAgent(); +// The ref carries the state shape; pass the BagTemplate at the inject site. +export const TYPED_DEPLOY_AGENT = createAgentRef('approval_agent'); + +const agent = injectAgent(TYPED_DEPLOY_AGENT); const raw = agent.langGraphInterrupts(); // ^? Interrupt[] diff --git a/apps/website/content/docs/langgraph/guides/memory.mdx b/apps/website/content/docs/langgraph/guides/memory.mdx index 0322626ab..5db86cf8f 100644 --- a/apps/website/content/docs/langgraph/guides/memory.mdx +++ b/apps/website/content/docs/langgraph/guides/memory.mdx @@ -81,6 +81,7 @@ graph = builder.compile() ```typescript import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; import { injectAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; import type { BaseMessage } from '@langchain/core/messages'; interface AgentState { @@ -90,10 +91,11 @@ interface AgentState { mentioned_topics: string[]; } +export const MEMORY_AGENT = createAgentRef('memory_agent'); + // Configure in app.config.ts: -// provideAgent({ +// provideAgent(MEMORY_AGENT, { // apiUrl: '...', -// assistantId: 'memory_agent', // threadId: signal(localStorage.getItem('memory-thread')), // onThreadId: (id) => localStorage.setItem('memory-thread', id), // }); @@ -104,7 +106,7 @@ interface AgentState { changeDetection: ChangeDetectionStrategy.OnPush, }) export class MemoryChatComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(MEMORY_AGENT); // Reactive memory signals derived from agent state preferences = computed(() => this.agent.value()?.user_preferences ?? {}); @@ -196,15 +198,17 @@ result = graph.invoke( On the Angular side, thread-scoped memory requires no extra code. The `threadId` signal handles it — configure it once in `provideAgent({...})`: ```typescript +// agent.ts (shared) +// export const MEMORY_AGENT = createAgentRef('memory_agent'); + // app.config.ts -provideAgent({ +provideAgent(MEMORY_AGENT, { apiUrl: '...', - assistantId: 'memory_agent', threadId: signal(userId()), // Same user = same thread = same memory }); // component.ts -const chat = injectAgent(); +const chat = injectAgent(MEMORY_AGENT); // chat.messages() restores full history on reconnect // chat.value() restores all custom state fields diff --git a/apps/website/content/docs/langgraph/guides/subgraphs.mdx b/apps/website/content/docs/langgraph/guides/subgraphs.mdx index d3b670601..05aaeaaef 100644 --- a/apps/website/content/docs/langgraph/guides/subgraphs.mdx +++ b/apps/website/content/docs/langgraph/guides/subgraphs.mdx @@ -76,6 +76,14 @@ graph = builder.compile() ```typescript import { Component, computed, inject, effect, ChangeDetectionStrategy } from '@angular/core'; import { injectAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; + +export interface OrchestratorState { + messages: BaseMessage[]; + // add your subgraph output fields here +} + +export const ORCHESTRATOR = createAgentRef('orchestrator'); @Component({ selector: 'app-orchestrator', @@ -83,7 +91,7 @@ import { injectAgent } from '@threadplane/langgraph'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class OrchestratorComponent { - protected readonly orchestrator = injectAgent(); + protected readonly orchestrator = injectAgent(ORCHESTRATOR); readonly messages = computed(() => this.orchestrator.messages()); readonly isRunning = computed(() => this.orchestrator.isLoading()); @@ -98,7 +106,7 @@ export class OrchestratorComponent { -`OrchestratorState` and `PipelineState` below are placeholders for your own graph's state schema — the shape your subgraph's `StateGraph` produces. They mirror the Python state the same way `ChatState` does on the [State Management](/docs/langgraph/concepts/state-management) page. A `messages`-only graph is just `injectAgent<{ messages: BaseMessage[] }>()`. +`OrchestratorState` and `PipelineState` below are placeholders for your own graph's state schema — the shape your subgraph's `StateGraph` produces. They mirror the Python state the same way `ChatState` does on the [State Management](/docs/langgraph/concepts/state-management) page. Use `createAgentRef('your-assistant-id')` to create a typed ref, then pass it to both `provideAgent()` and `injectAgent()`. ## Tracking delegated subagent execution @@ -106,14 +114,17 @@ export class OrchestratorComponent { The `subagents()` signal contains a Map of active delegated subagent streams. Use it when your graph delegates through tool calls, such as Deep Agents' default `task` tool or your own delegation tools. Plain subgraph nodes do not appear in this map. ```typescript +// In a shared file (e.g. agent.ts): +// import { createAgentRef } from '@threadplane/chat'; +// export const ORCHESTRATOR = createAgentRef('orchestrator'); + // Configure in app.config.ts: -// provideAgent({ +// provideAgent(ORCHESTRATOR, { // apiUrl: '...', -// assistantId: 'orchestrator', // subagentToolNames: ['task', 'delegate_to_researcher'], // }); -const orchestrator = injectAgent(); +const orchestrator = injectAgent(ORCHESTRATOR); // All subagent streams (active and completed) const subagents = computed(() => orchestrator.subagents()); @@ -164,15 +175,18 @@ const researchMessages = computed(() => researchAgent()?.messages() ?? []); The orchestrator pattern delegates specialised work to subagents and merges their results. Each subagent runs its own graph independently while the parent coordinates the whole. ```typescript +// In a shared file (e.g. agent.ts): +// import { createAgentRef } from '@threadplane/chat'; +// export const PIPELINE = createAgentRef('pipeline-orchestrator'); + // Configure in app.config.ts: -// provideAgent({ +// provideAgent(PIPELINE, { // apiUrl: '...', -// assistantId: 'pipeline-orchestrator', // subagentToolNames: ['task'], // filterSubagentMessages: true, // }); -const pipeline = injectAgent(); +const pipeline = injectAgent(PIPELINE); // Derive a summary of all subagent states const pipelineStatus = computed(() => { @@ -198,6 +212,7 @@ Render live progress for each subagent using the signals above. ```typescript import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; import { injectAgent } from '@threadplane/langgraph'; +import { ORCHESTRATOR } from './agent'; // createAgentRef('orchestrator') @Component({ selector: 'app-subagent-progress', @@ -205,7 +220,7 @@ import { injectAgent } from '@threadplane/langgraph'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SubagentProgressComponent { - protected readonly orchestrator = injectAgent(); + protected readonly orchestrator = injectAgent(ORCHESTRATOR); subagentEntries = computed(() => [...this.orchestrator.subagents().entries()] @@ -240,15 +255,18 @@ export class SubagentProgressComponent { By default, subagent messages appear in the parent's `messages()` signal. Filter them out for a cleaner parent view. ```typescript +// In a shared file (e.g. agent.ts): +// import { createAgentRef } from '@threadplane/chat'; +// export const ORCHESTRATOR = createAgentRef('orchestrator'); + // Configure in app.config.ts: -// provideAgent({ +// provideAgent(ORCHESTRATOR, { // apiUrl: '...', -// assistantId: 'orchestrator', // filterSubagentMessages: true, // Hide subagent messages from parent // subagentToolNames: ['task'], // }); -const orchestrator = injectAgent(); +const orchestrator = injectAgent(ORCHESTRATOR); // Parent messages only (no subagent chatter) const parentMessages = computed(() => orchestrator.messages()); diff --git a/apps/website/content/docs/langgraph/guides/time-travel.mdx b/apps/website/content/docs/langgraph/guides/time-travel.mdx index 4ce3c588e..c05ff0915 100644 --- a/apps/website/content/docs/langgraph/guides/time-travel.mdx +++ b/apps/website/content/docs/langgraph/guides/time-travel.mdx @@ -102,8 +102,12 @@ export class HistoryViewerComponent { The `history()` signal contains runtime-neutral `AgentCheckpoint` entries for the thread. For LangGraph-specific checkpoint metadata, `langGraphHistory()` exposes the raw `ThreadState[]`. The framework loads this history with `threads.getHistory()` when a thread is selected and refreshes it after a run completes. ```typescript -// Configured globally in app.config.ts via provideAgent({ ... threadId, assistantId }). -const agent = injectAgent(); +// agent.ts (shared): +// import { createAgentRef } from '@threadplane/chat'; +// export const MY_AGENT = createAgentRef('my-agent'); + +// Configured globally in app.config.ts via provideAgent(MY_AGENT, { ... threadId }). +const agent = injectAgent(MY_AGENT); // Runtime-neutral execution timeline const checkpoints = computed(() => agent.history()); @@ -249,13 +253,17 @@ Use the comparison result to render a diff view, highlight changed fields in you Combine forking with new input to explore how the agent would have responded differently. This is the core of the undo/redo experience. ```typescript +// agent.ts (shared): +// import { createAgentRef } from '@threadplane/chat'; +// export const MY_AGENT = createAgentRef('my-agent'); + @Component({ selector: 'app-replay', templateUrl: './replay.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ReplayComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(MY_AGENT); readonly history = computed(() => this.agent.history()); readonly rawHistory = computed(() => this.agent.langGraphHistory()); diff --git a/apps/website/content/docs/render/api/api-docs.json b/apps/website/content/docs/render/api/api-docs.json index 0263921f7..951646bf3 100644 --- a/apps/website/content/docs/render/api/api-docs.json +++ b/apps/website/content/docs/render/api/api-docs.json @@ -702,13 +702,13 @@ { "name": "defineAngularRegistry", "kind": "function", - "description": "", + "description": "Build an AngularRegistry from a plain object mapping tool-call names\nto Angular components (or fully specified RenderViewEntry objects).\n\nThe returned registry is consumed by both `provideRender` (to drive\ndynamic component rendering) and `provideChat` (via `renderRegistry`) so\nthat a single `defineAngularRegistry` call wires both layers.\n\n**Entry forms**\n- Bare `Type` — the component is paired with the built-in\n `DefaultFallbackComponent` while its props are still streaming.\n- `RenderViewEntry` object — lets you supply a custom `fallback` component,\n an optional Standard Schema (`schema`) used as a mount-readiness gate, and\n an optional `description` for model-facing tool registration.\n\n**Registry accessor**\nThe returned object exposes a single `getEntry(name: string)` accessor that\nreturns the fully-normalized NormalizedEntry (component + fallback +\noptional schema + optional description) or `undefined` when the name is not\nregistered. Use `names()` to enumerate all registered names.", "signature": "defineAngularRegistry(componentMap: RegistryInput): AngularRegistry", "params": [ { "name": "componentMap", "type": "RegistryInput", - "description": "", + "description": "Object whose keys are tool-call names and whose values\n are either bare Angular component classes or RenderViewEntry objects.", "optional": false } ], @@ -716,7 +716,9 @@ "type": "AngularRegistry", "description": "" }, - "examples": [] + "examples": [ + "```ts\nimport { defineAngularRegistry } from '@threadplane/render';\nimport { DayCardComponent } from './day-card.component';\nimport { LoadingSpinnerComponent } from './loading-spinner.component';\nimport { z } from 'zod';\n\nexport const registry = defineAngularRegistry({\n // Bare component — uses DefaultFallbackComponent while streaming.\n summary_card: SummaryCardComponent,\n\n // Full entry — custom fallback + schema-gated mounting.\n day_card: {\n component: DayCardComponent,\n fallback: LoadingSpinnerComponent,\n schema: z.object({ label: z.string(), day: z.number() }),\n description: 'Renders a single itinerary day card.',\n },\n});\n\n// Look up a registered entry at runtime:\nconst entry = registry.getEntry('day_card'); // NormalizedEntry | undefined\n```" + ] }, { "name": "injectRenderHost", @@ -758,13 +760,13 @@ { "name": "provideRender", "kind": "function", - "description": "", + "description": "Bootstrap `@threadplane/render` in an Angular application or standalone\ncomponent tree.\n\nRegisters the shared RenderConfig token and the internal\n`RenderLifecycleService` that coordinates mount/unmount events across\ndynamically rendered components. Call this once alongside `provideChat` in\n`bootstrapApplication` (or the root `ApplicationConfig`).", "signature": "provideRender(config: RenderConfig): EnvironmentProviders", "params": [ { "name": "config", "type": "RenderConfig", - "description": "", + "description": "Options bag that controls the render feature set:\n - `registry` — component registry returned by defineAngularRegistry;\n maps tool-call names to Angular components.\n - `store` — optional `StateStore` for `\\@json-render/core` state binding.\n - `functions` — optional map of computed functions available inside specs.\n - `handlers` — optional map of event handlers triggered by spec actions.", "optional": false } ], @@ -772,7 +774,9 @@ "type": "EnvironmentProviders", "description": "" }, - "examples": [] + "examples": [ + "```ts\n// main.ts\nimport { bootstrapApplication } from '@angular/platform-browser';\nimport { defineAngularRegistry, provideRender } from '@threadplane/render';\nimport { provideChat } from '@threadplane/chat';\nimport { DayCardComponent } from './day-card.component';\n\nconst registry = defineAngularRegistry({ day_card: DayCardComponent });\n\nbootstrapApplication(AppComponent, {\n providers: [\n provideRender({ registry }),\n provideChat({ renderRegistry: registry }),\n ],\n});\n```" + ] }, { "name": "provideViews", diff --git a/cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts b/cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts index 9c85cb01f..58724d47a 100644 --- a/cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts +++ b/cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts @@ -6,6 +6,7 @@ import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; import { z } from 'zod/v4'; import { WeatherCardComponent } from './weather-card.component'; import { ConfirmBookingComponent } from './confirm-booking.component'; +import { weatherCardSchema, confirmBookingSchema } from './schemas'; /** * Client-tools demo — tools declared in the browser that the model calls and @@ -15,6 +16,10 @@ import { ConfirmBookingComponent } from './confirm-booking.component'; * component whose emitted value becomes the result). The catalog is shipped to * the model via the AG-UI adapter; the backend graph binds the client stubs * (no server implementation) and ends the turn so the browser executes them. + * + * Under `strict: true` the typed `view`/`ask` overloads verify at compile time + * that every field the schema produces is a declared `input()` on the paired + * component. Mismatches become errors here, not silent runtime failures. */ const clientTools = tools({ get_weather: action( @@ -24,18 +29,12 @@ const clientTools = tools({ ), weather_card: view( 'Display a weather card for a location with the given readings.', - z.object({ - location: z.string(), - temperatureF: z.number(), - conditions: z.string(), - humidity: z.number(), - windMph: z.number(), - }), + weatherCardSchema, WeatherCardComponent, ), confirm_booking: ask( 'Ask the user to confirm a booking before finalizing it.', - z.object({ summary: z.string() }), + confirmBookingSchema, ConfirmBookingComponent, ), }); diff --git a/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts b/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts index 94ab9e96f..30c448223 100644 --- a/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts +++ b/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import type { ViewProps } from '@threadplane/chat'; import { injectRenderHost } from '@threadplane/render'; +import { confirmBookingSchema } from './schemas'; /** * The interactive component for the `confirm_booking` client tool (an `ask`). @@ -13,7 +15,14 @@ import { injectRenderHost } from '@threadplane/render'; * prop (chat-tool-views spreads `{...args, ...result, status}` into it). When * `confirmed()` is defined we render a FROZEN line with no buttons; the live * interactive card only shows while `confirmed()` is still undefined. + * + * Input types for schema-derived props are anchored to `ViewProps` — a schema change is a compile error here. */ + +/** Props this component receives from the `confirm_booking` schema. */ +type ConfirmBookingProps = ViewProps; + @Component({ selector: 'app-confirm-booking', standalone: true, @@ -47,7 +56,8 @@ import { injectRenderHost } from '@threadplane/render'; `], }) export class ConfirmBookingComponent { - readonly summary = input(); + // Schema-derived input — type anchored to ConfirmBookingProps. + readonly summary = input(); /** Spread back onto props after the ask resolves (undefined while interactive). */ readonly confirmed = input(undefined); private readonly host = injectRenderHost(); diff --git a/cockpit/ag-ui/client-tools/angular/src/app/schemas.ts b/cockpit/ag-ui/client-tools/angular/src/app/schemas.ts new file mode 100644 index 000000000..00a7cd95d --- /dev/null +++ b/cockpit/ag-ui/client-tools/angular/src/app/schemas.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +/** + * Shared Zod schemas for the client-tools demo. + * + * Exporting them here lets each view/ask component anchor its `ViewProps` type annotation directly to the schema — so a schema change is a + * compile error on the component, not a silent runtime mismatch. + */ +import { z } from 'zod/v4'; + +/** Schema for the `weather_card` view tool. */ +export const weatherCardSchema = z.object({ + location: z.string(), + temperatureF: z.number(), + conditions: z.string(), + humidity: z.number(), + windMph: z.number(), +}); + +/** Schema for the `confirm_booking` ask tool. */ +export const confirmBookingSchema = z.object({ summary: z.string() }); diff --git a/cockpit/ag-ui/client-tools/angular/src/app/weather-card.component.ts b/cockpit/ag-ui/client-tools/angular/src/app/weather-card.component.ts index c5976655f..478f09185 100644 --- a/cockpit/ag-ui/client-tools/angular/src/app/weather-card.component.ts +++ b/cockpit/ag-ui/client-tools/angular/src/app/weather-card.component.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import type { ViewProps } from '@threadplane/chat'; +import { weatherCardSchema } from './schemas'; /** * A frontend-owned view rendered for the `weather_card` tool call. Receives @@ -7,7 +9,14 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co * on completion (`temperatureF`, `conditions`, `humidity`, `windMph`), and a * `status` of 'running' | 'complete'. Renders a loading affordance until the * result arrives. + * + * Input types are derived from {@link weatherCardSchema} via `ViewProps` so + * that a schema change is a compile error here under `strict: true`. */ + +/** Props this component receives from the `weather_card` schema. */ +type WeatherCardProps = ViewProps; + @Component({ selector: 'app-weather-card', standalone: true, @@ -41,11 +50,15 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co `], }) export class WeatherCardComponent { - readonly location = input(); - readonly temperatureF = input(); - readonly conditions = input(); - readonly humidity = input(); - readonly windMph = input(); + // Schema-derived inputs — types anchored to WeatherCardProps so a schema + // change is a compile error. Optional because the framework sends partial + // props during streaming (args arrive before the tool result). + readonly location = input(); + readonly temperatureF = input(); + readonly conditions = input(); + readonly humidity = input(); + readonly windMph = input(); + /** Extra input not in the schema: injected by the framework for rendering state. */ readonly status = input<'running' | 'complete'>(); readonly pending = computed(() => this.status() !== 'complete' || this.temperatureF() === undefined); diff --git a/cockpit/ag-ui/client-tools/angular/tsconfig.json b/cockpit/ag-ui/client-tools/angular/tsconfig.json index 35cdb00e6..00d3e70aa 100644 --- a/cockpit/ag-ui/client-tools/angular/tsconfig.json +++ b/cockpit/ag-ui/client-tools/angular/tsconfig.json @@ -8,8 +8,7 @@ "composite": false, "lib": ["es2022", "dom"], "skipLibCheck": true, - "strict": false, - "strictNullChecks": true + "strict": true }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, diff --git a/cockpit/chat/messages/angular/src/app/agent-ref.ts b/cockpit/chat/messages/angular/src/app/agent-ref.ts new file mode 100644 index 000000000..47cf145c3 --- /dev/null +++ b/cockpit/chat/messages/angular/src/app/agent-ref.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +import { createAgentRef } from '@threadplane/chat'; + +/** State shape for the messages cockpit (LangGraph MessagesState). */ +export interface MessagesState { + messages: unknown[]; +} + +/** + * Typed DI handle for the messages agent. + * Wire with `provideAgent(MESSAGES_AGENT, { ... })` and inject with + * `injectAgent(MESSAGES_AGENT)` to get `LangGraphAgent`. + */ +export const MESSAGES_AGENT = createAgentRef('messages'); diff --git a/cockpit/chat/messages/angular/src/app/app.config.ts b/cockpit/chat/messages/angular/src/app/app.config.ts index 67b6e650c..12f61db9d 100644 --- a/cockpit/chat/messages/angular/src/app/app.config.ts +++ b/cockpit/chat/messages/angular/src/app/app.config.ts @@ -3,10 +3,11 @@ import { ApplicationConfig } from '@angular/core'; import { provideAgent } from '@threadplane/langgraph'; import { provideChat } from '@threadplane/chat'; import { environment } from '../environments/environment'; +import { MESSAGES_AGENT } from './agent-ref'; export const appConfig: ApplicationConfig = { providers: [ - provideAgent({ + provideAgent(MESSAGES_AGENT, { apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, }), diff --git a/cockpit/chat/messages/angular/src/app/messages.component.ts b/cockpit/chat/messages/angular/src/app/messages.component.ts index 1eeec73cd..7044e7176 100644 --- a/cockpit/chat/messages/angular/src/app/messages.component.ts +++ b/cockpit/chat/messages/angular/src/app/messages.component.ts @@ -11,6 +11,7 @@ import { } from '@threadplane/chat'; import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; import { injectAgent } from '@threadplane/langgraph'; +import { MESSAGES_AGENT, type MessagesState } from './agent-ref'; /** * MessagesComponent demonstrates the chat message primitives from @threadplane/chat. @@ -84,7 +85,9 @@ import { injectAgent } from '@threadplane/langgraph'; `, }) export class MessagesComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(MESSAGES_AGENT); + // Typed read: prove MessagesState flows through DI. + protected readonly _typedState: MessagesState = this.agent.value(); protected readonly messageContent = messageContent; diff --git a/cockpit/langgraph/client-tools/angular/src/app/agent-ref.ts b/cockpit/langgraph/client-tools/angular/src/app/agent-ref.ts new file mode 100644 index 000000000..b3b60bcfb --- /dev/null +++ b/cockpit/langgraph/client-tools/angular/src/app/agent-ref.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +import { createAgentRef } from '@threadplane/chat'; +import type { BaseMessage } from '@langchain/core/messages'; + +/** + * State shape of the LangGraph client-tools graph (mirrors the Python TypedDict + * in cockpit/langgraph/client-tools/python/src/graph.py). + * + * `messages` is the LangChain message channel (add_messages-annotated list). + * `client_tools` carries the browser-declared tool catalog that the + * `@threadplane/langgraph` adapter ships as `input.client_tools`. + */ +export interface ClientToolsState { + messages: BaseMessage[]; + client_tools: unknown[]; +} + +/** + * Typed DI ref for the client-tools LangGraph agent. + * Pass to `provideAgent` in `app.config.ts` and `injectAgent` in components + * to carry `ClientToolsState` through DI without repeating the generic. + */ +export const CLIENT_TOOLS_AGENT_REF = createAgentRef('client-tools'); diff --git a/cockpit/langgraph/client-tools/angular/src/app/app.config.ts b/cockpit/langgraph/client-tools/angular/src/app/app.config.ts index b08e6d2b7..282bb737f 100644 --- a/cockpit/langgraph/client-tools/angular/src/app/app.config.ts +++ b/cockpit/langgraph/client-tools/angular/src/app/app.config.ts @@ -3,10 +3,11 @@ import { ApplicationConfig } from '@angular/core'; import { provideAgent } from '@threadplane/langgraph'; import { provideChat } from '@threadplane/chat'; import { environment } from '../environments/environment'; +import { CLIENT_TOOLS_AGENT_REF } from './agent-ref'; export const appConfig: ApplicationConfig = { providers: [ - provideAgent({ + provideAgent(CLIENT_TOOLS_AGENT_REF, { apiUrl: environment.langGraphApiUrl, assistantId: environment.clientToolsAssistantId, }), diff --git a/cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts b/cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts index 942d0d59f..112d96e61 100644 --- a/cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts +++ b/cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts @@ -1,11 +1,13 @@ // SPDX-License-Identifier: MIT -import { Component } from '@angular/core'; +import { Component, computed } from '@angular/core'; import { ChatComponent, tools, action, view, ask } from '@threadplane/chat'; import { injectAgent } from '@threadplane/langgraph'; import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; import { z } from 'zod/v4'; import { WeatherCardComponent } from './weather-card.component'; import { ConfirmBookingComponent } from './confirm-booking.component'; +import { weatherCardSchema, confirmBookingSchema } from './schemas'; +import { CLIENT_TOOLS_AGENT_REF, type ClientToolsState } from './agent-ref'; /** * Client-tools demo — tools declared in the browser that the model calls and @@ -16,6 +18,10 @@ import { ConfirmBookingComponent } from './confirm-booking.component'; * the model via the LangGraph adapter (as `input.client_tools`); the backend * graph binds the client stubs (no server implementation) and ends the turn so * the browser executes them. + * + * Under `strict: true` the typed `view`/`ask` overloads verify at compile time + * that every field the schema produces is a declared `input()` on the paired + * component. Mismatches become errors here, not silent runtime failures. */ const clientTools = tools({ get_weather: action( @@ -25,18 +31,12 @@ const clientTools = tools({ ), weather_card: view( 'Display a weather card for a location with the given readings.', - z.object({ - location: z.string(), - temperatureF: z.number(), - conditions: z.string(), - humidity: z.number(), - windMph: z.number(), - }), + weatherCardSchema, WeatherCardComponent, ), confirm_booking: ask( 'Ask the user to confirm a booking before finalizing it.', - z.object({ summary: z.string() }), + confirmBookingSchema, ConfirmBookingComponent, ), }); @@ -52,6 +52,17 @@ const clientTools = tools({ `, }) export class ClientToolsComponent { - protected readonly agent = injectAgent(); + /** Typed agent: state() and value() are ClientToolsState. */ + protected readonly agent = injectAgent(CLIENT_TOOLS_AGENT_REF); protected readonly clientTools = clientTools; + + /** + * Typed state read — proves the typed DI path compiles under strict: true. + * `messages` and `client_tools` are read from the strongly-typed + * `ClientToolsState` shape; the compiler errors if the field does not exist. + */ + protected readonly messageCount = computed((): number => { + const s: ClientToolsState = this.agent.value(); + return s.messages.length; + }); } diff --git a/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts b/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts index 94ab9e96f..30c448223 100644 --- a/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts +++ b/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import type { ViewProps } from '@threadplane/chat'; import { injectRenderHost } from '@threadplane/render'; +import { confirmBookingSchema } from './schemas'; /** * The interactive component for the `confirm_booking` client tool (an `ask`). @@ -13,7 +15,14 @@ import { injectRenderHost } from '@threadplane/render'; * prop (chat-tool-views spreads `{...args, ...result, status}` into it). When * `confirmed()` is defined we render a FROZEN line with no buttons; the live * interactive card only shows while `confirmed()` is still undefined. + * + * Input types for schema-derived props are anchored to `ViewProps` — a schema change is a compile error here. */ + +/** Props this component receives from the `confirm_booking` schema. */ +type ConfirmBookingProps = ViewProps; + @Component({ selector: 'app-confirm-booking', standalone: true, @@ -47,7 +56,8 @@ import { injectRenderHost } from '@threadplane/render'; `], }) export class ConfirmBookingComponent { - readonly summary = input(); + // Schema-derived input — type anchored to ConfirmBookingProps. + readonly summary = input(); /** Spread back onto props after the ask resolves (undefined while interactive). */ readonly confirmed = input(undefined); private readonly host = injectRenderHost(); diff --git a/cockpit/langgraph/client-tools/angular/src/app/schemas.ts b/cockpit/langgraph/client-tools/angular/src/app/schemas.ts new file mode 100644 index 000000000..00a7cd95d --- /dev/null +++ b/cockpit/langgraph/client-tools/angular/src/app/schemas.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +/** + * Shared Zod schemas for the client-tools demo. + * + * Exporting them here lets each view/ask component anchor its `ViewProps` type annotation directly to the schema — so a schema change is a + * compile error on the component, not a silent runtime mismatch. + */ +import { z } from 'zod/v4'; + +/** Schema for the `weather_card` view tool. */ +export const weatherCardSchema = z.object({ + location: z.string(), + temperatureF: z.number(), + conditions: z.string(), + humidity: z.number(), + windMph: z.number(), +}); + +/** Schema for the `confirm_booking` ask tool. */ +export const confirmBookingSchema = z.object({ summary: z.string() }); diff --git a/cockpit/langgraph/client-tools/angular/src/app/weather-card.component.ts b/cockpit/langgraph/client-tools/angular/src/app/weather-card.component.ts index c5976655f..478f09185 100644 --- a/cockpit/langgraph/client-tools/angular/src/app/weather-card.component.ts +++ b/cockpit/langgraph/client-tools/angular/src/app/weather-card.component.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import type { ViewProps } from '@threadplane/chat'; +import { weatherCardSchema } from './schemas'; /** * A frontend-owned view rendered for the `weather_card` tool call. Receives @@ -7,7 +9,14 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co * on completion (`temperatureF`, `conditions`, `humidity`, `windMph`), and a * `status` of 'running' | 'complete'. Renders a loading affordance until the * result arrives. + * + * Input types are derived from {@link weatherCardSchema} via `ViewProps` so + * that a schema change is a compile error here under `strict: true`. */ + +/** Props this component receives from the `weather_card` schema. */ +type WeatherCardProps = ViewProps; + @Component({ selector: 'app-weather-card', standalone: true, @@ -41,11 +50,15 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co `], }) export class WeatherCardComponent { - readonly location = input(); - readonly temperatureF = input(); - readonly conditions = input(); - readonly humidity = input(); - readonly windMph = input(); + // Schema-derived inputs — types anchored to WeatherCardProps so a schema + // change is a compile error. Optional because the framework sends partial + // props during streaming (args arrive before the tool result). + readonly location = input(); + readonly temperatureF = input(); + readonly conditions = input(); + readonly humidity = input(); + readonly windMph = input(); + /** Extra input not in the schema: injected by the framework for rendering state. */ readonly status = input<'running' | 'complete'>(); readonly pending = computed(() => this.status() !== 'complete' || this.temperatureF() === undefined); diff --git a/cockpit/langgraph/client-tools/angular/tsconfig.json b/cockpit/langgraph/client-tools/angular/tsconfig.json index 35cdb00e6..00d3e70aa 100644 --- a/cockpit/langgraph/client-tools/angular/tsconfig.json +++ b/cockpit/langgraph/client-tools/angular/tsconfig.json @@ -8,8 +8,7 @@ "composite": false, "lib": ["es2022", "dom"], "skipLibCheck": true, - "strict": false, - "strictNullChecks": true + "strict": true }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, diff --git a/cockpit/langgraph/streaming/angular/src/app/agent-ref.ts b/cockpit/langgraph/streaming/angular/src/app/agent-ref.ts new file mode 100644 index 000000000..e4e807659 --- /dev/null +++ b/cockpit/langgraph/streaming/angular/src/app/agent-ref.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +import { createAgentRef } from '@threadplane/chat'; + +/** State shape for the streaming cockpit (LangGraph MessagesState). */ +export interface StreamingState { + messages: unknown[]; +} + +/** + * Typed DI handle for the streaming agent. + * Wire with `provideAgent(STREAMING_AGENT, { ... })` and inject with + * `injectAgent(STREAMING_AGENT)` to get `LangGraphAgent`. + */ +export const STREAMING_AGENT = createAgentRef('streaming'); diff --git a/cockpit/langgraph/streaming/angular/src/app/app.config.ts b/cockpit/langgraph/streaming/angular/src/app/app.config.ts index 67b6e650c..cfa0f2eff 100644 --- a/cockpit/langgraph/streaming/angular/src/app/app.config.ts +++ b/cockpit/langgraph/streaming/angular/src/app/app.config.ts @@ -3,10 +3,11 @@ import { ApplicationConfig } from '@angular/core'; import { provideAgent } from '@threadplane/langgraph'; import { provideChat } from '@threadplane/chat'; import { environment } from '../environments/environment'; +import { STREAMING_AGENT } from './agent-ref'; export const appConfig: ApplicationConfig = { providers: [ - provideAgent({ + provideAgent(STREAMING_AGENT, { apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, }), diff --git a/cockpit/langgraph/streaming/angular/src/app/streaming.component.ts b/cockpit/langgraph/streaming/angular/src/app/streaming.component.ts index b2504cb7b..7015c0d61 100644 --- a/cockpit/langgraph/streaming/angular/src/app/streaming.component.ts +++ b/cockpit/langgraph/streaming/angular/src/app/streaming.component.ts @@ -2,6 +2,7 @@ import { Component } from '@angular/core'; import { ChatComponent, ChatWelcomeSuggestionComponent } from '@threadplane/chat'; import { injectAgent } from '@threadplane/langgraph'; +import { STREAMING_AGENT, type StreamingState } from './agent-ref'; import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; const WELCOME_SUGGESTIONS = [ @@ -37,7 +38,9 @@ const WELCOME_SUGGESTIONS = [ `, }) export class StreamingComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(STREAMING_AGENT); + // Typed read: prove StreamingState flows through DI. + protected readonly _typedState: StreamingState = this.agent.value(); protected readonly suggestions = WELCOME_SUGGESTIONS; protected send(text: string): void { diff --git a/docs/superpowers/plans/2026-06-18-typescript-dx-pass.md b/docs/superpowers/plans/2026-06-18-typescript-dx-pass.md new file mode 100644 index 000000000..6df462401 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-typescript-dx-pass.md @@ -0,0 +1,1157 @@ +# TypeScript DX Pass Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the `@threadplane/*` hero primitives (`tools/action/view/ask`, `provideAgent/injectAgent`) infer correctly under `strict: true`, link schemas to handler args / component inputs, carry agent state types through DI, and read cleanly on hover — validated against the cockpit + canonical examples as forcing functions. + +**Architecture:** Five library workstreams in `@threadplane/chat`, `@threadplane/render`, `@threadplane/langgraph`, `@threadplane/ag-ui`, each gated by a hand-rolled `strict: true` type-test file. Then a four-tier example-validation pass: migrate + flip `strict: true` on the client-tools forcing-function apps, build-net across all `provideAgent`/`injectAgent` apps, live smoke, and the type-test gate in CI. + +**Tech Stack:** Angular 21 (signal inputs, `InputSignal`), TypeScript ≥ 5 (`const` type params), Nx, vitest, Standard Schema (vendored), Zod (`zod/v4`) in examples. + +**Reference spec:** `docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md`. Core type machinery for WS1 + WS2 was verified compiling under `strict: true` during design. + +**Conventions for the implementer:** +- No backwards-compatibility constraint. Flat named exports stay. +- Never name the reference framework in code/comments/commits (describe techniques generically). +- The render lib's vitest has no Angular template compiler; type-level behavior is validated by `*.type-spec.ts` files compiled with `tsc`, not by runtime tests. +- Type-test TDD loop: the "failing test" is a `tsc -p ` run that errors before the implementation lands and passes after. `@ts-expect-error` lines invert the assertion (an *unused* `@ts-expect-error` is itself a compile error, so they double as negative tests). +- Commit after each task. + +--- + +## File Structure + +**`libs/chat`** +- `src/lib/client-tools/tool-def.ts` — MODIFY: `FunctionToolDef`, new `AnyFunctionToolDef`, `ViewToolDef`, `AskToolDef`, `ClientToolDef` union. +- `src/lib/client-tools/tools.ts` — MODIFY: `action`, generic `tools`, generic `view`/`ask`, `ViewProps` re-export point. +- `src/lib/client-tools/component-inputs.ts` — NEW: `ComponentInputs`, `CompatibleProps`, `AcceptComponent`. +- `src/lib/agent/agent.ts` — MODIFY: `Agent>`. +- `src/lib/agent/agent-with-history.ts` — MODIFY: `AgentWithHistory>`. +- `src/lib/agent/agent-ref.ts` — NEW: `AgentRef`, `createAgentRef`. +- `src/lib/internals/prettify.ts` — NEW: `Prettify` (internal). +- `src/lib/client-tools/*.type-spec.ts`, `src/lib/agent/*.type-spec.ts` — NEW type-tests. +- `src/testing/type-assert.ts` — NEW: `Equal`, `Expect` (shared by type-specs; no vitest dep). +- `tsconfig.type-tests.json` — NEW: strict tsconfig for type-specs. +- `project.json` — MODIFY: add `type-tests` target. +- `src/public-api.ts` — MODIFY: re-exports + JSDoc-touched symbols. + +**`libs/langgraph`** +- `src/lib/agent.provider.ts`, `src/lib/inject-agent.ts`, `src/lib/agent.types.ts` — MODIFY: ref overloads; `LangGraphAgent` already generic. +- `src/lib/*.type-spec.ts`, `tsconfig.type-tests.json`, `project.json` — NEW type-test gate. + +**`libs/ag-ui`** +- `src/lib/to-agent.ts`, `src/lib/provide-agent.ts` — MODIFY: `AgUiAgent`, ref overloads. + +**Examples (forcing functions)** +- `cockpit/ag-ui/client-tools/angular/`, `cockpit/langgraph/client-tools/angular/`, `examples/ag-ui/angular/` — MODIFY: typed APIs + `strict: true`. +- `examples/chat/angular/`, one `cockpit/langgraph/*`, one `cockpit/chat/*` — MODIFY: `createAgentRef`. + +--- + +## Task 1: Type-test harness (WS5 foundation) + +**Files:** +- Create: `libs/chat/src/testing/type-assert.ts` +- Create: `libs/chat/tsconfig.type-tests.json` +- Create: `libs/chat/src/lib/client-tools/tools.type-spec.ts` (smoke) +- Modify: `libs/chat/project.json` + +- [ ] **Step 1: Create the type-assertion kit** + +`libs/chat/src/testing/type-assert.ts`: +```ts +// SPDX-License-Identifier: MIT +/** Compile-time assertion helpers for *.type-spec.ts files (no runtime, no vitest dep). */ + +/** Exact-type equality (invariant). `Equal` is `true` iff A and B are identical. */ +export type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +/** Passes only when `T` is exactly `true`; otherwise a compile error. */ +export type Expect = T; + +/** True if `A` is assignable to `B`. */ +export type Assignable = A extends B ? true : false; +``` + +- [ ] **Step 2: Create the strict type-tests tsconfig** + +`libs/chat/tsconfig.type-tests.json`: +```json +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "strictFunctionTypes": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src/**/*.type-spec.ts", "src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} +``` + +- [ ] **Step 3: Add a smoke type-spec that should pass immediately** + +`libs/chat/src/lib/client-tools/tools.type-spec.ts`: +```ts +// SPDX-License-Identifier: MIT +import type { Equal, Expect } from '../../testing/type-assert'; + +// Harness smoke — proves the type-test pipeline runs. +type _smoke = Expect>; +``` + +- [ ] **Step 4: Add the nx `type-tests` target** + +In `libs/chat/project.json`, add to `targets`: +```json +"type-tests": { + "executor": "nx:run-commands", + "options": { + "command": "tsc --noEmit -p libs/chat/tsconfig.type-tests.json" + } +} +``` + +- [ ] **Step 5: Run the gate — expect PASS (harness only)** + +Run: `npx nx type-tests chat` +Expected: exits 0 (only the smoke assertion present). + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/testing/type-assert.ts libs/chat/tsconfig.type-tests.json \ + libs/chat/src/lib/client-tools/tools.type-spec.ts libs/chat/project.json +git commit -m "test(chat): strict type-test harness (Equal/Expect + tsconfig + nx target)" +``` + +--- + +## Task 2: WS1 — variance-safe tool-def types + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/tool-def.ts` +- Modify: `libs/chat/src/lib/client-tools/tools.type-spec.ts` + +- [ ] **Step 1: Write the failing type-spec** + +Replace the body of `libs/chat/src/lib/client-tools/tools.type-spec.ts`: +```ts +// SPDX-License-Identifier: MIT +import type { Equal, Expect } from '../../testing/type-assert'; +import type { StandardSchemaV1 } from '@threadplane/render'; +import type { FunctionToolDef, ClientToolDef } from './tool-def'; +import { action, tools } from './tools'; +import { z } from 'zod/v4'; + +const moveSchema = z.object({ fromDay: z.number(), placeId: z.string() }); + +// action() infers handler arg from the schema output and carries the return type R. +const moveAction = action('Move a stop', moveSchema, (a) => a.fromDay + 1); +type _argInfer = Expect[0], { fromDay: number; placeId: string }>>; +type _retInfer = Expect>>; + +// A precise FunctionToolDef must be assignable into the bivariant union. +const _u: ClientToolDef = moveAction; + +// tools() preserves per-key tool types AND literal keys under strict. +const registry = tools({ + move_stop: moveAction, + note: action('Note', z.object({ text: z.string() }), (a) => a.text), +}); +type _keys = Expect>; +type _perKey = Expect>>; +``` + +- [ ] **Step 2: Run the gate — expect FAIL** + +Run: `npx nx type-tests chat` +Expected: FAIL — `tools()` currently returns `ClientToolRegistry` (widened), so `_keys`/`_perKey` error; `FunctionToolDef` has no `R` param so `_retInfer` errors. + +- [ ] **Step 3: Rewrite `tool-def.ts`** + +`libs/chat/src/lib/client-tools/tool-def.ts`: +```ts +// SPDX-License-Identifier: MIT +import type { Type } from '@angular/core'; +import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; + +/** Precise authored function tool — what `action()` returns. Carries the schema + * `S` and the handler's resolved return type `R`. */ +export interface FunctionToolDef { + readonly kind: 'function'; + readonly description: string; + readonly schema: S; + readonly handler: (args: StandardSchemaInferOutput) => R | Promise; +} + +/** Bivariant union member used only for registry storage/iteration. The handler + * param is `any` (NOT `never`): `any` is simultaneously a supertype any precise + * `FunctionToolDef` is assignable to under `strictFunctionTypes`, AND + * callable by internal code that has narrowed by `kind` and parsed runtime args. + * A `never` param would satisfy the former but break the latter. */ +export interface AnyFunctionToolDef { + readonly kind: 'function'; + readonly description: string; + readonly schema: StandardSchemaV1; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bivariance escape hatch; see note above + readonly handler: (args: any) => unknown | Promise; +} + +export interface ViewToolDef { + readonly kind: 'view'; + readonly description: string; + readonly schema: S; + readonly component: Type; +} + +export interface AskToolDef { + readonly kind: 'ask'; + readonly description: string; + readonly schema: S; + readonly component: Type; +} + +/** A client tool the model can call; executed in the browser. */ +export type ClientToolDef = + | AnyFunctionToolDef + | ViewToolDef + | AskToolDef; + +/** A frozen, name-keyed registry of client tools. */ +export type ClientToolRegistry = Readonly>; +``` + +- [ ] **Step 4: Run the gate — expect partial progress** + +Run: `npx nx type-tests chat` +Expected: still FAILS on `_keys`/`_perKey` (tools.ts not yet generic) but `_retInfer`/`_argInfer`/`_u` now depend on Task 3. Proceed to Task 3 before re-running. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/client-tools/tool-def.ts +git commit -m "feat(chat): variance-safe ClientToolDef (FunctionToolDef + bivariant AnyFunctionToolDef)" +``` + +--- + +## Task 3: WS1 — generic `action` + `tools` + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/tools.ts` + +- [ ] **Step 1: Rewrite `action` and `tools` (view/ask updated in Task 5)** + +In `libs/chat/src/lib/client-tools/tools.ts`, replace the `action` and `tools` definitions (leave `view`/`ask` for Task 5). Update the imports to add `ClientToolDef` and the `FunctionToolDef` generic: +```ts +// SPDX-License-Identifier: MIT +import type { Type } from '@angular/core'; +import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; +import type { ClientToolDef, FunctionToolDef } from './tool-def'; + +/** Async function tool — its resolved return value becomes the tool result. */ +export function action( + description: string, + schema: S, + handler: (args: StandardSchemaInferOutput) => R | Promise, +): FunctionToolDef { + return { kind: 'function', description, schema, handler }; +} + +// ... view()/ask() remain here for now (updated in Task 5) ... + +/** Collect named client tools into a frozen registry (the key is the tool name). + * Generic + `const` over the map so per-tool types and literal keys survive. */ +export function tools>(map: M): Readonly { + return Object.freeze({ ...map }); +} +``` + +- [ ] **Step 2: Run the gate — expect the WS1 assertions to PASS** + +Run: `npx nx type-tests chat` +Expected: PASS for all assertions in `tools.type-spec.ts` (`_argInfer`, `_retInfer`, `_u`, `_keys`, `_perKey`). + +- [ ] **Step 3: Run the chat unit suite + lint to catch internal call-site fallout** + +Run: `npx nx run-many -t test lint --projects=chat --skip-nx-cache` +Expected: PASS. The `any`-param member keeps `coordinator`/`executor` `def.handler(args)` calls valid. If any internal site that previously relied on `ClientToolRegistry` now sees a narrowed type, fix by reading through `ClientToolDef` (no behavior change). + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/client-tools/tools.ts +git commit -m "feat(chat): generic tools()/action() — strict-safe registry with per-key + literal-key types" +``` + +--- + +## Task 4: WS2 — component-input extraction types + +**Files:** +- Create: `libs/chat/src/lib/client-tools/component-inputs.ts` +- Create: `libs/chat/src/lib/client-tools/view-ask.type-spec.ts` + +- [ ] **Step 1: Create the type machinery** + +`libs/chat/src/lib/client-tools/component-inputs.ts`: +```ts +// SPDX-License-Identifier: MIT +import type { InputSignal, InputSignalWithTransform, Type } from '@angular/core'; +import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; + +/** Value type carried by an Angular signal input. */ +type InputValue

= + P extends InputSignal ? T : + P extends InputSignalWithTransform ? T : + never; + +/** A component instance's declared signal inputs, as a plain prop bag. */ +export type ComponentInputs = { + [K in keyof C as C[K] extends InputSignal | InputSignalWithTransform + ? K + : never]: InputValue; +}; + +/** STRICT: every prop the schema PRODUCES must be a declared input with an + * assignable type. FLEXIBLE: the component may declare extra inputs the schema + * doesn't fill. A schema key absent from `Inputs` maps to `never`, so its + * (non-never) value fails assignment and the error pins to that prop. */ +export type CompatibleProps = { + [K in keyof Out]: K extends keyof Inputs ? Inputs[K] : never; +}; + +/** The accepted `component` parameter type for `view`/`ask`: the real component + * `Type` when the schema output is compatible, else a labelled error tuple + * that surfaces both shapes in the compiler message. */ +export type AcceptComponent = + StandardSchemaInferOutput extends CompatibleProps, ComponentInputs> + ? Type + : readonly [ + 'Schema output is not assignable to this component’s inputs', + StandardSchemaInferOutput, + ComponentInputs, + ]; + +/** Reverse helper: derive a component's input prop types FROM a schema, so a + * component authored straight from the schema is guaranteed compatible. */ +export type ViewProps = StandardSchemaInferOutput; +``` + +- [ ] **Step 2: Write the failing type-spec (good + 3 bad + assignable-to-union)** + +`libs/chat/src/lib/client-tools/view-ask.type-spec.ts`: +```ts +// SPDX-License-Identifier: MIT +import { Component, input } from '@angular/core'; +import { z } from 'zod/v4'; +import type { ClientToolDef, ViewToolDef } from './tool-def'; +import { view, ask } from './tools'; +import { tools } from './tools'; + +@Component({ template: '' }) +class DayCardComponent { + day = input.required(); + places = input([]); // optional (default) + highlight = input(false); // extra input NOT in schema +} + +@Component({ template: '' }) +class UnrelatedComponent { + title = input.required(); +} + +const daySchema = z.object({ day: z.number(), places: z.array(z.string()) }); + +// ✅ good — schema output keys ⊆ inputs, compatible types; extra `highlight` allowed. +const dayView = view('Show a day', daySchema, DayCardComponent); + +// the view tool stays assignable to the registry union and through tools(). +const _u: ClientToolDef = dayView; +const _reg = tools({ day_card: view('Show a day', daySchema, DayCardComponent) }); + +// the result carries the component type. +const _carries: ViewToolDef = dayView; + +// ❌ typo prop the component can't receive. +const typoSchema = z.object({ dayz: z.number() }); +// @ts-expect-error `dayz` is not an input of DayCardComponent +const _bad1 = view('typo', typoSchema, DayCardComponent); + +// ❌ type mismatch (day: string vs input number). +const wrongType = z.object({ day: z.string() }); +// @ts-expect-error day: string not assignable to input day: number +const _bad2 = view('wrong type', wrongType, DayCardComponent); + +// ❌ unrelated component. +// @ts-expect-error schema output {day, places} has no matching inputs on UnrelatedComponent +const _bad3 = ask('unrelated', daySchema, UnrelatedComponent); +``` + +- [ ] **Step 3: Run the gate — expect FAIL** + +Run: `npx nx type-tests chat` +Expected: FAIL — `view`/`ask` are still non-generic (`component: Type`), so the good case has no linkage and the three `@ts-expect-error` lines are *unused* (which is itself a compile error). Implemented in Task 5. + +- [ ] **Step 4: Commit (machinery only; spec stays red until Task 5)** + +```bash +git add libs/chat/src/lib/client-tools/component-inputs.ts libs/chat/src/lib/client-tools/view-ask.type-spec.ts +git commit -m "feat(chat): ComponentInputs/AcceptComponent type machinery for view/ask linkage" +``` + +--- + +## Task 5: WS2 — generic `view` / `ask` + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/tools.ts` + +- [ ] **Step 1: Make `view`/`ask` generic + export `ViewProps`** + +In `libs/chat/src/lib/client-tools/tools.ts`, replace the `view` and `ask` functions and extend imports: +```ts +import type { ViewToolDef, AskToolDef } from './tool-def'; +import type { AcceptComponent } from './component-inputs'; +export type { ViewProps } from './component-inputs'; + +/** Render-only component tool — the model fills its props; auto-acknowledged. + * The component's signal inputs are checked against the schema's output type. */ +export function view( + description: string, + schema: S, + component: AcceptComponent, +): ViewToolDef { + return { kind: 'view', description, schema, component: component as Type }; +} + +/** Interactive (HITL) component tool — the value it emits becomes the result. + * The component's signal inputs are checked against the schema's output type. */ +export function ask( + description: string, + schema: S, + component: AcceptComponent, +): AskToolDef { + return { kind: 'ask', description, schema, component: component as Type }; +} +``` + +- [ ] **Step 2: Run the gate — expect PASS** + +Run: `npx nx type-tests chat` +Expected: PASS — good case compiles, `_u`/`_reg`/`_carries` hold, all three `@ts-expect-error` lines are now *used* (the bad cases genuinely error). + +- [ ] **Step 3: Run chat unit suite + lint** + +Run: `npx nx run-many -t test lint --projects=chat --skip-nx-cache` +Expected: PASS (the `as Type` cast keeps the runtime object shape identical; coordinator reads `component` unchanged). + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/client-tools/tools.ts +git commit -m "feat(chat): generic view()/ask() — strict-but-flexible schema<->component input linkage" +``` + +--- + +## Task 6: WS3 — genericize the neutral `Agent` contract + +**Files:** +- Modify: `libs/chat/src/lib/agent/agent.ts` +- Modify: `libs/chat/src/lib/agent/agent-with-history.ts` + +- [ ] **Step 1: Genericize `Agent`** + +In `libs/chat/src/lib/agent/agent.ts`, change the interface declaration and the `state` member: +```ts +export interface Agent> { + // ...unchanged members... + state: Signal; + // ...rest unchanged... +} +``` +(Only the `interface Agent` line and `state:` line change; everything else stays.) + +- [ ] **Step 2: Thread the param through `AgentWithHistory`** + +In `libs/chat/src/lib/agent/agent-with-history.ts`: +```ts +export interface AgentWithHistory> extends Agent { + history: Signal; + messageCheckpoints?: Signal>; +} +``` + +- [ ] **Step 3: Build chat + the adapters to confirm the default contains the ripple** + +Run: `npx nx run-many -t build --projects=chat,langgraph,ag-ui --skip-nx-cache` +Expected: PASS. The `= Record` default means every existing `Agent` / `AgentWithHistory` reference is unchanged. If `LangGraphAgent extends AgentWithHistory` now needs `AgentWithHistory` to surface typed `state`, that is done in Task 8 — a plain build here should still pass because `LangGraphAgent` defaulting keeps it valid. + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/agent/agent.ts libs/chat/src/lib/agent/agent-with-history.ts +git commit -m "feat(chat): genericize Agent + AgentWithHistory (default Record)" +``` + +--- + +## Task 7: WS3 — `createAgentRef` typed DI handle + +**Files:** +- Create: `libs/chat/src/lib/agent/agent-ref.ts` +- Modify: `libs/chat/src/lib/agent/index.ts` +- Create: `libs/chat/src/lib/agent/agent-ref.type-spec.ts` + +- [ ] **Step 1: Write the failing type-spec** + +`libs/chat/src/lib/agent/agent-ref.type-spec.ts`: +```ts +// SPDX-License-Identifier: MIT +import type { InjectionToken } from '@angular/core'; +import type { Equal, Expect } from '../../testing/type-assert'; +import type { Agent } from './agent'; +import { createAgentRef, type AgentRef } from './agent-ref'; + +interface TripState { day: number; places: string[]; } + +const trip = createAgentRef('trip'); +type _refTyped = Expect>>; +type _tokenTyped = Expect>>>; +``` + +- [ ] **Step 2: Run the gate — expect FAIL** + +Run: `npx nx type-tests chat` +Expected: FAIL — module `./agent-ref` does not exist. + +- [ ] **Step 3: Create `agent-ref.ts`** + +`libs/chat/src/lib/agent/agent-ref.ts`: +```ts +// SPDX-License-Identifier: MIT +import { InjectionToken } from '@angular/core'; +import type { Agent } from './agent'; + +/** A typed handle that threads a state shape through Angular DI from + * `provideAgent(ref, …)` to `injectAgent(ref)` without per-call-site + * restatement of the generic. */ +export interface AgentRef { + readonly token: InjectionToken>; +} + +/** + * Create a typed agent handle. + * + * @param debugName Optional name shown in Angular DI error messages. + * @returns An {@link AgentRef} carrying a state-typed `InjectionToken`. + * @example + * ```ts + * interface TripState { day: number; places: string[]; } + * export const TRIP = createAgentRef('trip'); + * // app.config.ts: provideAgent(TRIP, { assistantId: 'trip' }) + * // component: const agent = injectAgent(TRIP); // LangGraphAgent + * ``` + */ +export function createAgentRef(debugName?: string): AgentRef { + return { token: new InjectionToken>(debugName ?? 'ThreadplaneAgent') }; +} +``` + +- [ ] **Step 4: Export from the agent barrel** + +In `libs/chat/src/lib/agent/index.ts`, add: +```ts +export type { AgentRef } from './agent-ref'; +export { createAgentRef } from './agent-ref'; +``` + +- [ ] **Step 5: Run the gate — expect PASS** + +Run: `npx nx type-tests chat` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/agent/agent-ref.ts libs/chat/src/lib/agent/index.ts libs/chat/src/lib/agent/agent-ref.type-spec.ts +git commit -m "feat(chat): createAgentRef typed DI handle (AgentRef)" +``` + +--- + +## Task 8: WS3 — LangGraph `provideAgent`/`injectAgent` ref overloads + +**Files:** +- Modify: `libs/langgraph/src/lib/agent.types.ts` (LangGraphAgent extends `AgentWithHistory`) +- Modify: `libs/langgraph/src/lib/agent.provider.ts` +- Modify: `libs/langgraph/src/lib/inject-agent.ts` +- Create: `libs/langgraph/tsconfig.type-tests.json`, `libs/langgraph/src/lib/inject-agent.type-spec.ts` +- Modify: `libs/langgraph/project.json` + +- [ ] **Step 1: Set up the langgraph type-test gate** + +`libs/langgraph/tsconfig.type-tests.json`: +```json +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "strictFunctionTypes": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src/**/*.type-spec.ts", "src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} +``` +Add to `libs/langgraph/project.json` `targets`: +```json +"type-tests": { + "executor": "nx:run-commands", + "options": { + "command": "tsc --noEmit -p libs/langgraph/tsconfig.type-tests.json" + } +} +``` + +- [ ] **Step 2: Write the failing type-spec** + +`libs/langgraph/src/lib/inject-agent.type-spec.ts`: +```ts +// SPDX-License-Identifier: MIT +import type { Signal } from '@angular/core'; +import { createAgentRef } from '@threadplane/chat'; +import type { Equal, Expect } from '@threadplane/chat/testing'; +import { injectAgent } from './inject-agent'; +import type { LangGraphAgent } from './agent.types'; + +interface TripState { day: number; places: string[]; } +const TRIP = createAgentRef('trip'); + +declare function ctx(fn: () => T): T; + +// injectAgent(ref) is typed LangGraphAgent; state is Signal. +const typed = ctx(() => injectAgent(TRIP)); +type _agent = Expect>>; +type _state = Expect, TripState>>; +type _value = Expect, TripState>>; + +// no-arg form stays valid (default state). +const plain = ctx(() => injectAgent()); +type _plainState = Expect, Record>>; +``` +> Note: this references `@threadplane/chat/testing`. If `Equal`/`Expect` are not already re-exported there, import them via a relative path to the chat source `type-assert.ts` instead, or add the re-export in Task 10. Prefer adding the re-export in Task 10 and using the package path here. + +- [ ] **Step 3: Run the gate — expect FAIL** + +Run: `npx nx type-tests langgraph` +Expected: FAIL — `injectAgent(ref)` overload does not exist; `LangGraphAgent` `state` not yet typed off `T`. + +- [ ] **Step 4: Make `LangGraphAgent` surface typed state** + +In `libs/langgraph/src/lib/agent.types.ts`, change the declaration: +```ts +export interface LangGraphAgent + extends AgentWithHistory { +``` +(`AgentWithHistory` now flows `T` into the inherited `state: Signal`. Import `AgentWithHistory` from `@threadplane/chat` if not already.) + +- [ ] **Step 5: Add the ref overloads to `provideAgent`** + +In `libs/langgraph/src/lib/agent.provider.ts`, replace the single `provideAgent` signature with overloads (keep the existing body, route the token): +```ts +import type { AgentRef } from '@threadplane/chat'; + +export function provideAgent>( + ref: AgentRef, + configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; +export function provideAgent>( + configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; +export function provideAgent>( + refOrConfig: AgentRef | AgentConfig | (() => AgentConfig), + maybeConfig?: AgentConfig | (() => AgentConfig), +): Provider[] { + const ref = isAgentRef(refOrConfig) ? refOrConfig : undefined; + const configOrFactory = (ref ? maybeConfig : refOrConfig) as + | AgentConfig + | (() => AgentConfig); + + const resolveConfig = (): AgentConfig => + typeof configOrFactory === 'function' ? configOrFactory() : configOrFactory; + + const providers: Provider[] = [ + { provide: AGENT_CONFIG, useFactory: resolveConfig }, + { provide: AGENT, useFactory: agentFactory }, + ]; + // Also bind the typed ref token to the same singleton, when a ref is used. + if (ref) { + providers.push({ provide: ref.token, useExisting: AGENT }); + } + return providers; +} +``` +Extract the existing `useFactory` body into a named `agentFactory()` (same logic, currently inline) so both `AGENT` and the ref alias resolve one singleton. Add the guard: +```ts +function isAgentRef(x: unknown): x is AgentRef { + return typeof x === 'object' && x !== null && 'token' in x; +} +``` + +- [ ] **Step 6: Add the ref overload to `injectAgent`** + +`libs/langgraph/src/lib/inject-agent.ts`: +```ts +import { inject } from '@angular/core'; +import type { BagTemplate } from '@langchain/langgraph-sdk'; +import type { AgentRef } from '@threadplane/chat'; +import { AGENT } from './agent.provider'; +import type { LangGraphAgent } from './agent.types'; + +export function injectAgent(): LangGraphAgent; +export function injectAgent( + ref: AgentRef, +): LangGraphAgent; +export function injectAgent( + ref?: AgentRef, +): LangGraphAgent { + return inject(ref ? ref.token : AGENT) as LangGraphAgent; +} +``` +(The bare `injectAgent()` generic-only form is removed; callers either use the default or pass a ref.) + +- [ ] **Step 7: Run the gate + langgraph suite** + +Run: `npx nx type-tests langgraph && npx nx run-many -t test lint build --projects=langgraph --skip-nx-cache` +Expected: PASS. (If the type-spec import of `@threadplane/chat/testing` fails, complete Task 10's re-export first, then re-run.) + +- [ ] **Step 8: Commit** + +```bash +git add libs/langgraph/src/lib/agent.types.ts libs/langgraph/src/lib/agent.provider.ts \ + libs/langgraph/src/lib/inject-agent.ts libs/langgraph/tsconfig.type-tests.json \ + libs/langgraph/src/lib/inject-agent.type-spec.ts libs/langgraph/project.json +git commit -m "feat(langgraph): provideAgent/injectAgent AgentRef overloads — typed state through DI" +``` + +--- + +## Task 9: WS3 — AG-UI generic agent + ref overloads + +**Files:** +- Modify: `libs/ag-ui/src/lib/to-agent.ts` (`AgUiAgent`) +- Modify: `libs/ag-ui/src/lib/provide-agent.ts` + +- [ ] **Step 1: Genericize `AgUiAgent`** + +In `libs/ag-ui/src/lib/to-agent.ts`, change the `AgUiAgent` interface to extend the generic neutral contract: +```ts +export interface AgUiAgent> extends Agent { + // ...existing extensions (customEvents, clientTools, subagents) unchanged... +} +``` +`toAgent` keeps returning `AgUiAgent` (default state); no signature change needed beyond the interface. + +- [ ] **Step 2: Add ref overloads to `provideAgent` + `injectAgent`** + +In `libs/ag-ui/src/lib/provide-agent.ts`: +```ts +import type { AgentRef } from '@threadplane/chat'; + +export function provideAgent>( + ref: AgentRef, + configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; +export function provideAgent( + configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; +export function provideAgent>( + refOrConfig: AgentRef | AgentConfig | (() => AgentConfig), + maybeConfig?: AgentConfig | (() => AgentConfig), +): Provider[] { + const ref = isAgentRef(refOrConfig) ? refOrConfig : undefined; + const configOrFactory = (ref ? maybeConfig : refOrConfig) as + | AgentConfig + | (() => AgentConfig); + const providers: Provider[] = [ + { provide: AGENT, useFactory: () => buildAgUiAgent(configOrFactory) }, + ]; + if (ref) providers.push({ provide: ref.token, useExisting: AGENT }); + return providers; +} + +export function injectAgent(): AgUiAgent; +export function injectAgent(ref: AgentRef): AgUiAgent; +export function injectAgent(ref?: AgentRef): AgUiAgent { + return inject(ref ? ref.token : AGENT) as AgUiAgent; +} + +function isAgentRef(x: unknown): x is AgentRef { + return typeof x === 'object' && x !== null && 'token' in x; +} +``` +Extract the existing HttpAgent-construction `useFactory` body into a `buildAgUiAgent(configOrFactory)` helper (same logic). + +- [ ] **Step 3: Run ag-ui suite + build** + +Run: `npx nx run-many -t test lint build --projects=ag-ui --skip-nx-cache` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add libs/ag-ui/src/lib/to-agent.ts libs/ag-ui/src/lib/provide-agent.ts +git commit -m "feat(ag-ui): genericize AgUiAgent + provideAgent/injectAgent AgentRef overloads" +``` + +--- + +## Task 10: WS4 — Prettify hovers + re-exports + +**Files:** +- Create: `libs/chat/src/lib/internals/prettify.ts` +- Modify: `libs/chat/src/lib/client-tools/tool-def.ts` (wrap inferred surfaces) +- Modify: `libs/chat/src/public-api.ts` +- Modify: `libs/chat/src/testing/` barrel (re-export `Equal`/`Expect`) + +- [ ] **Step 1: Add the internal `Prettify` helper** + +`libs/chat/src/lib/internals/prettify.ts`: +```ts +// SPDX-License-Identifier: MIT +/** + * @internal + * Identity mapped type that flattens an object type so editor quick-info shows + * the expanded shape instead of a raw conditional/mapped-type expression. + */ +export type Prettify = { [K in keyof T]: T[K] } & {}; +``` + +- [ ] **Step 2: Apply `Prettify` to the inferred public surfaces** + +In `libs/chat/src/lib/client-tools/component-inputs.ts`, wrap `ViewProps`: +```ts +import type { Prettify } from '../internals/prettify'; +export type ViewProps = Prettify>; +``` +(Leave `tool-def.ts` interfaces as-is — interfaces already hover cleanly; `Prettify` is for inferred *type aliases* like `ViewProps` and `ToolArgs`.) + +- [ ] **Step 3: Add re-exports to the chat public API** + +In `libs/chat/src/public-api.ts`, in the client-tools block: +```ts +export { tools, action, view, ask } from './lib/client-tools/tools'; +export type { ViewProps } from './lib/client-tools/component-inputs'; +// Schema typing helpers (so consumers don't reach into @threadplane/render): +export type { + StandardSchemaV1, + StandardSchemaInferInput, + StandardSchemaInferOutput, +} from '@threadplane/render'; +/** Inferred argument type for a schema (alias of StandardSchemaInferOutput). */ +export type ToolArgs = + import('@threadplane/render').StandardSchemaInferOutput; +export type { + FunctionToolDef, AnyFunctionToolDef, ViewToolDef, AskToolDef, ClientToolDef, ClientToolRegistry, +} from './lib/client-tools/tool-def'; +``` +And ensure the agent block re-exports the ref (it flows from the `./lib/agent` barrel updated in Task 7 — confirm `AgentRef`/`createAgentRef` appear in `public-api.ts`'s `export … from './lib/agent'`; add explicitly if the barrel is enumerated): +```ts +export { createAgentRef } from './lib/agent'; +export type { AgentRef } from './lib/agent'; +``` + +- [ ] **Step 4: Re-export the type-assert kit from `@threadplane/chat/testing`** + +Find the testing secondary entry point (`libs/chat/src/testing.ts` or `libs/chat/testing/`); add: +```ts +export type { Equal, Expect, Assignable } from './testing/type-assert'; +``` +> If the testing entry point imports vitest at module level, keep `type-assert` import as a `export type` only (it has no runtime), so it stays bundle-safe. + +- [ ] **Step 5: Verify chat builds, type-tests pass, langgraph type-spec resolves the testing import** + +Run: `npx nx run-many -t build type-tests --projects=chat --skip-nx-cache && npx nx type-tests langgraph` +Expected: PASS, including the langgraph `inject-agent.type-spec.ts` import of `@threadplane/chat/testing`. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/internals/prettify.ts libs/chat/src/lib/client-tools/component-inputs.ts \ + libs/chat/src/public-api.ts libs/chat/src/testing* +git commit -m "feat(chat): Prettify hovers + re-export StandardSchema*/ToolArgs/AgentRef + testing type-assert" +``` + +--- + +## Task 11: WS4 — JSDoc with `@example` on hero exports + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/tools.ts` +- Modify: `libs/chat/src/lib/provide-chat.ts` +- Modify: `libs/render/src/lib/provide-render.ts`, `libs/render/src/lib/define-angular-registry.ts` +- (`createAgentRef`, `provideAgent`/`injectAgent` JSDoc already added in their tasks.) + +- [ ] **Step 1: Add `@param`/`@returns`/`@example` to `tools`/`action`/`view`/`ask`** + +Replace the terse one-line comments in `libs/chat/src/lib/client-tools/tools.ts`. Example for `action`: +```ts +/** + * Declare an async function tool the model can call; its resolved return value + * becomes the tool result shipped back to the model. + * + * @param description Natural-language description the model sees. + * @param schema Standard Schema (e.g. a Zod object) for the tool's arguments; + * the handler's argument type is inferred from it. + * @param handler Runs in the browser when the model calls the tool. + * @returns A {@link FunctionToolDef} for inclusion in {@link tools}. + * @example + * ```ts + * const move = action('Move a stop', z.object({ fromDay: z.number() }), (a) => a.fromDay); + * const registry = tools({ move_stop: move }); + * ``` + */ +``` +Add equivalents for `view`, `ask` (mention the schema↔component input check + `ViewProps`) and `tools` (mention literal-key/per-key preservation). Keep each accurate to the signatures from Tasks 3 & 5. + +- [ ] **Step 2: Add JSDoc to `provideChat`, `provideRender`, `defineAngularRegistry`** + +Add a `@param`/`@returns`/`@example` block to each (they currently have none). Example for `defineAngularRegistry` mentions the `getEntry` accessor. + +- [ ] **Step 3: Verify hovers compile (build the libs)** + +Run: `npx nx run-many -t build lint --projects=chat,render --skip-nx-cache` +Expected: PASS (JSDoc is comments; this confirms nothing else regressed). + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/client-tools/tools.ts libs/chat/src/lib/provide-chat.ts \ + libs/render/src/lib/provide-render.ts libs/render/src/lib/define-angular-registry.ts +git commit -m "docs(chat,render): @param/@returns/@example JSDoc on hero exports" +``` + +--- + +## Task 12: Tier-1 forcing function — `cockpit/ag-ui/client-tools` + +**Files:** +- Modify: `cockpit/ag-ui/client-tools/angular/tsconfig.json` (flip `strict`) +- Modify: `cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts` +- Modify: `cockpit/ag-ui/client-tools/angular/src/app/app.config.ts` +- Modify: the view/ask component(s) under that app (input types to match schemas) + +- [ ] **Step 1: Flip the app to strict** + +In `cockpit/ag-ui/client-tools/angular/tsconfig.json`, set `"strict": true` (remove `"strict": false`). Leave `angularCompilerOptions` as-is unless step 4 requires `strictTemplates`. + +- [ ] **Step 2: Build — observe the failures (the forcing function in action)** + +Run: `npx nx build cockpit-ag-ui-client-tools-angular` +Expected: FAIL with `strictFunctionTypes`/typing errors on the `tools({...})` registry and any untyped `view`/`ask` components — this is the bug surfacing exactly as a real consumer would see it. + +- [ ] **Step 3: Migrate call sites to the typed APIs** + +Update `client-tools.component.ts` so `tools({...})` composes `action`/`view`/`ask` results directly (the typed registry now compiles). For each `view`/`ask`, ensure the paired component's signal inputs match the schema output (use `ViewProps` to type a component input bag where helpful). Fix any genuine schema↔component mismatches the compiler now flags. + +- [ ] **Step 4: Resolve remaining strict fallout** + +Fix any unrelated `strict` errors in this app's own code (implicit `any`, null checks). If the fallout is disproportionate and unrelated to the typed APIs, fall back to adding only `"strictFunctionTypes": true` to this app's tsconfig (the exact flag that masked the bug) and note it in the commit body. + +- [ ] **Step 5: Build green** + +Run: `npx nx build cockpit-ag-ui-client-tools-angular` +Expected: PASS under strict. + +- [ ] **Step 6: Commit** + +```bash +git add cockpit/ag-ui/client-tools/angular +git commit -m "test(cockpit/ag-ui): client-tools app on strict:true + typed tools/view/ask (forcing function)" +``` + +--- + +## Task 13: Tier-1 forcing function — `cockpit/langgraph/client-tools` + +**Files:** +- Modify: `cockpit/langgraph/client-tools/angular/tsconfig.json` +- Modify: `cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts` +- Modify: `cockpit/langgraph/client-tools/angular/src/app/app.config.ts` (optionally `createAgentRef`) +- Modify: paired view/ask components + +- [ ] **Step 1: Flip to strict** + +Set `"strict": true` in `cockpit/langgraph/client-tools/angular/tsconfig.json`. + +- [ ] **Step 2: Build — observe failures** + +Run: `npx nx build cockpit-langgraph-client-tools-angular` +Expected: FAIL (same class of typed-registry/view-ask errors). + +- [ ] **Step 3: Migrate to typed APIs + a typed agent ref** + +Same migration as Task 12 for `tools/view/ask`. Additionally define a `createAgentRef()` for this app's state and use `provideAgent(ref, {...})` + `injectAgent(ref)` so the LangGraph typed-state path is exercised end to end. + +- [ ] **Step 4: Resolve remaining strict fallout (same fallback rule as Task 12 step 4).** + +- [ ] **Step 5: Build green** + +Run: `npx nx build cockpit-langgraph-client-tools-angular` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add cockpit/langgraph/client-tools/angular +git commit -m "test(cockpit/langgraph): client-tools app on strict:true + typed tools/view/ask + createAgentRef" +``` + +--- + +## Task 14: Tier-1 forcing function — `examples/ag-ui` (canonical itinerary) + +**Files:** +- Modify: `examples/ag-ui/angular/tsconfig.json` +- Modify: `examples/ag-ui/angular/src/app/client-tools.ts` +- Modify: `examples/ag-ui/angular/src/app/app.config.ts`, `shell/ag-ui-shell.component.ts` +- Modify: the itinerary view/ask components (e.g. `day_card`, `clear_day`) input types + +- [ ] **Step 1: Flip to strict** + +Set `"strict": true` in `examples/ag-ui/angular/tsconfig.json`. + +- [ ] **Step 2: Build — observe failures** + +Run: `npx nx build examples-ag-ui-angular` +Expected: FAIL (typed registry + view/ask linkage on the real itinerary tools). + +- [ ] **Step 3: Migrate the canonical demo** + +Update `client-tools.ts` to the typed registry; ensure `day_card`/`clear_day`/etc. components' signal inputs match their schemas (this is the demo that surfaced NG0950 — typed linkage now guards it at compile time). Use `createAgentRef` for the itinerary state and wire `provideAgent(ref, …)` / `injectAgent(ref)`. + +- [ ] **Step 4: Resolve remaining strict fallout (same fallback rule).** + +- [ ] **Step 5: Build green** + +Run: `npx nx build examples-ag-ui-angular` +Expected: PASS under strict. + +- [ ] **Step 6: Commit** + +```bash +git add examples/ag-ui/angular +git commit -m "test(examples/ag-ui): canonical itinerary on strict:true + typed client-tools + createAgentRef (forcing function)" +``` + +--- + +## Task 15: Tier-2 — build-net + representative `createAgentRef` migrations + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` (+ `app.config.ts`) +- Modify: one `cockpit/langgraph/*` app (e.g. `cockpit/langgraph/streaming/angular`) — `createAgentRef` +- Modify: one `cockpit/chat/*` app (e.g. `cockpit/chat/messages/angular`) — `createAgentRef` + +- [ ] **Step 1: Migrate `examples/chat` to a typed agent ref** + +In `examples/chat/angular`, define `createAgentRef()`, switch `provideAgent`/`injectAgent` to the ref form, and read typed `agent.state` somewhere to exercise the type. Keep the app's existing `strict` setting (Tier-2 proves non-breakage, not strict adoption). + +- [ ] **Step 2: Migrate one langgraph + one chat cockpit app likewise** (pick the simplest of each). + +- [ ] **Step 3: Build the migrated three** + +Run: `npx nx run-many -t build --projects=examples-chat-angular,cockpit-langgraph-streaming-angular,cockpit-chat-messages-angular --skip-nx-cache` +Expected: PASS. + +- [ ] **Step 4: Build-net — every affected app must still build green on existing settings** + +Run: `npx nx run-many -t build --projects=tag:scope:cockpit,examples-ag-ui-angular,examples-chat-angular --skip-nx-cache` +(If a `scope:cockpit` tag does not exist, enumerate the angular projects: `npx nx run-many -t build --all --skip-nx-cache` and confirm no regression vs. baseline.) +Expected: PASS across all `provideAgent`/`injectAgent` apps — proof the `Agent` default contains the genericization ripple. + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/angular cockpit/langgraph/streaming/angular cockpit/chat/messages/angular +git commit -m "test(examples,cockpit): representative createAgentRef migrations + build-net for Agent" +``` + +--- + +## Task 16: Tier-4 — full type-test + unit gate + +**Files:** none (verification + any straggler fixes) + +- [ ] **Step 1: Run both type-test gates** + +Run: `npx nx run-many -t type-tests --projects=chat,langgraph --skip-nx-cache` +Expected: PASS — all WS1/WS2/WS3 assertions and the `@ts-expect-error` negative tests hold under strict. + +- [ ] **Step 2: Run the full affected unit + lint + build suite** + +Run: `npx nx run-many -t test lint build --projects=chat,render,langgraph,ag-ui --skip-nx-cache` +Expected: PASS (0 lint errors; warnings pre-existing). + +- [ ] **Step 3: Commit any straggler fixes** (only if steps surfaced something). + +```bash +git commit -am "fix(dx): resolve straggler type/lint issues from the DX pass" || echo "nothing to commit" +``` + +--- + +## Task 17: Tier-3 — live-LLM smoke (manual gate) + +**Files:** none + +- [ ] **Step 1: Serve each forcing-function app with a real key and drive it** + +Per the live-LLM-smoke gate, for each of `cockpit/ag-ui/client-tools`, `cockpit/langgraph/client-tools`, `examples/ag-ui` (itinerary), and `examples/chat`: serve with a real API key, drive the tool flows in Chrome, and confirm zero console errors and that view/ask tool flows complete (type changes are erased at runtime, but the migrations touched real call sites). Free ports between runs; do not run e2e while a live serve holds the same ports. + +- [ ] **Step 2: Record the smoke result** in the PR description (which apps, what flows, console-clean confirmation). + +> This is a human/controller gate — do not mark complete on green unit/type tests alone. + +--- + +## Task 18: Final review + PR + +- [ ] **Step 1: Final whole-implementation code review** (subagent-driven-development's terminal reviewer): correctness of the variance formulation, the `view`/`ask` linkage (incl. the error-tuple branch), the DI ref overloads in both adapters, no internal-type leaks in the built `.d.ts`, JSDoc accuracy. + +- [ ] **Step 2: Push the branch and open the PR** + +```bash +git push -u origin claude/typescript-dx-pass +gh pr create --base main --title "feat: TypeScript DX pass — strict-safe tools/view/ask + typed agent DI" --body-file <(...) +``` +PR body summarizes the five workstreams, the four-tier example validation (incl. the strict-flipped forcing-function apps and the live-smoke result), and links the spec. + +- [ ] **Step 3: Enable auto-merge** (`gh pr merge --squash --auto`) and finish via `superpowers:finishing-a-development-branch`. + +--- + +## Self-Review (against the spec) + +**Spec coverage:** +- WS1 (tools/action strict fix + per-key/literal keys + return type) → Tasks 2, 3. ✓ +- WS2 (view/ask linkage + ViewProps) → Tasks 4, 5. ✓ +- WS3 (Agent + AgentWithHistory + createAgentRef + both adapters) → Tasks 6, 7, 8, 9. ✓ +- WS4 (Prettify, re-exports incl. StandardSchema*/ToolArgs/AgentRef, JSDoc) → Tasks 10, 11. ✓ +- WS5 (strict type-test gate) → Task 1 (harness) + per-WS type-specs + Task 16 (full gate). ✓ +- Tier 1 (migrate + strict on 3 apps) → Tasks 12, 13, 14. ✓ +- Tier 2 (build-net + representative refs) → Task 15. ✓ +- Tier 3 (live smoke) → Task 17. ✓ +- Tier 4 (type-spec gate) → Tasks 1/16. ✓ + +**Type consistency:** `FunctionToolDef`, `AnyFunctionToolDef`, `ViewToolDef`, `AskToolDef`, `ClientToolDef`, `AcceptComponent`, `ComponentInputs`, `ViewProps`, `ToolArgs`, `Agent`, `AgentWithHistory`, `AgentRef`, `createAgentRef`, `LangGraphAgent`, `AgUiAgent` — names used consistently across tasks. + +**Placeholder scan:** no TBD/TODO; every code step shows real code. The one soft spot (`@threadplane/chat/testing` re-export ordering) is explicitly sequenced: Task 10 adds the re-export; Task 8's note says complete Task 10 first if the import fails. Task 15 step 4 gives a concrete fallback if no `scope:cockpit` tag exists. diff --git a/docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md b/docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md new file mode 100644 index 000000000..4a90e5405 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md @@ -0,0 +1,256 @@ +# TypeScript DX Pass — `@threadplane/*` Public API Design + +**Status:** Approved (brainstorm) — ready for implementation plan +**Date:** 2026-06-18 +**Scope:** Developer-facing TypeScript surface of `@threadplane/chat`, `@threadplane/render`, `@threadplane/langgraph`, `@threadplane/ag-ui`. + +## Goal + +Make the hero primitives an app developer touches first — `tools/action/view/ask`, `provideAgent/injectAgent`, the render/registry providers — infer correctly under `strict: true`, link schemas to handler args / component inputs / tool results, carry types through Angular DI, and read cleanly on hover. The target bar is the authored-elsewhere reference framework whose TypeScript DX the project owner wants to carry over: precise inference from minimal annotations, readable quick-info, and consistent inline JSDoc guidance. + +No backwards-compatibility constraint (the packages are pre-1.0, `0.0.x`). Flat named exports are kept (no namespace wrapper). + +## Motivating findings (from the audit) + +An empirical probe compiled scratch consumer code against the real published types and read back the inferred types from `tsc`. The defects below ship to npm consumers exactly as written (the `.d.ts` rollup is otherwise clean — no internal-type leaks). + +1. **Critical — `tools()` rejects every typed tool under `strict: true`.** `tools(map: Record)` takes the non-generic union whose handler is `(args: unknown) => …`. Under `strictFunctionTypes`, a typed `action()` whose handler demands `MoveInput` is not assignable there, so `tools({ move: action(...) })` is a compile error. It has never surfaced because both `examples/ag-ui/angular` and `examples/chat/angular` compile with `strict: false` (which disables `strictFunctionTypes`), while a default Angular CLI app is `strict: true`. +2. **High — `tools()` widens keys + values.** Return type loses per-key tool types (all collapse to `ClientToolDef`) and literal keys (`'move' | 'show'` widen to `string`), so there is no autocomplete or per-tool typing off the registry. +3. **High — `view()` / `ask()` have zero schema↔component linkage.** Signature is `(description, schema: StandardSchemaV1, component: Type)`. Any component compiles regardless of whether it can receive the props the model fills from the schema. +4. **High — `provideAgent` does not propagate `T` to `injectAgent`.** DI erases the generic; a dev must restate `injectAgent()` at every call site, and omitting it silently yields `Record`. +5. **High — typed state lives only on `.value`, not `.state`.** The runtime-neutral `state` signal that chat primitives and most docs reference stays `Signal>` even when the LangGraph-specific `value` is typed. +6. **Medium — `action`'s handler return type is hardcoded `unknown`** (the resolved value becomes the tool result, but its type is discarded). +7. **Medium — `view`/`ask` schema param is non-generic `StandardSchemaV1`** (output type discarded at the boundary; feeds #3). +8. **Minor — `StandardSchemaV1` is not re-exported from `@threadplane/chat`**, though `action/view/ask` all take it; missing `@param`/`@returns`/`@example` JSDoc on several hero exports. + +## Architecture + +Five independently-testable workstreams. The shared neutral contract (`Agent`, the schema-infer helpers, the agent ref) lives in `@threadplane/chat` / `@threadplane/render`; the adapters (`langgraph`, `ag-ui`) consume it. + +### WS1 — `tools()` / `action()` inference + +Fixes findings #1, #2, #6. + +```ts +// libs/chat/src/lib/client-tools/tool-def.ts + +/** Precise authored type — what action() returns. Carries the schema (S) and + * the handler's resolved return type (R). */ +export interface FunctionToolDef { + readonly kind: 'function'; + readonly description: string; + readonly schema: S; + readonly handler: (args: StandardSchemaInferOutput) => R | Promise; +} + +/** Bivariant union member used only for storage/iteration. The handler param is + * `any` (NOT `never`): `any` is the one param type that is simultaneously + * (a) a supertype any precise `FunctionToolDef` is assignable to under + * `strictFunctionTypes`, and (b) callable by internal code that has already + * narrowed by `kind` and parsed runtime args. A `never` param satisfies (a) + * but breaks (b) — the coordinator/executor could no longer call `handler()`. */ +export interface AnyFunctionToolDef { + readonly kind: 'function'; + readonly description: string; + readonly schema: StandardSchemaV1; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bivariance escape hatch; see above + readonly handler: (args: any) => unknown | Promise; +} + +export type ClientToolDef = + | AnyFunctionToolDef + | ViewToolDef + | AskToolDef; +``` + +Verified under `strict: true`: a precise `FunctionToolDef` is assignable to `ClientToolDef`; `tools({...})` preserves per-key types and literal keys; and an internal consumer that does `if (def.kind === 'function') def.handler(parsedArgs)` type-checks. + +```ts +// libs/chat/src/lib/client-tools/tools.ts + +export function action( + description: string, + schema: S, + handler: (args: StandardSchemaInferOutput) => R | Promise, +): FunctionToolDef { + return { kind: 'function', description, schema, handler }; +} + +/** Collect named client tools into a frozen registry (the key is the tool name). + * Generic + const over the map so per-tool types and literal keys survive. */ +export function tools>(map: M): Readonly { + return Object.freeze({ ...map }); +} +``` + +Result: `tools({ move: action('…', moveSchema, h) })` compiles under `strict: true`; the returned registry's `move` key keeps its `FunctionToolDef` type and the key union is `'move'`. The `ClientToolRegistry` alias becomes `Readonly>` (unchanged shape), and `createClientToolsCoordinator`/`toClientToolSpecs` keep accepting `Record` — the bivariant member is what lets a typed registry pass that boundary too. + +### WS2 — `view()` / `ask()` schema↔component linkage (strict-but-flexible) + +Fixes findings #3, #7. **Verified compiling under `strict: true`** (probe: good case + extra inputs accepted; typo, type-mismatch, and unrelated-component cases rejected). + +```ts +// libs/chat/src/lib/client-tools/component-inputs.ts (new) +import type { InputSignal, InputSignalWithTransform, Type } from '@angular/core'; + +type InputValue

= + P extends InputSignal ? T : + P extends InputSignalWithTransform ? T : never; + +/** The component instance's declared signal inputs, as a plain prop bag. */ +export type ComponentInputs = { + [K in keyof C as C[K] extends InputSignal | InputSignalWithTransform ? K : never]: + InputValue; +}; + +/** STRICT: every prop the schema PRODUCES must be a real input with an assignable + * type. FLEXIBLE: the component may declare extra inputs the schema doesn't fill. + * A schema key absent from the inputs maps to `never`, so its (non-never) value + * fails assignment and the error pins to that prop. */ +export type CompatibleProps = { + [K in keyof Out]: K extends keyof Inputs ? Inputs[K] : never; +}; + +export type AcceptComponent = + StandardSchemaInferOutput extends CompatibleProps, ComponentInputs> + ? Type + : ['✗ schema output is not assignable to this component’s inputs', + StandardSchemaInferOutput, ComponentInputs]; +``` + +```ts +// libs/chat/src/lib/client-tools/tools.ts +export function view( + description: string, schema: S, component: AcceptComponent, +): ViewToolDef { return { kind: 'view', description, schema, component: component as Type }; } + +export function ask( + description: string, schema: S, component: AcceptComponent, +): AskToolDef { return { kind: 'ask', description, schema, component: component as Type }; } + +/** Reverse helper: derive component input types FROM a schema, so a component + * authored straight from the schema is guaranteed compatible. */ +export type ViewProps = StandardSchemaInferOutput; +``` + +`ViewToolDef` / `AskToolDef` carry both the schema and the component type. Their `component: Type` is assignable to the union members' `component: Type` (covariant construct return), so they remain assignable to `ClientToolDef` and `tools({...})` accepts them. + +**Known limitation (intentional):** "every *required* input must be covered by the schema" is not enforceable at compile time — Angular does not brand required inputs in the type system (`input.required()` and `input(default)` are both `InputSignal`). The shipped runtime schema-readiness gate (holds the fallback until streamed props validate) is the backstop for that case. Compile-time blocks structural mismatches; runtime blocks incomplete/missing props. + +### WS3 — agent typing through DI + +Fixes findings #4, #5. Highest-surface workstream. + +```ts +// libs/chat/src/lib/agent/agent.ts +export interface Agent> { + // …existing members… + readonly state: Signal; // was Signal> +} +``` + +The `= Record` default means every existing use of bare `Agent` is unchanged. + +```ts +// libs/chat/src/lib/agent/agent-ref.ts (new) +/** A typed handle that threads a state shape through Angular DI from + * provideAgent() to injectAgent() without per-call-site restatement. */ +export interface AgentRef { readonly token: InjectionToken>; } +export function createAgentRef(debugName?: string): AgentRef { + return { token: new InjectionToken>(debugName ?? 'ThreadplaneAgent') }; +} +``` + +Both adapters gain a ref-aware overload while keeping the no-arg form: + +```ts +// libs/langgraph + libs/ag-ui (analogous) +export function provideAgent(ref: AgentRef, config: AgentConfig | (() => AgentConfig)): Provider[]; +export function provideAgent(config: AgentConfig | (() => AgentConfig)): Provider[]; // default token + +export function injectAgent(): LangGraphAgent; // default state +export function injectAgent(ref: AgentRef): LangGraphAgent; // typed via ref +``` + +The bare generic `injectAgent()` (which never propagated) is removed. + +### WS4 — hovers + discoverability + +Fixes finding #8 and readability across the board. + +- Internal `Prettify = { [K in keyof T]: T[K] } & {}` applied to the public *inferred* types (`FunctionToolDef`, `ViewToolDef`/`AskToolDef`, `ViewProps`, the agent state surface) so quick-info shows the expanded object instead of raw conditional/mapped-type expressions. `Prettify` itself is `@internal`. +- Re-export from `@threadplane/chat`: `StandardSchemaV1`, `StandardSchemaInferInput`, `StandardSchemaInferOutput`, and a convenience alias `ToolArgs = StandardSchemaInferOutput` so a consumer can type a handler/arg without reaching into `@threadplane/render`. +- `@param` / `@returns` / `@example` JSDoc on every hero export: `tools`, `action`, `view`, `ask`, `provideChat`, `provideRender`, `defineAngularRegistry`, `injectRenderHost`, `provideAgent`/`injectAgent` (both adapters), `createAgentRef`, and the `views`/`withViews`/`overrideViews`/`withoutViews`/`toRenderRegistry` helpers. + +### WS5 — regression gate (the TDD spine) + +A hand-rolled type-assertion kit (no new dependency): + +```ts +// libs/chat/src/testing/type-assert.ts (or a shared internal util) +export type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +export type Expect = T; +``` + +`*.type-spec.ts` files compiled under a dedicated **`strict: true`** tsconfig per touched lib, run in CI via `tsc --noEmit -p tsconfig.type-tests.json`. They assert the public behavior: +- `tools({ x: action('…', s, h) })` compiles; the result's `x` key keeps `FunctionToolDef<…>`; the key union is the literal `'x'`. +- `action` handler arg is the schema output; the carried return type `R` flows. +- the three `view`/`ask` rejection cases hold (typo prop, type mismatch, unrelated component) via `// @ts-expect-error`; the good case (incl. extra inputs) compiles. +- `injectAgent(ref)` is `Agent`; `agent.state` is `Signal`. + +Whole existing unit suites stay green; one example app is built to confirm lib source still compiles. + +## Files touched + +- `libs/chat/src/lib/client-tools/tools.ts` — generic `tools`, `action`, `view`, `ask`, `ViewProps`. +- `libs/chat/src/lib/client-tools/tool-def.ts` — `AnyFunctionToolDef` (bivariant union member), `FunctionToolDef`, `ViewToolDef`, `AskToolDef`, the `ClientToolDef` union. +- `libs/chat/src/lib/client-tools/component-inputs.ts` *(new)* — `ComponentInputs`, `CompatibleProps`, `AcceptComponent`. +- `libs/chat/src/lib/agent/agent.ts` — `Agent>`. +- `libs/chat/src/lib/agent/agent-ref.ts` *(new)* — `AgentRef`, `createAgentRef`. +- `libs/chat/src/public-api.ts` — re-exports (`StandardSchema*`, `ToolArgs`, `ViewProps`, `AgentRef`, `createAgentRef`) + barrel updates. +- `libs/render/src/lib/standard-schema.ts` — confirm `StandardSchemaInfer{Input,Output}` are exported (already present); add `Prettify` internal if hosted here. +- `libs/langgraph/src/lib/agent.provider.ts`, `inject-agent.ts`, `public-api.ts` — ref overloads, `LangGraphAgent` already generic. +- `libs/ag-ui/src/lib/provide-agent.ts`, `to-agent.ts`, `public-api.ts` — genericize `AgUiAgent`, ref overloads. +- JSDoc across all the above hero exports. +- `*.type-spec.ts` + `tsconfig.type-tests.json` in `libs/chat`, `libs/langgraph` (+ project.json target wiring). +- **Tier-1 forcing-function apps flipped to `strict: true` and migrated to the typed APIs:** `cockpit/ag-ui/client-tools/angular`, `cockpit/langgraph/client-tools/angular`, `examples/ag-ui/angular` (tsconfig + the `client-tools*.ts` / `app.config.ts` call sites + the view/ask component input types). Fallout fixed in example code. +- **Tier-2 representative migrations** to `createAgentRef` typed state: `examples/chat/angular` + one `cockpit/langgraph/*` + one `cockpit/chat/*`. +- Docs updated to the new signatures (`tools` typed registry, `view/ask` generic, `createAgentRef` usage). + +## Testing strategy + +TDD via WS5: write the failing strict type-test, then make it pass. Each workstream's type-spec is its acceptance gate. Existing vitest suites (render, chat, langgraph, ag-ui) must stay green. + +### Validation via examples (forcing functions) — required, thorough + +The unit type-tests are necessary but not sufficient: the critical bug survived precisely because every example compiles with `strict: false`. The real, non-negotiable acceptance gate is that the **cockpit examples and the canonical `langgraph` + `ag-ui` examples**, compiled the way a real consumer compiles, exercise the new typed APIs and pass. Four tiers: + +**Tier 1 — typed-API migration + full `strict: true` (deep forcing functions).** Migrate and flip these apps to `strict: true`, fixing any fallout in the example code: +- `cockpit/ag-ui/client-tools/angular` +- `cockpit/langgraph/client-tools/angular` +- `examples/ag-ui/angular` (the canonical itinerary demo — uses `tools/view/ask` + `provideAgent`/`injectAgent`) + +Each is migrated to: a typed `tools({...})` registry; generic `view`/`ask` paired with components whose signal inputs match the schema output (WS2 linkage actively exercised, including at least one intentionally-correct non-trivial schema↔component pair); and `createAgentRef()` for typed agent state. Compiled under full `strict: true`, these reproduce exactly how an Angular CLI consumer builds — so if WS1/WS2/WS3 regress, these apps fail to compile. + +**Tier 2 — build-regression net (proves WS3 genericization is non-breaking).** All ~35 cockpit + canonical apps that call `provideAgent`/`injectAgent` must still build green on their existing settings (the `Agent>` default must keep them untouched). Run `nx run-many -t build` across every affected angular project. Additionally migrate `examples/chat/angular` and one representative `cockpit/langgraph/*` and `cockpit/chat/*` app to `createAgentRef` typed state as extra forcing functions. + +**Tier 3 — live smoke (runtime correctness).** Per the live-LLM-smoke gate, serve with a real key and drive: both client-tools apps (`cockpit/ag-ui/client-tools`, `cockpit/langgraph/client-tools`), the canonical `examples/ag-ui` itinerary, and `examples/chat`. Confirm the typed-API changes did not alter runtime behavior — tool flows complete, zero console errors. (Type changes are erased at runtime, but the migrations touch real call sites, so this guards against accidental behavioral edits.) + +**Tier 4 — type-test gate (WS5).** The hand-rolled `strict:true` type-spec files remain the fast unit-level guard that runs in CI on every change. + +A workstream is "done" only when its Tier-1 forcing-function apps compile under `strict: true`, the Tier-2 build net is green, and (for the client-tools/canonical apps) the Tier-3 live smoke passes. + +## Risks & decisions + +- **`Agent` genericization (WS3) is the highest-surface change.** The `= Record` default contains the ripple, but it touches the shared contract and both adapters. The Tier-2 build net (all ~35 `provideAgent`/`injectAgent` apps building green on their existing settings) is the explicit proof that the default truly contains it. Accepted as in-scope (comprehensive spec); WS3 is the natural split point if it later needs to be de-risked into its own PR. +- **Flipping Tier-1 apps to full `strict: true` may surface unrelated example-code issues** (implicit `any`, etc.) beyond the typed-API changes. These are fixed in the example code as part of the migration; if the fallout in any one app proves disproportionate, fall back to enabling `strictFunctionTypes: true` alone (the exact flag that masked the bug) for that app and note it. +- **`const` type parameter on `tools()`** requires TypeScript ≥ 5.0; the repo is well past that. +- **The `view`/`ask` error-tuple constraint** must keep `ViewToolDef`/`AskToolDef` assignable to `ClientToolDef` so `tools({...})` still accepts views/asks — covered by a WS5 type-test. + +## Explicitly NOT in scope + +- A namespace/`s.*`-style export wrapper (decided: keep flat named exports). +- Enforcing required-input coverage at compile time for `view`/`ask` (not expressible; runtime gate covers it). +- `@threadplane/a2ui` JSDoc and the broader cockpit/internal packages (not developer-facing hero APIs). +- The separate error-UX and thread-persistence follow-ups. diff --git a/examples/ag-ui/angular/src/app/app.config.ts b/examples/ag-ui/angular/src/app/app.config.ts index 06f50cff1..9175382e9 100644 --- a/examples/ag-ui/angular/src/app/app.config.ts +++ b/examples/ag-ui/angular/src/app/app.config.ts @@ -11,6 +11,7 @@ import { provideAgent } from '@threadplane/ag-ui'; import { environment } from '../environments/environment'; import { routes } from './app.routes'; import { ItineraryStore } from './itinerary-store'; +import { ITINERARY_AGENT } from './client-tools'; export const appConfig: ApplicationConfig = { providers: [ @@ -29,7 +30,10 @@ export const appConfig: ApplicationConfig = { // injectThreadRouting (see examples/chat); for AG-UI to adopt it the server // would need a persistent store (e.g. SqliteSaver / PostgresSaver) so // reloading a prior threadId actually returns prior history. - provideAgent({ url: environment.agentUrl }), + // + // Typed agent provider: flows ItineraryState through DI so every + // injectAgent(ITINERARY_AGENT) call returns AgUiAgent. + provideAgent(ITINERARY_AGENT, { url: environment.agentUrl }), provideChat({ license: environment.license }), // The frontend-owned itinerary is a single shared instance: the panel, // the App component, and the client-tool ask component all inject it, so diff --git a/examples/ag-ui/angular/src/app/client-tools.ts b/examples/ag-ui/angular/src/app/client-tools.ts index 1d553d32e..daddf8e56 100644 --- a/examples/ag-ui/angular/src/app/client-tools.ts +++ b/examples/ag-ui/angular/src/app/client-tools.ts @@ -1,11 +1,35 @@ // SPDX-License-Identifier: MIT import { inject } from '@angular/core'; -import { tools, action, view, ask, type ClientToolRegistry } from '@threadplane/chat'; +import { tools, action, view, ask, type ClientToolRegistry, createAgentRef } from '@threadplane/chat'; import { z } from 'zod/v4'; import { ItineraryStore } from './itinerary-store'; -import { DayCardComponent } from './day-card.component'; +import { DayCardComponent, DAY_CARD_SCHEMA } from './day-card.component'; import { ClearDayConfirmComponent } from './clear-day-confirm.component'; +/** + * Shape of the state the itinerary agent reads from `RunAgentInput.state`. + * The Python graph's `State` TypedDict defines these three optional keys; + * the Angular shell writes them into every `submit()` call so the backend + * can pick up the user's palette choices. + */ +export interface ItineraryState { + model?: string; + reasoning_effort?: string; + gen_ui_mode?: string; +} + +/** + * Typed DI handle for the itinerary AG-UI agent. + * Pass to `provideAgent(ITINERARY_AGENT, config)` in app.config and + * `injectAgent(ITINERARY_AGENT)` at the inject site for a typed + * `AgUiAgent` without repeating the generic everywhere. + */ +export const ITINERARY_AGENT = createAgentRef('ItineraryAgent'); + +/** Schema for the `clear_day` ask tool — exported in case consumers want to + * derive types from it (e.g. `ViewProps`). */ +export const CLEAR_DAY_SCHEMA = z.object({ day: z.number().int().min(1) }); + /** Client tools over the frontend-owned itinerary. Call inside an injection * context (e.g. a field initializer in App). The descriptions are the ONLY * steering the model gets — no system-prompt coaching (by design). */ @@ -34,12 +58,12 @@ export function itineraryClientTools(): ClientToolRegistry { ), clear_day: ask( 'Ask the user to confirm clearing every stop on a day, then clear it if they accept.', - z.object({ day: z.number().int().min(1) }), + CLEAR_DAY_SCHEMA, ClearDayConfirmComponent, ), day_card: view( "Show the user a visual recap card for one itinerary day. Call it after add_stop or move_stop with the day's full updated place list.", - z.object({ day: z.number().int().min(1), places: z.array(z.string()) }), + DAY_CARD_SCHEMA, DayCardComponent, ), }); diff --git a/examples/ag-ui/angular/src/app/day-card.component.ts b/examples/ag-ui/angular/src/app/day-card.component.ts index d38d1ef32..43ec6343b 100644 --- a/examples/ag-ui/angular/src/app/day-card.component.ts +++ b/examples/ag-ui/angular/src/app/day-card.component.ts @@ -1,5 +1,21 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import type { ViewProps } from '@threadplane/chat'; +import { z } from 'zod/v4'; + +/** + * Schema for the `day_card` view tool — co-located with the component so the + * inputs and the schema shape can be kept in sync at a glance. + * `client-tools.ts` imports this schema to pass to `view(…, DAY_CARD_SCHEMA, …)`. + */ +export const DAY_CARD_SCHEMA = z.object({ + day: z.number().int().min(1), + places: z.array(z.string()), +}); + +/** Input types derived directly from the `day_card` schema — guarantees this + * component stays compatible with the view() check at compile time. */ +type Inputs = ViewProps; /** * A frontend-owned view rendered for the `day_card` client tool. The model @@ -51,6 +67,6 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'; ], }) export class DayCardComponent { - readonly day = input.required(); - readonly places = input([]); + readonly day = input.required(); + readonly places = input([]); } diff --git a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts index b786604aa..a308fa702 100644 --- a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts +++ b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts @@ -16,7 +16,7 @@ import { } from '@threadplane/chat'; import { PalettePersistence } from './palette-persistence.service'; import { ItineraryPanelComponent } from '../itinerary-panel.component'; -import { itineraryClientTools } from '../client-tools'; +import { itineraryClientTools, ITINERARY_AGENT } from '../client-tools'; export type DemoMode = 'embed' | 'popup' | 'sidebar'; const MODES: readonly DemoMode[] = ['embed', 'popup', 'sidebar'] as const; @@ -104,8 +104,11 @@ export class AgUiShell { readonly clientTools = itineraryClientTools(); // ── Shared agent: submit wrapper merges the knobs into input.state ────── + // injectAgent(ITINERARY_AGENT) returns AgUiAgent, so + // a.submit's input type carries { state?: ItineraryState } — no cast needed + // to spread the palette knobs into state. readonly agent = (() => { - const a = injectAgent(); + const a = injectAgent(ITINERARY_AGENT); const orig = a.submit.bind(a); (a as { submit: typeof a.submit }).submit = (async ( input: Parameters[0], @@ -115,7 +118,7 @@ export class AgUiShell { { ...(input ?? {}), state: { - ...((input as { state?: Record })?.state ?? {}), + ...(input?.state ?? {}), model: this.model(), reasoning_effort: this.effort(), gen_ui_mode: this.genUiMode(), diff --git a/examples/ag-ui/angular/tsconfig.json b/examples/ag-ui/angular/tsconfig.json index 1139eb967..185f669ec 100644 --- a/examples/ag-ui/angular/tsconfig.json +++ b/examples/ag-ui/angular/tsconfig.json @@ -8,7 +8,7 @@ "composite": false, "lib": ["es2022", "dom"], "skipLibCheck": true, - "strict": false + "strict": true }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, diff --git a/examples/chat/angular/src/app/shell/agent-ref.ts b/examples/chat/angular/src/app/shell/agent-ref.ts new file mode 100644 index 000000000..023af1b09 --- /dev/null +++ b/examples/chat/angular/src/app/shell/agent-ref.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +import { createAgentRef } from '@threadplane/chat'; + +/** + * State shape for the canonical chat demo agent. + * Mirrors the Python graph's `State` TypedDict in + * `examples/chat/python/src/graph.py`. + */ +export interface DemoState { + messages: unknown[]; + model: string | null; + reasoning_effort: string | null; + gen_ui_mode: string | null; +} + +/** + * Typed DI handle for the canonical demo agent. + * Wire with `provideAgent(DEMO_AGENT_REF, () => ({ ... }))` and inject with + * `injectAgent(DEMO_AGENT_REF)` to get `LangGraphAgent`. + */ +export const DEMO_AGENT_REF = createAgentRef('demo'); diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.ts b/examples/chat/angular/src/app/shell/demo-shell.component.ts index 46e667f84..a898d9cd0 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -14,6 +14,7 @@ import { Router, RouterOutlet, NavigationEnd } from '@angular/router'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { filter, map, startWith } from 'rxjs/operators'; import { injectAgent, provideAgent, LangGraphThreadsAdapter, refreshOnRunEnd } from '@threadplane/langgraph'; +import { DEMO_AGENT_REF, type DemoState } from './agent-ref'; import { ThreadplaneTelemetryService } from '@threadplane/telemetry/browser'; import { ChatInterruptPanelComponent, @@ -86,7 +87,10 @@ function parseUrl(url: string): { mode: DemoMode; threadId: string | null } { // model selection — all per-instance. The telemetry sink delegates to the // shell-built sink (populated in the constructor) since the real sink needs // the injected telemetry service + live model() read. - provideAgent({ + // Typed via DEMO_AGENT_REF so injectAgent(DEMO_AGENT_REF) returns a + // LangGraphAgent. Static config: client retry options are + // single-sourced in the adapter, so no lazy factory is needed here. + provideAgent(DEMO_AGENT_REF, { apiUrl: environment.langGraphApiUrl, assistantId: environment.assistantId, threadId: threadIdState, @@ -398,7 +402,7 @@ export class DemoShell { this.telemetry, () => this.model(), ); - const a = injectAgent(); + const a = injectAgent(DEMO_AGENT_REF); void this.telemetry.capture('ngaf:browser_chat_init', { surface: TELEMETRY_SURFACE }); const orig = a.submit.bind(a); (a as { submit: typeof a.submit }).submit = (async ( @@ -421,6 +425,9 @@ export class DemoShell { return a; })(); + /** Typed read: proves DemoState flows through DI. Not used at runtime. */ + protected readonly _demoState: DemoState = this.agent.value(); + protected onModeChange(next: DemoMode | string): void { // Preserve the active thread across mode switches: /embed/abc → // /popup/abc keeps the conversation visible in the new chrome. diff --git a/libs/ag-ui/project.json b/libs/ag-ui/project.json index ee184acd8..c000bb579 100644 --- a/libs/ag-ui/project.json +++ b/libs/ag-ui/project.json @@ -47,6 +47,12 @@ "options": { "configFile": "libs/ag-ui/vite.config.mts" } + }, + "type-tests": { + "executor": "nx:run-commands", + "options": { + "command": "npx tsc --noEmit -p libs/ag-ui/tsconfig.type-tests.json" + } } } } diff --git a/libs/ag-ui/src/lib/provide-agent.ts b/libs/ag-ui/src/lib/provide-agent.ts index c55da9008..f9ffcd5ed 100644 --- a/libs/ag-ui/src/lib/provide-agent.ts +++ b/libs/ag-ui/src/lib/provide-agent.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import { InjectionToken, inject, type Provider } from '@angular/core'; import { HttpAgent } from '@ag-ui/client'; -import type { AgentRuntimeTelemetrySink } from '@threadplane/chat'; +import type { AgentRef, AgentRuntimeTelemetrySink } from '@threadplane/chat'; import { toAgent, type AgUiAgent } from './to-agent'; /** @@ -28,6 +28,25 @@ export interface AgentConfig { */ export const AGENT = new InjectionToken('AGENT'); +/** @internal — shared factory for building an AgUiAgent from an AgentConfig or factory. */ +function buildAgUiAgent(configOrFactory: AgentConfig | (() => AgentConfig)): AgUiAgent { + // useFactory runs in an injection context, so a config factory may + // call inject() to read runtime/DI state. + const config = + typeof configOrFactory === 'function' ? configOrFactory() : configOrFactory; + const source = new HttpAgent({ + url: config.url, + ...(config.agentId !== undefined ? { agentId: config.agentId } : {}), + ...(config.threadId !== undefined ? { threadId: config.threadId } : {}), + ...(config.headers !== undefined ? { headers: config.headers } : {}), + }); + return toAgent(source, { telemetry: config.telemetry }); +} + +function isAgentRef(x: unknown): x is AgentRef { + return typeof x === 'object' && x !== null && 'token' in x; +} + /** * Provides an Agent instance wired through HttpAgent and toAgent. * Constructs an HttpAgent from config and wraps it in the runtime-neutral @@ -38,28 +57,38 @@ export const AGENT = new InjectionToken('AGENT'); * config is known up front. Pass a `() => AgentConfig` factory when the config * depends on runtime/DI state — the factory runs inside an Angular injection * context, so it may call `inject()` to read services or route params. + * + * **Typed state via AgentRef.** Pass a typed ref as the first argument to flow + * the state shape from `provideAgent` to `injectAgent` without repeating the + * generic at every call site: + * + * ```ts + * interface TripState { day: number; places: string[]; } + * export const TRIP = createAgentRef('trip'); + * // app.config.ts: + * providers: [provideAgent(TRIP, { url: 'http://localhost:8000/agent' })] + * // component: + * const agent = injectAgent(TRIP); // AgUiAgent + * ``` */ +export function provideAgent>( + ref: AgentRef, + configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; export function provideAgent( configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; +export function provideAgent>( + refOrConfig: AgentRef | AgentConfig | (() => AgentConfig), + maybeConfig?: AgentConfig | (() => AgentConfig), ): Provider[] { - return [ - { - provide: AGENT, - useFactory: () => { - // useFactory runs in an injection context, so a config factory may - // call inject() to read runtime/DI state. - const config = - typeof configOrFactory === 'function' ? configOrFactory() : configOrFactory; - const source = new HttpAgent({ - url: config.url, - ...(config.agentId !== undefined ? { agentId: config.agentId } : {}), - ...(config.threadId !== undefined ? { threadId: config.threadId } : {}), - ...(config.headers !== undefined ? { headers: config.headers } : {}), - }); - return toAgent(source, { telemetry: config.telemetry }); - }, - }, + const ref = isAgentRef(refOrConfig) ? refOrConfig : undefined; + const configOrFactory = (ref ? maybeConfig : refOrConfig) as AgentConfig | (() => AgentConfig); + const providers: Provider[] = [ + { provide: AGENT, useFactory: () => buildAgUiAgent(configOrFactory) }, ]; + if (ref) providers.push({ provide: ref.token, useExisting: AGENT }); + return providers; } /** @@ -70,7 +99,19 @@ export function provideAgent( * Returns an `AgUiAgent` — the runtime-neutral `Agent` contract plus the * AG-UI-specific `customEvents` signal — so `customEvents` is reachable * directly, without casting. + * + * **Typed state via AgentRef.** Pass the same ref that was supplied to + * `provideAgent(ref, …)` to carry the state type through DI without repeating + * the generic at every call site: + * + * ```ts + * const agent = injectAgent(TRIP); // AgUiAgent + * ``` + * + * The no-arg form defaults to `AgUiAgent>`. */ -export function injectAgent(): AgUiAgent { - return inject(AGENT); +export function injectAgent(): AgUiAgent; +export function injectAgent(ref: AgentRef): AgUiAgent; +export function injectAgent(ref?: AgentRef): AgUiAgent { + return inject(ref ? ref.token : AGENT) as AgUiAgent; } diff --git a/libs/ag-ui/src/lib/provide-agent.type-spec.ts b/libs/ag-ui/src/lib/provide-agent.type-spec.ts new file mode 100644 index 000000000..6d89e3e75 --- /dev/null +++ b/libs/ag-ui/src/lib/provide-agent.type-spec.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +import { createAgentRef } from '@threadplane/chat'; +import type { Equal, Expect } from '../testing/type-assert'; +import { injectAgent } from './provide-agent'; +import type { AgUiAgent } from './to-agent'; + +interface TripState { day: number; places: string[]; } +const TRIP = createAgentRef('trip'); +declare function ctx(fn: () => T): T; + +const typed = ctx(() => injectAgent(TRIP)); +type _state = Expect, TripState>>; +type _isAgUi = Expect>>; + +const plain = ctx(() => injectAgent()); +type _plainState = Expect, Record>>; diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index 8c39d79d5..824e8c3d6 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -54,7 +54,7 @@ function agentRuntimeTelemetryErrorClass(error: unknown): string { * live a2ui streaming) and the optional `clientTools` capability. * Mirrors langgraph's LangGraphAgent extension. */ -export interface AgUiAgent extends Agent { +export interface AgUiAgent> extends Agent { customEvents: Signal; clientTools: ClientToolsCapability; /** Subagent activities (activityType==='subagent') projected to the neutral diff --git a/libs/ag-ui/src/testing/type-assert.ts b/libs/ag-ui/src/testing/type-assert.ts new file mode 100644 index 000000000..63a48b0bb --- /dev/null +++ b/libs/ag-ui/src/testing/type-assert.ts @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MIT +export type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +export type Expect = T; diff --git a/libs/ag-ui/tsconfig.type-tests.json b/libs/ag-ui/tsconfig.type-tests.json new file mode 100644 index 000000000..47e1cd415 --- /dev/null +++ b/libs/ag-ui/tsconfig.type-tests.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "strictFunctionTypes": true, + "skipLibCheck": true, + "types": [], + "inlineSources": false, + "declaration": false, + "declarationMap": false, + "noUnusedLocals": false + }, + "files": [], + "include": ["src/**/*.type-spec.ts"], + "exclude": ["src/**/*.spec.ts"], + "references": [] +} diff --git a/libs/chat/src/lib/agent/agent-ref.ts b/libs/chat/src/lib/agent/agent-ref.ts new file mode 100644 index 000000000..ae7111a8b --- /dev/null +++ b/libs/chat/src/lib/agent/agent-ref.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +import { InjectionToken } from '@angular/core'; +import type { Agent } from './agent'; + +/** A typed handle that threads a state shape through Angular DI from + * `provideAgent(ref, …)` to `injectAgent(ref)` without per-call-site + * restatement of the generic. */ +export interface AgentRef { + readonly token: InjectionToken>; +} + +/** + * Create a typed agent handle. + * + * @param debugName Optional name shown in Angular DI error messages. + * @returns An {@link AgentRef} carrying a state-typed `InjectionToken`. + * @example + * ```ts + * interface TripState { day: number; places: string[]; } + * export const TRIP = createAgentRef('trip'); + * // app.config.ts: provideAgent(TRIP, { assistantId: 'trip' }) + * // component: const agent = injectAgent(TRIP); // LangGraphAgent + * ``` + */ +export function createAgentRef(debugName?: string): AgentRef { + return { token: new InjectionToken>(debugName ?? 'ThreadplaneAgent') }; +} diff --git a/libs/chat/src/lib/agent/agent-ref.type-spec.ts b/libs/chat/src/lib/agent/agent-ref.type-spec.ts new file mode 100644 index 000000000..89865893d --- /dev/null +++ b/libs/chat/src/lib/agent/agent-ref.type-spec.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +import type { InjectionToken } from '@angular/core'; +import type { Equal, Expect } from '../../testing/type-assert'; +import type { Agent } from './agent'; +import { createAgentRef, type AgentRef } from './agent-ref'; + +interface TripState { day: number; places: string[]; } + +const trip = createAgentRef('trip'); +type _refTyped = Expect>>; +type _tokenTyped = Expect>>>; + +// default state when no param. +type _default = Expect>>>; diff --git a/libs/chat/src/lib/agent/agent-with-history.ts b/libs/chat/src/lib/agent/agent-with-history.ts index 0fc764afd..be44d7a15 100644 --- a/libs/chat/src/lib/agent/agent-with-history.ts +++ b/libs/chat/src/lib/agent/agent-with-history.ts @@ -10,7 +10,7 @@ import type { AgentCheckpoint } from './agent-checkpoint'; * implement this. Pure request/response runtimes that don't have checkpoints * should implement plain Agent. */ -export interface AgentWithHistory extends Agent { +export interface AgentWithHistory> extends Agent { history: Signal; /** * Optional reactive map of `messageId → checkpointId`, computed by diff --git a/libs/chat/src/lib/agent/agent.ts b/libs/chat/src/lib/agent/agent.ts index d57402ccc..af7e8033e 100644 --- a/libs/chat/src/lib/agent/agent.ts +++ b/libs/chat/src/lib/agent/agent.ts @@ -24,14 +24,14 @@ import type { AgentError } from './agent-error'; * Invariant: state lives on signals; `events$` carries only things that are * not derivable from signals. */ -export interface Agent { +export interface Agent> { // Core state messages: Signal; status: Signal; isLoading: Signal; error: Signal; toolCalls: Signal; - state: Signal>; + state: Signal; // Actions submit: (input: AgentSubmitInput, opts?: AgentSubmitOptions) => Promise; diff --git a/libs/chat/src/lib/agent/index.ts b/libs/chat/src/lib/agent/index.ts index 9dfa924dc..627abd694 100644 --- a/libs/chat/src/lib/agent/index.ts +++ b/libs/chat/src/lib/agent/index.ts @@ -19,6 +19,8 @@ export type { } from './agent-event'; export type { AgentCheckpoint } from './agent-checkpoint'; export type { AgentWithHistory } from './agent-with-history'; +export type { AgentRef } from './agent-ref'; +export { createAgentRef } from './agent-ref'; export type { AgentRuntimeTelemetryEvent, AgentRuntimeTelemetryPayload, diff --git a/libs/chat/src/lib/client-tools/component-inputs.ts b/libs/chat/src/lib/client-tools/component-inputs.ts new file mode 100644 index 000000000..e7920b37e --- /dev/null +++ b/libs/chat/src/lib/client-tools/component-inputs.ts @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +import type { InputSignal, InputSignalWithTransform, Type } from '@angular/core'; +import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; +import type { Prettify } from '../internals/prettify'; + +/** Value type carried by an Angular signal input. */ +type InputValue

= + P extends InputSignal ? T : + P extends InputSignalWithTransform ? T : + never; + +/** A component instance's declared signal inputs, as a plain prop bag. + * + * Implementation note: Angular's `InputSignal` and `InputSignalWithTransform` + * use `InputSignalNode` in an invariant position, making them invariant in their + * type parameters under TypeScript's structural system. `InputSignal` + * does NOT extend `InputSignal`. We therefore use `any` in the filter + * predicate — `any` is a two-way assignability wildcard that correctly subsumes + * all concrete instantiations without widening the extracted value type. */ +export type ComponentInputs = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- required; see note above + [K in keyof C as C[K] extends InputSignal | InputSignalWithTransform + ? K + : never]: InputValue; +}; + +/** STRICT: every prop the schema PRODUCES must be a declared input with an + * assignable type. FLEXIBLE: the component may declare extra inputs the schema + * doesn't fill. A schema key absent from `Inputs` maps to `never`, so its + * (non-never) value fails assignment and the error pins to that prop. + * + * This mapped type is homomorphic over `keyof Out`, so it preserves the + * optionality (`?`) of each schema prop. A consequence: an OPTIONAL schema prop + * is accepted against a `required` component input — the compiler cannot know + * the model will actually supply it. That residual case (required input not + * guaranteed by the schema) is caught at runtime by the schema-readiness mount + * gate, which holds the fallback until the streamed props validate. Compile + * time blocks structural mismatches; runtime blocks missing-but-required props. */ +export type CompatibleProps = { + [K in keyof Out]: K extends keyof Inputs ? Inputs[K] : never; +}; + +/** The accepted `component` parameter type for `view`/`ask`: the real component + * `Type` when the schema output is compatible, else a labelled error tuple + * that surfaces both shapes in the compiler message. + * + * Implementation note: `C extends ...` (distributive over C) lets TypeScript + * infer `C` from the `Type` arm first, then verify the constraint. A bare + * conditional on the param type blocks inference when C appears only on the + * right-hand side of the inner `extends`. */ +export type AcceptComponent = + C extends ( + StandardSchemaInferOutput extends CompatibleProps, ComponentInputs> + ? C + : never + ) + ? Type + : readonly [ + 'Schema output is not assignable to this component\'s inputs', + StandardSchemaInferOutput, + ComponentInputs, + ]; + +/** Reverse helper: derive a component's input prop types FROM a schema, so a + * component authored straight from the schema is guaranteed compatible. */ +export type ViewProps = Prettify>; diff --git a/libs/chat/src/lib/client-tools/execute.ts b/libs/chat/src/lib/client-tools/execute.ts index 4f9907e77..14f592c67 100644 --- a/libs/chat/src/lib/client-tools/execute.ts +++ b/libs/chat/src/lib/client-tools/execute.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT import type { StandardSchemaV1 } from '@threadplane/render'; -import type { FunctionToolDef } from './tool-def'; +import type { AnyFunctionToolDef } from './tool-def'; import type { ClientToolResult } from './client-tools-capability'; /** Validate raw model args against a Standard Schema. */ @@ -20,7 +20,7 @@ export async function validateArgs( /** Validate args, run the handler, and normalize the outcome to a ClientToolResult. */ export async function executeFunctionTool( - def: FunctionToolDef, + def: AnyFunctionToolDef, rawArgs: unknown, ): Promise { const v = await validateArgs(def.schema, rawArgs); diff --git a/libs/chat/src/lib/client-tools/index.ts b/libs/chat/src/lib/client-tools/index.ts index 24af7f41d..05b46764b 100644 --- a/libs/chat/src/lib/client-tools/index.ts +++ b/libs/chat/src/lib/client-tools/index.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT export { action, view, ask, tools } from './tools'; export { deriveJsonSchema } from './to-json-schema'; -export type { ClientToolDef, FunctionToolDef, ViewToolDef, AskToolDef, ClientToolRegistry } from './tool-def'; +export type { ClientToolDef, FunctionToolDef, AnyFunctionToolDef, ViewToolDef, AskToolDef, ClientToolRegistry } from './tool-def'; export type { ClientToolSpec } from './to-json-schema'; export type { ClientToolsCapability, ClientToolResult } from './client-tools-capability'; export { validateArgs, executeFunctionTool } from './execute'; diff --git a/libs/chat/src/lib/client-tools/tool-def.ts b/libs/chat/src/lib/client-tools/tool-def.ts index ac0aefda7..c1d513786 100644 --- a/libs/chat/src/lib/client-tools/tool-def.ts +++ b/libs/chat/src/lib/client-tools/tool-def.ts @@ -1,33 +1,50 @@ // SPDX-License-Identifier: MIT import type { Type } from '@angular/core'; -import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; +import type { StandardSchemaV1, StandardSchemaInferInput, StandardSchemaInferOutput } from '@threadplane/render'; -/** A client tool the model can call; executed in the browser. */ -export type ClientToolDef = - | FunctionToolDef - | ViewToolDef - | AskToolDef; +export type { StandardSchemaV1, StandardSchemaInferInput, StandardSchemaInferOutput }; -export interface FunctionToolDef { +/** Precise authored function tool — what `action()` returns. Carries the schema + * `S` and the handler's resolved return type `R`. */ +export interface FunctionToolDef { readonly kind: 'function'; readonly description: string; readonly schema: S; - readonly handler: (args: StandardSchemaInferOutput) => unknown | Promise; + readonly handler: (args: StandardSchemaInferOutput) => R | Promise; } -export interface ViewToolDef { - readonly kind: 'view'; +/** Bivariant union member used only for registry storage/iteration. The handler + * param is `any` (NOT `never`): `any` is simultaneously a supertype any precise + * `FunctionToolDef` is assignable to under `strictFunctionTypes`, AND + * callable by internal code that has narrowed by `kind` and parsed runtime args. + * A `never` param would satisfy the former but break the latter. */ +export interface AnyFunctionToolDef { + readonly kind: 'function'; readonly description: string; readonly schema: StandardSchemaV1; - readonly component: Type; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bivariance escape hatch; see note above + readonly handler: (args: any) => unknown | Promise; +} + +export interface ViewToolDef { + readonly kind: 'view'; + readonly description: string; + readonly schema: S; + readonly component: Type; } -export interface AskToolDef { +export interface AskToolDef { readonly kind: 'ask'; readonly description: string; - readonly schema: StandardSchemaV1; - readonly component: Type; + readonly schema: S; + readonly component: Type; } +/** A client tool the model can call; executed in the browser. */ +export type ClientToolDef = + | AnyFunctionToolDef + | ViewToolDef + | AskToolDef; + /** A frozen, name-keyed registry of client tools. */ export type ClientToolRegistry = Readonly>; diff --git a/libs/chat/src/lib/client-tools/tools.ts b/libs/chat/src/lib/client-tools/tools.ts index 665cbf554..0cbc24e6a 100644 --- a/libs/chat/src/lib/client-tools/tools.ts +++ b/libs/chat/src/lib/client-tools/tools.ts @@ -1,28 +1,134 @@ // SPDX-License-Identifier: MIT import type { Type } from '@angular/core'; import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; -import type { ClientToolDef, ClientToolRegistry, FunctionToolDef } from './tool-def'; +import type { ClientToolDef, FunctionToolDef, ViewToolDef, AskToolDef } from './tool-def'; +import type { AcceptComponent } from './component-inputs'; +export type { ViewProps } from './component-inputs'; -/** Async function tool — its resolved return value becomes the tool result. */ -export function action( +/** + * Declare an async function tool the model can call; its resolved return value + * becomes the tool result shipped back to the model. + * + * @param description Natural-language description the model sees. + * @param schema Standard Schema (e.g. a Zod object) for the arguments; the + * handler's argument type is inferred from it. + * @param handler Runs in the browser when the model calls the tool; its return + * type `R` is carried on the resulting {@link FunctionToolDef}. + * @returns A {@link FunctionToolDef} for inclusion in {@link tools}. + * @example + * ```ts + * const move = action('Move a stop', z.object({ fromDay: z.number() }), (a) => a.fromDay); + * const registry = tools({ move_stop: move }); + * ``` + */ +export function action( description: string, schema: S, - handler: (args: StandardSchemaInferOutput) => unknown | Promise, -): FunctionToolDef { + handler: (args: StandardSchemaInferOutput) => R | Promise, +): FunctionToolDef { return { kind: 'function', description, schema, handler }; } -/** Render-only component tool — the model fills its props; auto-acknowledged. */ -export function view(description: string, schema: StandardSchemaV1, component: Type): ClientToolDef { - return { kind: 'view', description, schema, component }; +/** + * Render-only component tool — the model fills the component's props from the + * schema's output; the tool call is auto-acknowledged once the component mounts. + * + * The component's signal inputs are checked against the schema output type + * (strict-but-flexible: every schema key must be a declared input with an + * assignable type; the component may declare extra inputs the schema doesn't fill). + * Author the component with `ViewProps` as the input type set to + * guarantee the shapes stay aligned. + * + * @param description Natural-language description the model sees. + * @param schema Standard Schema defining the props the model must supply. + * @param component Angular component whose signal inputs must be compatible with + * the schema output. A type-level error is reported here when they diverge. + * @returns A {@link ViewToolDef} for inclusion in {@link tools}. + * @example + * ```ts + * const schema = z.object({ label: z.string(), day: z.number() }); + * type Inputs = ViewProps; // { label: string; day: number } + * + * \@Component({ ... }) + * class DayCardComponent { + * label = input.required(); + * day = input.required(); + * } + * + * const dayCard = view('Show a day card', schema, DayCardComponent); + * const registry = tools({ day_card: dayCard }); + * ``` + */ +export function view( + description: string, + schema: S, + component: AcceptComponent, +): ViewToolDef { + return { kind: 'view', description, schema, component: component as Type }; } -/** Interactive (HITL) component tool — the value it emits becomes the result. */ -export function ask(description: string, schema: StandardSchemaV1, component: Type): ClientToolDef { - return { kind: 'ask', description, schema, component }; +/** + * Interactive (human-in-the-loop) component tool — the model fills the + * component's props from the schema's output; the value the component emits + * back to the framework becomes the tool result sent to the model. + * + * The component's signal inputs are checked against the schema output type + * (strict-but-flexible: every schema key must be a declared input with an + * assignable type; the component may declare extra inputs the schema doesn't + * fill). Author the component with `ViewProps` to derive input + * prop types directly from the schema. + * + * @param description Natural-language description the model sees. + * @param schema Standard Schema defining the props the model must supply. + * @param component Angular component whose signal inputs must be compatible with + * the schema output. A type-level error is reported here when they diverge. + * @returns An {@link AskToolDef} for inclusion in {@link tools}. + * @example + * ```ts + * const schema = z.object({ question: z.string(), options: z.array(z.string()) }); + * type Inputs = ViewProps; + * + * \@Component({ ... }) + * class ChoiceCardComponent { + * question = input.required(); + * options = input.required(); + * // Emits the chosen option back to the model. + * } + * + * const choice = ask('Ask the user to choose', schema, ChoiceCardComponent); + * const registry = tools({ pick_option: choice }); + * ``` + */ +export function ask( + description: string, + schema: S, + component: AcceptComponent, +): AskToolDef { + return { kind: 'ask', description, schema, component: component as Type }; } -/** Collect named client tools into a frozen registry (the key is the tool name). */ -export function tools(map: Record): ClientToolRegistry { +/** + * Collect named client tools into a frozen, name-keyed registry. + * + * The overload is generic over the entire map (`const M`) so that each tool's + * precise type ({@link FunctionToolDef}``, {@link ViewToolDef}``, or + * {@link AskToolDef}``) and every literal key are preserved in the + * {@link ClientToolRegistry} passed to `provideChat`. This lets downstream + * consumers look up individual tools without losing generic information. + * + * @param map An object literal mapping tool names to tool definitions created + * by {@link action}, {@link view}, or {@link ask}. + * @returns A frozen `Readonly` where `M` is the exact inferred map shape. + * @example + * ```ts + * const move = action('Move a stop', z.object({ fromDay: z.number() }), (a) => a.fromDay); + * const dayCard = view('Show a day card', z.object({ label: z.string() }), DayCardComponent); + * + * const registry = tools({ move_stop: move, day_card: dayCard }); + * // registry.move_stop is FunctionToolDef<...> + * // registry.day_card is ViewToolDef<...> + * ``` + */ +export function tools>(map: M): Readonly { return Object.freeze({ ...map }); } diff --git a/libs/chat/src/lib/client-tools/tools.type-spec.ts b/libs/chat/src/lib/client-tools/tools.type-spec.ts new file mode 100644 index 000000000..2719738c6 --- /dev/null +++ b/libs/chat/src/lib/client-tools/tools.type-spec.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +import type { Equal, Expect } from '../../testing/type-assert'; +import type { FunctionToolDef, ClientToolDef } from './tool-def'; +import { action, tools } from './tools'; +import { z } from 'zod/v4'; + +const moveSchema = z.object({ fromDay: z.number(), placeId: z.string() }); + +// action() infers handler arg from the schema output and carries the return type R. +const moveAction = action('Move a stop', moveSchema, (a) => a.fromDay + 1); +type _argInfer = Expect[0], { fromDay: number; placeId: string }>>; +type _retInfer = Expect>>; + +// A precise FunctionToolDef must be assignable into the bivariant union. +const _u: ClientToolDef = moveAction; + +// tools() preserves per-key tool types AND literal keys under strict. +const registry = tools({ + move_stop: moveAction, + note: action('Note', z.object({ text: z.string() }), (a) => a.text), +}); +type _keys = Expect>; +type _perKey = Expect>>; diff --git a/libs/chat/src/lib/client-tools/view-ask.type-spec.ts b/libs/chat/src/lib/client-tools/view-ask.type-spec.ts new file mode 100644 index 000000000..5d7422308 --- /dev/null +++ b/libs/chat/src/lib/client-tools/view-ask.type-spec.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +import { Component, input } from '@angular/core'; +import { z } from 'zod/v4'; +import type { ClientToolDef, ViewToolDef } from './tool-def'; +import { view, ask, tools } from './tools'; + +@Component({ template: '' }) +class DayCardComponent { + day = input.required(); + places = input([]); // optional (default) + highlight = input(false); // extra input NOT in schema +} + +@Component({ template: '' }) +class UnrelatedComponent { + title = input.required(); +} + +const daySchema = z.object({ day: z.number(), places: z.array(z.string()) }); + +// ✅ good — schema output keys ⊆ inputs, compatible types; extra `highlight` allowed. +const dayView = view('Show a day', daySchema, DayCardComponent); + +// the view tool stays assignable to the registry union and through tools(). +const _u: ClientToolDef = dayView; +const _reg = tools({ day_card: view('Show a day', daySchema, DayCardComponent) }); + +// the result carries the component type. +const _carries: ViewToolDef = dayView; + +// ❌ typo prop the component can't receive. +const typoSchema = z.object({ dayz: z.number() }); +// @ts-expect-error `dayz` is not an input of DayCardComponent +const _bad1 = view('typo', typoSchema, DayCardComponent); + +// ❌ type mismatch (day: string vs input number). +const wrongType = z.object({ day: z.string() }); +// @ts-expect-error day: string not assignable to input day: number +const _bad2 = view('wrong type', wrongType, DayCardComponent); + +// ❌ unrelated component. +// @ts-expect-error schema output {day, places} has no matching inputs on UnrelatedComponent +const _bad3 = ask('unrelated', daySchema, UnrelatedComponent); diff --git a/libs/chat/src/lib/internals/prettify.ts b/libs/chat/src/lib/internals/prettify.ts new file mode 100644 index 000000000..e47760de0 --- /dev/null +++ b/libs/chat/src/lib/internals/prettify.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +/** + * @internal + * Identity mapped type that flattens an object type so editor quick-info shows + * the expanded shape instead of a raw conditional/mapped-type expression. + */ +export type Prettify = { [K in keyof T]: T[K] } & {}; diff --git a/libs/chat/src/lib/provide-chat.ts b/libs/chat/src/lib/provide-chat.ts index ee5c3a526..228fc39be 100644 --- a/libs/chat/src/lib/provide-chat.ts +++ b/libs/chat/src/lib/provide-chat.ts @@ -33,6 +33,46 @@ export interface ChatConfig { export const CHAT_CONFIG = new InjectionToken('CHAT_CONFIG'); +/** + * Bootstrap `@threadplane/chat` in an Angular application or standalone + * component tree. + * + * Call this once inside `bootstrapApplication` (or the `providers` array of a + * root `ApplicationConfig`). It registers the shared {@link ChatConfig} token + * so every chat component in the tree can read the render registry, avatar + * label, and assistant display name without explicit prop threading. + * + * A license check is fired asynchronously on every call (it never throws; a + * watermark is shown in non-commercial builds when no valid token is supplied). + * + * @param config Options bag that controls the chat feature set: + * - `renderRegistry` — shared {@link AngularRegistry} wiring tool-view + * components to their names; pass the value returned by + * `defineAngularRegistry` from `\@threadplane/render`. + * - `avatarLabel` — short label shown in the AI avatar bubble (default `"A"`). + * - `assistantName` — display name shown above assistant messages + * (default `"Assistant"`). + * - `license` — signed token from threadplane.ai; omit in development. + * @returns An `EnvironmentProviders` value suitable for the `providers` array + * of `bootstrapApplication` or `ApplicationConfig`. + * @example + * ```ts + * // main.ts + * import { bootstrapApplication } from '@angular/platform-browser'; + * import { provideChat } from '@threadplane/chat'; + * import { defineAngularRegistry, provideRender } from '@threadplane/render'; + * import { DayCardComponent } from './day-card.component'; + * + * const registry = defineAngularRegistry({ day_card: DayCardComponent }); + * + * bootstrapApplication(AppComponent, { + * providers: [ + * provideChat({ renderRegistry: registry, avatarLabel: 'AI' }), + * provideRender({ registry }), + * ], + * }); + * ``` + */ export function provideChat(config: ChatConfig) { void runLicenseCheck({ package: PACKAGE_NAME, diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 07c31e896..f1c88a1f0 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -34,11 +34,13 @@ export { isAssistantMessage, isToolMessage, isSystemMessage, + createAgentRef, AgentError, AGENT_ERROR_MESSAGES, toAgentError, isAbortError, } from './lib/agent'; +export type { AgentRef } from './lib/agent'; export type { AgentErrorKind } from './lib/agent'; // Primitives @@ -217,7 +219,10 @@ export { isPathRef, isLiteralString, isLiteralNumber, isLiteralBoolean } from '@ // Client tools (declaration API — tools/action/view/ask + JSON-schema derivation) export { tools, action, view, ask } from './lib/client-tools/tools'; export { deriveJsonSchema } from './lib/client-tools/to-json-schema'; -export type { ClientToolDef, FunctionToolDef, ViewToolDef, AskToolDef, ClientToolRegistry } from './lib/client-tools/tool-def'; +export type { ClientToolDef, AnyFunctionToolDef, FunctionToolDef, ViewToolDef, AskToolDef, ClientToolRegistry, StandardSchemaV1, StandardSchemaInferInput, StandardSchemaInferOutput } from './lib/client-tools/tool-def'; +export type { ViewProps } from './lib/client-tools/component-inputs'; +/** Inferred argument type for a schema (alias of StandardSchemaInferOutput). */ +export type ToolArgs = import('./lib/client-tools/tool-def').StandardSchemaInferOutput; export type { ClientToolSpec } from './lib/client-tools/to-json-schema'; export type { ClientToolsCapability, ClientToolResult } from './lib/client-tools/client-tools-capability'; export { validateArgs, executeFunctionTool } from './lib/client-tools/execute'; diff --git a/libs/chat/src/testing/type-assert.ts b/libs/chat/src/testing/type-assert.ts index c7f9e1ab3..dd607ae04 100644 --- a/libs/chat/src/testing/type-assert.ts +++ b/libs/chat/src/testing/type-assert.ts @@ -16,3 +16,6 @@ export type Equal = /** Causes a compile error when T is not `true`. */ export type Expect = T; + +/** True if `A` is assignable to `B`. */ +export type Assignable = A extends B ? true : false; diff --git a/libs/langgraph/project.json b/libs/langgraph/project.json index 66d0d7e67..e2d1ea50b 100644 --- a/libs/langgraph/project.json +++ b/libs/langgraph/project.json @@ -47,6 +47,10 @@ "options": { "configFile": "libs/langgraph/vite.config.mts" } + }, + "type-tests": { + "executor": "nx:run-commands", + "options": { "command": "npx tsc --noEmit -p libs/langgraph/tsconfig.type-tests.json" } } } } diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index bb94509d9..4d59b7b5d 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -428,7 +428,7 @@ export function agent< isLoading, error: errorSig, toolCalls: toolCallsNeutral, - state: stateNeutral, + state: stateNeutral as Signal, interrupt: interruptNeutral, subagents: subagentsNeutral, events$, diff --git a/libs/langgraph/src/lib/agent.provider.ts b/libs/langgraph/src/lib/agent.provider.ts index 884cef351..c0b763c66 100644 --- a/libs/langgraph/src/lib/agent.provider.ts +++ b/libs/langgraph/src/lib/agent.provider.ts @@ -2,7 +2,7 @@ import { InjectionToken, inject, type Provider, type Signal } from '@angular/core'; import type { BaseMessage } from '@langchain/core/messages'; import type { BagTemplate } from '@langchain/langgraph-sdk'; -import type { AgentRuntimeTelemetrySink } from '@threadplane/chat'; +import type { AgentRef, AgentRuntimeTelemetrySink } from '@threadplane/chat'; import { agent } from './agent.fn'; import type { AgentTransport, @@ -61,6 +61,36 @@ export const AGENT_CONFIG = new InjectionToken('AGENT_CONFIG'); */ export const AGENT = new InjectionToken('AGENT'); +/** @internal — shared factory that reads AGENT_CONFIG and constructs the singleton. */ +function agentFactory(): LangGraphAgent { + // useFactory runs in an injection context, so the legacy `agent()` + // factory's `inject(DestroyRef)` calls work. + const config = inject(AGENT_CONFIG) as AgentConfig; + if (config.assistantId === undefined) { + throw new Error( + 'provideAgent: `assistantId` is required to construct the AGENT singleton.', + ); + } + return agent({ + assistantId: config.assistantId, + ...(config.apiUrl !== undefined ? { apiUrl: config.apiUrl } : {}), + ...(config.threadId !== undefined ? { threadId: config.threadId } : {}), + ...(config.onThreadId !== undefined ? { onThreadId: config.onThreadId } : {}), + ...(config.initialValues !== undefined ? { initialValues: config.initialValues } : {}), + ...(config.throttle !== undefined ? { throttle: config.throttle } : {}), + ...(config.toMessage !== undefined ? { toMessage: config.toMessage } : {}), + ...(config.transport !== undefined ? { transport: config.transport } : {}), + ...(config.clientOptions !== undefined ? { clientOptions: config.clientOptions } : {}), + ...(config.telemetry !== undefined ? { telemetry: config.telemetry } : {}), + ...(config.filterSubagentMessages !== undefined ? { filterSubagentMessages: config.filterSubagentMessages } : {}), + ...(config.subagentToolNames !== undefined ? { subagentToolNames: config.subagentToolNames } : {}), + }); +} + +function isAgentRef(x: unknown): x is AgentRef { + return typeof x === 'object' && x !== null && 'token' in x; +} + /** * Wire the LangGraph adapter into Angular's dependency injection. * @@ -86,46 +116,47 @@ export const AGENT = new InjectionToken('AGENT'); * }), * ]; * ``` + * + * **Typed state via AgentRef.** Pass a typed ref as the first argument to flow + * the state shape from `provideAgent` to `injectAgent` without repeating the + * generic at every call site: + * + * ```ts + * export const TRIP = createAgentRef('trip'); + * // app.config.ts: + * providers: [provideAgent(TRIP, { assistantId: 'trip-graph' })] + * // component: + * const agent = injectAgent(TRIP); // LangGraphAgent + * ``` */ +export function provideAgent>( + ref: AgentRef, + configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; export function provideAgent>( configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; +export function provideAgent>( + refOrConfig: AgentRef | AgentConfig | (() => AgentConfig), + maybeConfig?: AgentConfig | (() => AgentConfig), ): Provider[] { + const ref = isAgentRef(refOrConfig) ? refOrConfig : undefined; + const configOrFactory = (ref ? maybeConfig : refOrConfig) as + | AgentConfig + | (() => AgentConfig); + // Resolve the factory (if any) lazily, inside the injection context of the // AGENT_CONFIG useFactory below — never at decoration time. const resolveConfig = (): AgentConfig => - typeof configOrFactory === 'function' ? configOrFactory() : configOrFactory; + typeof configOrFactory === 'function' ? (configOrFactory as () => AgentConfig)() : configOrFactory; - return [ + const providers: Provider[] = [ // AGENT_CONFIG resolves the config once (running the factory in an // injection context if a factory was passed). AGENT reads the resolved // config from here, so the factory is invoked exactly once. { provide: AGENT_CONFIG, useFactory: resolveConfig }, - { - provide: AGENT, - useFactory: () => { - // useFactory runs in an injection context, so the legacy `agent()` - // factory's `inject(DestroyRef)` calls work. - const config = inject(AGENT_CONFIG) as AgentConfig; - if (config.assistantId === undefined) { - throw new Error( - 'provideAgent: `assistantId` is required to construct the AGENT singleton.', - ); - } - return agent({ - assistantId: config.assistantId, - ...(config.apiUrl !== undefined ? { apiUrl: config.apiUrl } : {}), - ...(config.threadId !== undefined ? { threadId: config.threadId } : {}), - ...(config.onThreadId !== undefined ? { onThreadId: config.onThreadId } : {}), - ...(config.initialValues !== undefined ? { initialValues: config.initialValues } : {}), - ...(config.throttle !== undefined ? { throttle: config.throttle } : {}), - ...(config.toMessage !== undefined ? { toMessage: config.toMessage } : {}), - ...(config.transport !== undefined ? { transport: config.transport } : {}), - ...(config.clientOptions !== undefined ? { clientOptions: config.clientOptions } : {}), - ...(config.telemetry !== undefined ? { telemetry: config.telemetry } : {}), - ...(config.filterSubagentMessages !== undefined ? { filterSubagentMessages: config.filterSubagentMessages } : {}), - ...(config.subagentToolNames !== undefined ? { subagentToolNames: config.subagentToolNames } : {}), - }); - }, - }, + { provide: AGENT, useFactory: agentFactory }, ]; + if (ref) providers.push({ provide: ref.token, useExisting: AGENT }); + return providers; } diff --git a/libs/langgraph/src/lib/agent.types.ts b/libs/langgraph/src/lib/agent.types.ts index 675db8c8a..fadbc4551 100644 --- a/libs/langgraph/src/lib/agent.types.ts +++ b/libs/langgraph/src/lib/agent.types.ts @@ -312,7 +312,7 @@ export interface SubagentStreamRef { * `langGraph` to avoid collision with the runtime-neutral names. */ export interface LangGraphAgent - extends AgentWithHistory { + extends AgentWithHistory { // ── Raw LangGraph signals ──────────────────────────────────────────────── /** Raw LangChain BaseMessage list. Use `messages` for chat rendering. */ diff --git a/libs/langgraph/src/lib/inject-agent.ts b/libs/langgraph/src/lib/inject-agent.ts index 3c6eabf9f..8cd83d1ae 100644 --- a/libs/langgraph/src/lib/inject-agent.ts +++ b/libs/langgraph/src/lib/inject-agent.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT import { inject } from '@angular/core'; import type { BagTemplate } from '@langchain/langgraph-sdk'; +import type { AgentRef } from '@threadplane/chat'; import { AGENT } from './agent.provider'; import type { LangGraphAgent } from './agent.types'; @@ -12,10 +13,23 @@ import type { LangGraphAgent } from './agent.types'; * singleton scoped to the injector that called `provideAgent()` — re-provide * in a child component's `providers: []` to scope a different agent to that * subtree (Angular's hierarchical DI handles the rest). + * + * **Typed state via AgentRef.** Pass the same ref that was supplied to + * `provideAgent(ref, …)` to carry the state type through DI without repeating + * the generic at every call site: + * + * ```ts + * const agent = injectAgent(TRIP); // LangGraphAgent + * ``` + * + * The no-arg form defaults to `LangGraphAgent>`. */ -export function injectAgent< - T = Record, - ResolvedBag extends BagTemplate = BagTemplate, ->(): LangGraphAgent { - return inject(AGENT) as LangGraphAgent; +export function injectAgent(): LangGraphAgent>; +export function injectAgent( + ref: AgentRef, +): LangGraphAgent; +export function injectAgent, ResolvedBag extends BagTemplate = BagTemplate>( + ref?: AgentRef, +): LangGraphAgent { + return inject(ref ? ref.token : AGENT) as LangGraphAgent; } diff --git a/libs/langgraph/src/lib/inject-agent.type-spec.ts b/libs/langgraph/src/lib/inject-agent.type-spec.ts new file mode 100644 index 000000000..a05c50860 --- /dev/null +++ b/libs/langgraph/src/lib/inject-agent.type-spec.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +import { createAgentRef } from '@threadplane/chat'; +import type { Equal, Expect } from '../testing/type-assert'; +import { injectAgent } from './inject-agent'; + +interface TripState { day: number; places: string[]; } +const TRIP = createAgentRef('trip'); + +declare function ctx(fn: () => T): T; + +// injectAgent(ref) is typed LangGraphAgent; state and value are Signal. +const typed = ctx(() => injectAgent(TRIP)); +type _agentState = Expect, TripState>>; +type _agentValue = Expect, TripState>>; + +// no-arg form stays valid (default state). +const plain = ctx(() => injectAgent()); +type _plainState = Expect, Record>>; diff --git a/libs/langgraph/src/testing/type-assert.ts b/libs/langgraph/src/testing/type-assert.ts new file mode 100644 index 000000000..b355134b3 --- /dev/null +++ b/libs/langgraph/src/testing/type-assert.ts @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT +/** Compile-time assertion helpers for *.type-spec.ts files (no runtime). */ +export type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +export type Expect = T; diff --git a/libs/langgraph/tsconfig.type-tests.json b/libs/langgraph/tsconfig.type-tests.json new file mode 100644 index 000000000..47e1cd415 --- /dev/null +++ b/libs/langgraph/tsconfig.type-tests.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "strictFunctionTypes": true, + "skipLibCheck": true, + "types": [], + "inlineSources": false, + "declaration": false, + "declarationMap": false, + "noUnusedLocals": false + }, + "files": [], + "include": ["src/**/*.type-spec.ts"], + "exclude": ["src/**/*.spec.ts"], + "references": [] +} diff --git a/libs/render/src/lib/define-angular-registry.ts b/libs/render/src/lib/define-angular-registry.ts index b671333c7..194340913 100644 --- a/libs/render/src/lib/define-angular-registry.ts +++ b/libs/render/src/lib/define-angular-registry.ts @@ -17,6 +17,54 @@ function normalize(entry: Type | RenderViewEntry): NormalizedEntry { }; } +/** + * Build an {@link AngularRegistry} from a plain object mapping tool-call names + * to Angular components (or fully specified {@link RenderViewEntry} objects). + * + * The returned registry is consumed by both `provideRender` (to drive + * dynamic component rendering) and `provideChat` (via `renderRegistry`) so + * that a single `defineAngularRegistry` call wires both layers. + * + * **Entry forms** + * - Bare `Type` — the component is paired with the built-in + * `DefaultFallbackComponent` while its props are still streaming. + * - `RenderViewEntry` object — lets you supply a custom `fallback` component, + * an optional Standard Schema (`schema`) used as a mount-readiness gate, and + * an optional `description` for model-facing tool registration. + * + * **Registry accessor** + * The returned object exposes a single `getEntry(name: string)` accessor that + * returns the fully-normalized {@link NormalizedEntry} (component + fallback + + * optional schema + optional description) or `undefined` when the name is not + * registered. Use `names()` to enumerate all registered names. + * + * @param componentMap Object whose keys are tool-call names and whose values + * are either bare Angular component classes or {@link RenderViewEntry} objects. + * @returns An {@link AngularRegistry} with `getEntry` and `names` accessors. + * @example + * ```ts + * import { defineAngularRegistry } from '@threadplane/render'; + * import { DayCardComponent } from './day-card.component'; + * import { LoadingSpinnerComponent } from './loading-spinner.component'; + * import { z } from 'zod'; + * + * export const registry = defineAngularRegistry({ + * // Bare component — uses DefaultFallbackComponent while streaming. + * summary_card: SummaryCardComponent, + * + * // Full entry — custom fallback + schema-gated mounting. + * day_card: { + * component: DayCardComponent, + * fallback: LoadingSpinnerComponent, + * schema: z.object({ label: z.string(), day: z.number() }), + * description: 'Renders a single itinerary day card.', + * }, + * }); + * + * // Look up a registered entry at runtime: + * const entry = registry.getEntry('day_card'); // NormalizedEntry | undefined + * ``` + */ export function defineAngularRegistry(componentMap: RegistryInput): AngularRegistry { const map = new Map(); for (const [name, entry] of Object.entries(componentMap)) { diff --git a/libs/render/src/lib/provide-render.ts b/libs/render/src/lib/provide-render.ts index d33f37b38..2a4ac7074 100644 --- a/libs/render/src/lib/provide-render.ts +++ b/libs/render/src/lib/provide-render.ts @@ -6,6 +6,41 @@ import { RenderLifecycleService } from './render-lifecycle.service'; export const RENDER_CONFIG = new InjectionToken('RENDER_CONFIG'); +/** + * Bootstrap `@threadplane/render` in an Angular application or standalone + * component tree. + * + * Registers the shared {@link RenderConfig} token and the internal + * `RenderLifecycleService` that coordinates mount/unmount events across + * dynamically rendered components. Call this once alongside `provideChat` in + * `bootstrapApplication` (or the root `ApplicationConfig`). + * + * @param config Options bag that controls the render feature set: + * - `registry` — component registry returned by {@link defineAngularRegistry}; + * maps tool-call names to Angular components. + * - `store` — optional `StateStore` for `\@json-render/core` state binding. + * - `functions` — optional map of computed functions available inside specs. + * - `handlers` — optional map of event handlers triggered by spec actions. + * @returns An `EnvironmentProviders` value suitable for the `providers` array + * of `bootstrapApplication` or `ApplicationConfig`. + * @example + * ```ts + * // main.ts + * import { bootstrapApplication } from '@angular/platform-browser'; + * import { defineAngularRegistry, provideRender } from '@threadplane/render'; + * import { provideChat } from '@threadplane/chat'; + * import { DayCardComponent } from './day-card.component'; + * + * const registry = defineAngularRegistry({ day_card: DayCardComponent }); + * + * bootstrapApplication(AppComponent, { + * providers: [ + * provideRender({ registry }), + * provideChat({ renderRegistry: registry }), + * ], + * }); + * ``` + */ export function provideRender(config: RenderConfig) { return makeEnvironmentProviders([ { provide: RENDER_CONFIG, useValue: config },