|
| 1 | +# Client Tools M6 Provider Contract Notes |
| 2 | + |
| 3 | +**Date:** 2026-07-08 |
| 4 | +**Threadplane branch:** `blove/client-tools-m6-provider-discovery` |
| 5 | +**Provider source inspected:** `/Users/blove/repos/hashbrown` |
| 6 | +**Provider source commit inspected:** `c7b10d419ea9350cfcb701e9d0a0adb1922bfc92` |
| 7 | +**Provider package versions inspected:** `@hashbrownai/core@0.5.0-beta.4`, `@hashbrownai/angular@0.5.0-beta.4` |
| 8 | + |
| 9 | +These notes execute the M6 discovery step from `docs/superpowers/plans/2026-07-08-client-tools-m6-agent-bridge-plan.md`. The source repository was read-only during discovery; no provider files or secrets were modified or read. |
| 10 | + |
| 11 | +## Summary |
| 12 | + |
| 13 | +The provider source does not currently expose an AG-UI `AbstractAgent`-style event subscriber. It exposes: |
| 14 | + |
| 15 | +- a headless core chat object from `fryHashbrown(...)`; |
| 16 | +- Angular resources (`chatResource`, `structuredChatResource`, `uiChatResource`) that wrap that core object in Angular signals; |
| 17 | +- an internal transport frame protocol that is decoded inside the core runtime before state is exposed. |
| 18 | + |
| 19 | +That points to a bespoke Threadplane `Agent` adapter over the provider chat/resource state, not reuse of `libs/ag-ui/src/lib/reducer.ts`. |
| 20 | + |
| 21 | +## Source And Construction API |
| 22 | + |
| 23 | +Primary source files: |
| 24 | + |
| 25 | +- `/Users/blove/repos/hashbrown/packages/core/src/hashbrown.ts` |
| 26 | +- `/Users/blove/repos/hashbrown/packages/angular/src/resources/chat-resource.fn.ts` |
| 27 | +- `/Users/blove/repos/hashbrown/packages/angular/src/resources/structured-chat-resource.fn.ts` |
| 28 | +- `/Users/blove/repos/hashbrown/packages/angular/src/resources/ui-chat-resource.fn.ts` |
| 29 | +- `/Users/blove/repos/hashbrown/packages/core/src/frames/frame-types.ts` |
| 30 | +- `/Users/blove/repos/hashbrown/packages/core/src/effects/generate-message.effects.ts` |
| 31 | + |
| 32 | +Core construction: |
| 33 | + |
| 34 | +```ts |
| 35 | +fryHashbrown({ |
| 36 | + apiUrl, |
| 37 | + model, |
| 38 | + system, |
| 39 | + messages, |
| 40 | + tools, |
| 41 | + responseSchema, |
| 42 | + structuredOutput, |
| 43 | + middleware, |
| 44 | + emulateStructuredOutput, |
| 45 | + debounce, |
| 46 | + retries, |
| 47 | + transport, |
| 48 | + ui, |
| 49 | + threadId, |
| 50 | +}) |
| 51 | +``` |
| 52 | + |
| 53 | +Angular construction: |
| 54 | + |
| 55 | +```ts |
| 56 | +chatResource(options) |
| 57 | +structuredChatResource(options) |
| 58 | +uiChatResource(options) |
| 59 | +``` |
| 60 | + |
| 61 | +Angular setup also supports a DI config via: |
| 62 | + |
| 63 | +```ts |
| 64 | +provideHashbrown(...) |
| 65 | +``` |
| 66 | + |
| 67 | +## Public State Surface |
| 68 | + |
| 69 | +The core `Hashbrown<Output, Tools>` interface exposes: |
| 70 | + |
| 71 | +- `messages` |
| 72 | +- `error` |
| 73 | +- `isReceiving` |
| 74 | +- `isSending` |
| 75 | +- `isGenerating` |
| 76 | +- `isRunningToolCalls` |
| 77 | +- `isLoading` |
| 78 | +- `exhaustedRetries` |
| 79 | +- `sendingError` |
| 80 | +- `generatingError` |
| 81 | +- `lastAssistantMessage` |
| 82 | +- thread flags and thread errors |
| 83 | +- `threadId` |
| 84 | + |
| 85 | +Control methods: |
| 86 | + |
| 87 | +- `setMessages(messages)` |
| 88 | +- `sendMessage(message)` |
| 89 | +- `resendMessages()` |
| 90 | +- `updateOptions(options)` |
| 91 | +- `stop(clearStreamingMessage?)` |
| 92 | +- `sizzle()` |
| 93 | + |
| 94 | +Angular resources convert those state signals into Angular `Signal`s and expose: |
| 95 | + |
| 96 | +- `value` for messages; |
| 97 | +- `status`; |
| 98 | +- `hasValue`; |
| 99 | +- loading/error signals; |
| 100 | +- `sendMessage`; |
| 101 | +- `setMessages` on chat and structured chat resources; |
| 102 | +- `resendMessages` on structured and UI chat resources; |
| 103 | +- `reload`; |
| 104 | +- `stop`; |
| 105 | +- `lastAssistantMessage`. |
| 106 | + |
| 107 | +## Transport Frame Contract |
| 108 | + |
| 109 | +The internal frame protocol in `packages/core/src/frames/frame-types.ts` is: |
| 110 | + |
| 111 | +| Frame | Payload | |
| 112 | +| --- | --- | |
| 113 | +| `generation-start` | none | |
| 114 | +| `generation-chunk` | `chunk: Chat.Api.CompletionChunk` | |
| 115 | +| `generation-error` | `error`, optional `stacktrace` | |
| 116 | +| `generation-finish` | none | |
| 117 | +| `thread-load-start` | none | |
| 118 | +| `thread-load-success` | optional `thread: Chat.Api.Message[]` | |
| 119 | +| `thread-load-failure` | `error`, optional `stacktrace` | |
| 120 | +| `thread-save-start` | none | |
| 121 | +| `thread-save-success` | `threadId` | |
| 122 | +| `thread-save-failure` | `error`, optional `stacktrace` | |
| 123 | + |
| 124 | +These frames are decoded by `generate-message.effects.ts` and reduced into provider state. They are not equivalent to AG-UI runtime events: |
| 125 | + |
| 126 | +- there is no public `subscribe({ onEvent })` API on the Angular resource surface; |
| 127 | +- generation chunks preserve OpenAI-style completion deltas, not AG-UI event names such as `TEXT_MESSAGE_START` or `TOOL_CALL_START`; |
| 128 | +- tool execution is internal and post-assistant-turn, not projected as a Threadplane `ClientToolsCapability` pending queue. |
| 129 | + |
| 130 | +## Message And Tool Model |
| 131 | + |
| 132 | +Provider API messages: |
| 133 | + |
| 134 | +- `user`: `{ role: 'user', content: string }` |
| 135 | +- `assistant`: `{ role: 'assistant', content?: string, toolCalls?: ToolCall[] }` |
| 136 | +- `tool`: `{ role: 'tool', content: PromiseSettledResult<any>, toolCallId, toolName }` |
| 137 | +- `error`: `{ role: 'error', content: string }` |
| 138 | + |
| 139 | +Provider view messages: |
| 140 | + |
| 141 | +- `user`: `{ role: 'user', content: JsonValue }` |
| 142 | +- `assistant`: `{ role: 'assistant', content?: Output, toolCalls: AnyToolCall[] }` |
| 143 | +- `error`: `{ role: 'error', content: string }` |
| 144 | + |
| 145 | +Internal tool calls: |
| 146 | + |
| 147 | +```ts |
| 148 | +{ |
| 149 | + id: string; |
| 150 | + name: string; |
| 151 | + arguments: string; |
| 152 | + argumentsResolved?: JsonValue; |
| 153 | + result?: PromiseSettledResult<any>; |
| 154 | + progress?: number; |
| 155 | + status: 'pending' | 'done'; |
| 156 | +} |
| 157 | +``` |
| 158 | + |
| 159 | +Tool execution is run by `packages/core/src/effects/tools.effects.ts` after `assistantTurnFinalized`. It: |
| 160 | + |
| 161 | +- selects pending internal tool calls; |
| 162 | +- finds matching registered tools; |
| 163 | +- parses/validates arguments; |
| 164 | +- invokes the provider tool handler with `(args, abortSignal)`; |
| 165 | +- dispatches `runToolCallsSuccess`; |
| 166 | +- marks tool calls `done`; |
| 167 | +- causes generation to continue through `internalActions.runToolCallsSuccess`. |
| 168 | + |
| 169 | +## Threadplane Agent Mapping |
| 170 | + |
| 171 | +Likely first adapter target: core `Hashbrown` or Angular `ChatResourceRef`/`StructuredChatResourceRef`. |
| 172 | + |
| 173 | +Threadplane field mapping: |
| 174 | + |
| 175 | +| Threadplane `Agent` field | Provider source | |
| 176 | +| --- | --- | |
| 177 | +| `messages` | Map provider `messages`/resource `value` | |
| 178 | +| `status` | Derive from `isLoading`, `error`, and message presence | |
| 179 | +| `isLoading` | Provider `isLoading` | |
| 180 | +| `error` | Provider `error`, normalized through Threadplane `toAgentError` | |
| 181 | +| `toolCalls` | Flatten assistant `toolCalls` from provider view messages | |
| 182 | +| `state` | `{}` initially; no generic state signal is exposed | |
| 183 | +| `events$` | `EMPTY` initially; no public event stream exists | |
| 184 | +| `submit` | `sendMessage({ role: 'user', content })` | |
| 185 | +| `stop` | `stop(clearStreamingMessage?)`, guarding the provider's throw-when-idle behavior | |
| 186 | +| `retry` | `resendMessages()` if available; otherwise no-op | |
| 187 | +| `regenerate` | `setMessages(trimmed)` followed by resend/reload semantics where available | |
| 188 | +| `clientTools` | Do not map initially; provider owns tool execution internally | |
| 189 | +| `interrupt` | unsupported/absent initially | |
| 190 | +| `subagents` | unsupported/absent initially | |
| 191 | + |
| 192 | +## Reducer Decision |
| 193 | + |
| 194 | +Do not reuse `libs/ag-ui/src/lib/reducer.ts` for the first implementation. The provider stream has a different contract: |
| 195 | + |
| 196 | +- Threadplane AG-UI reducer expects named AG-UI events such as `RUN_STARTED`, `TEXT_MESSAGE_CONTENT`, `TOOL_CALL_ARGS`, `STATE_SNAPSHOT`, `CUSTOM`, and `ACTIVITY_*`. |
| 197 | +- The provider exposes a chat/resource state surface and internal transport frames. |
| 198 | +- The stable adapter boundary is therefore provider state to Threadplane `Agent`, not provider frames to AG-UI events. |
| 199 | + |
| 200 | +The only plausible AG-UI reuse path would be a future provider feature that explicitly emits AG-UI events or implements AG-UI's `AbstractAgent` API. That was not present in the inspected source. |
| 201 | + |
| 202 | +## Open Questions Before Runtime Code |
| 203 | + |
| 204 | +1. Should the first bridge wrap core `fryHashbrown(...)` directly or wrap Angular `chatResource(...)`/`structuredChatResource(...)`? |
| 205 | + - Core wrapping is framework-neutral but needs state-signal conversion in Threadplane. |
| 206 | + - Angular resource wrapping matches Threadplane's Angular library shape but requires an injection context and may duplicate provider DI configuration. |
| 207 | +2. Should the bridge target unstructured chat only first, or also support structured/UI chat from day one? |
| 208 | +3. Where should the bridge live? |
| 209 | + - New adapter package is cleanest if it is a public migration bridge. |
| 210 | + - A private internal adapter avoids new public exports until the API is proven. |
| 211 | +4. How should provider-managed tools relate to Threadplane `ClientToolsCapability`? |
| 212 | + - First recommendation: do not expose a Threadplane client-tools capability. Let provider tools remain provider-owned. |
| 213 | + - A later bridge could translate provider tool definitions into Threadplane `action`/`view`/`ask`, but only if product direction requires Threadplane to own the client-tool loop. |
| 214 | +5. Should `regenerate` use `setMessages(trimmed)` plus `resendMessages()`, or mimic the provider resource `reload()` behavior that only removes the last assistant response? |
| 215 | + |
| 216 | +## Recommended Next PR |
| 217 | + |
| 218 | +Implement a small, private proof-of-contract adapter test before any public API: |
| 219 | + |
| 220 | +1. Create a local fake `Hashbrown`/resource object in a Threadplane spec. |
| 221 | +2. Write conformance tests for mapping provider state into Threadplane `Agent`: |
| 222 | + - initial idle state; |
| 223 | + - user submit; |
| 224 | + - loading/status projection; |
| 225 | + - assistant message projection; |
| 226 | + - error projection; |
| 227 | + - stop while idle is swallowed/no-op; |
| 228 | + - retry delegates to provider resend where available; |
| 229 | + - regenerate trims messages and resends when supported. |
| 230 | +3. Implement the smallest private adapter helper inside the spec or an internal module. |
| 231 | +4. Do not add public exports or dependencies yet. |
| 232 | + |
| 233 | +This keeps M6 moving while preserving the spec constraint that productized public surface waits until the provider boundary is proven. |
0 commit comments