@crombolo/assistant 0.0.0-stage → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Bedrock, through the Converse API.
3
+ *
4
+ * The AWS SDK does the signing and the credential resolution. That is a
5
+ * dependency this package otherwise does without, and it is here for one reason:
6
+ * credentials. A developer's AWS access is an SSO profile rather than a pair of
7
+ * long-lived keys, and resolving one means the shared config file,
8
+ * `source_profile` chains, the SSO token cache and its refresh — most of why the
9
+ * SDK is large, and not something to reimplement. Resolving credentials alone
10
+ * costs 13 MB against the full client's 14 MB, so the client comes too and the
11
+ * hand-rolled SigV4 goes.
12
+ *
13
+ * `AWS_PROFILE` and the rest of the chain then work as they do for every other AWS
14
+ * tool: options first, then the environment, the shared config, SSO, and finally
15
+ * an instance or task role.
16
+ */
17
+ import {
18
+ BedrockRuntimeClient,
19
+ type ContentBlock,
20
+ ConverseCommand,
21
+ type Message,
22
+ } from '@aws-sdk/client-bedrock-runtime'
23
+ import { defaultProvider } from '@aws-sdk/credential-provider-node'
24
+ import { NodeHttpHandler } from '@smithy/node-http-handler'
25
+ import type { DocumentType } from '@smithy/types'
26
+ import type {
27
+ AssistantProvider,
28
+ ConverseInput,
29
+ ConverseResult,
30
+ MessagePart,
31
+ ProviderMessage,
32
+ ProviderStatus,
33
+ } from '../provider.ts'
34
+
35
+ export interface BedrockOptions {
36
+ /** A model id or inference profile id, e.g. `apac.anthropic.claude-…`. */
37
+ model: string
38
+ region?: string
39
+ /** A named profile from the shared config, as `AWS_PROFILE` would select. */
40
+ profile?: string
41
+ /** Static credentials, for a site that holds them somewhere of its own. */
42
+ accessKeyId?: string
43
+ secretAccessKey?: string
44
+ sessionToken?: string
45
+ /** Overrides the endpoint, for a VPC endpoint or a test double. */
46
+ endpoint?: string
47
+ maxTokens?: number
48
+ }
49
+
50
+ /** An option or variable that was given; a container passes an unset one as `''`. */
51
+ const given = (...values: (string | undefined)[]): string | undefined =>
52
+ values.find((value) => value !== undefined && value !== '')
53
+
54
+ /**
55
+ * The credential chain ends at the instance metadata endpoint, which does not
56
+ * answer on a laptop or in a container that has no route to it — and waits a long
57
+ * time about it: a profile that cannot be resolved took two and a half minutes to
58
+ * say so. A check that reports late is a check nobody waits for, so it is bounded.
59
+ */
60
+ const CHECK_TIMEOUT_MS = 10_000
61
+
62
+ /** Small enough to be nearly free, large enough that no model calls it invalid. */
63
+ const CHECK_MAX_TOKENS = 16
64
+
65
+ function withTimeout<T>(work: Promise<T>, ms: number, message: string): Promise<T> {
66
+ return new Promise<T>((resolve, reject) => {
67
+ const timer = setTimeout(() => reject(new Error(message)), ms)
68
+ timer.unref?.()
69
+ work.then(resolve, reject).finally(() => clearTimeout(timer))
70
+ })
71
+ }
72
+
73
+ /** Turns a Bedrock refusal into something worth acting on. */
74
+ function modelRemedy(error: Error, model: string, region: string): string {
75
+ const name = error.name
76
+ if (name === 'AccessDeniedException') {
77
+ return `Grant this account access to ${model} in the Bedrock console for ${region}.`
78
+ }
79
+ if (name === 'ValidationException' || name === 'ResourceNotFoundException') {
80
+ return `Check the model id: ${model} may not exist in ${region}, and most models need a regional or global inference profile id — au.…, apac.… or global.… — rather than a bare id.`
81
+ }
82
+ return `Check that ${model} is available to this account in ${region}.`
83
+ }
84
+
85
+ function toContent(part: MessagePart): ContentBlock {
86
+ if (part.kind === 'text') return { text: part.text }
87
+ if (part.kind === 'tool-use') {
88
+ // Both sides came from JSON, which is what a smithy document is.
89
+ return { toolUse: { toolUseId: part.id, name: part.name, input: part.input as DocumentType } }
90
+ }
91
+ return {
92
+ toolResult: {
93
+ toolUseId: part.id,
94
+ content: [{ json: asJsonObject(part.content) }],
95
+ status: part.ok ? 'success' : 'error',
96
+ },
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Converse requires `toolResult.content[].json` to be a JSON *object*, and
102
+ * refuses an array or a scalar with "the format of the value … is invalid".
103
+ * Tools are free to answer with a list — `list_operations` does — so anything
104
+ * that is not an object is wrapped rather than rejected.
105
+ */
106
+ function asJsonObject(value: unknown): DocumentType {
107
+ const isObject = typeof value === 'object' && value !== null && !Array.isArray(value)
108
+ return (isObject ? value : { result: value ?? null }) as DocumentType
109
+ }
110
+
111
+ const toMessage = (message: ProviderMessage): Message => ({
112
+ role: message.role,
113
+ content: message.parts.map(toContent),
114
+ })
115
+
116
+ export function bedrock(options: BedrockOptions): AssistantProvider {
117
+ const region = given(options.region, Bun.env.AWS_REGION) ?? 'us-east-1'
118
+ const profile = given(options.profile, Bun.env.AWS_PROFILE)
119
+ const accessKeyId = given(options.accessKeyId, Bun.env.AWS_ACCESS_KEY_ID)
120
+ const secretAccessKey = given(options.secretAccessKey, Bun.env.AWS_SECRET_ACCESS_KEY)
121
+
122
+ /**
123
+ * Static credentials when the site holds them, otherwise the standard chain.
124
+ * Built once, so a resolved SSO token is cached and refreshed rather than read
125
+ * from disk on every request.
126
+ */
127
+ const credentials =
128
+ accessKeyId && secretAccessKey
129
+ ? { accessKeyId, secretAccessKey, sessionToken: given(options.sessionToken) }
130
+ : defaultProvider(profile ? { profile } : {})
131
+
132
+ /**
133
+ * Built lazily, so a missing or expired profile can never stop the site
134
+ * booting: the assistant is optional and reports its own state instead.
135
+ */
136
+ let client: BedrockRuntimeClient | undefined
137
+ const connection = () => {
138
+ client ??= new BedrockRuntimeClient({
139
+ region,
140
+ credentials,
141
+ ...(given(options.endpoint) ? { endpoint: options.endpoint } : {}),
142
+ // HTTP/1.1. The client defaults to HTTP/2 for the streaming operations and
143
+ // Converse does not stream, so this keeps it off Bun's h2 client entirely.
144
+ requestHandler: new NodeHttpHandler(),
145
+ })
146
+ return client
147
+ }
148
+
149
+ // Follows the precedence above rather than guessing it: static keys win over a
150
+ // profile for the credentials, so they must win for the label too, or a status
151
+ // line names a profile that had nothing to do with the call.
152
+ const source =
153
+ typeof credentials === 'function'
154
+ ? profile
155
+ ? `profile ${profile}`
156
+ : 'the environment'
157
+ : 'static credentials'
158
+
159
+ return {
160
+ name: `bedrock:${options.model}`,
161
+
162
+ /**
163
+ * Whether credentials resolve — not whether the model is reachable or access
164
+ * to it has been granted, which only a real call would show and which would
165
+ * cost tokens to ask on every boot.
166
+ */
167
+ async check(deep = false): Promise<ProviderStatus> {
168
+ const where = `${options.model} in ${region}, using ${source}`
169
+ let expires: string | undefined
170
+ try {
171
+ const resolved = await withTimeout(
172
+ typeof credentials === 'function' ? credentials() : Promise.resolve(credentials),
173
+ CHECK_TIMEOUT_MS,
174
+ 'Timed out resolving AWS credentials.',
175
+ )
176
+ const expiration = (resolved as { expiration?: Date }).expiration
177
+ expires = expiration ? expiration.toISOString() : undefined
178
+ } catch (error) {
179
+ return {
180
+ ok: false,
181
+ detail: (error as Error).message,
182
+ remedy: profile
183
+ ? `Run \`aws sso login --profile ${profile}\`, or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.`
184
+ : 'Set AWS_PROFILE, or AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.',
185
+ }
186
+ }
187
+
188
+ if (!deep) return { ok: true, detail: where, expires, model: 'unchecked' }
189
+
190
+ // Credentials resolving says nothing about whether this account may invoke
191
+ // this model in this region. Only a real call does, so it makes close to the
192
+ // smallest one there is: a word in, a few tokens out.
193
+ //
194
+ // Not one token out: some models refuse a `maxTokens` that low —
195
+ // `global.xai.grok-4.7` answers `integer_below_min_value` — and a check that
196
+ // reports a working model as broken is worse than one that costs a token or
197
+ // two more.
198
+ try {
199
+ await withTimeout(
200
+ connection().send(
201
+ new ConverseCommand({
202
+ modelId: options.model,
203
+ messages: [{ role: 'user', content: [{ text: 'ping' }] }],
204
+ inferenceConfig: { maxTokens: CHECK_MAX_TOKENS },
205
+ }),
206
+ ),
207
+ CHECK_TIMEOUT_MS,
208
+ `Timed out asking Bedrock for ${options.model}.`,
209
+ )
210
+ return { ok: true, detail: `${where}, model reachable`, expires, model: 'ok' }
211
+ } catch (error) {
212
+ const failure = error as Error
213
+ // Being throttled means the call got through: credentials and access are
214
+ // both fine, which is what this is asking.
215
+ if (failure.name === 'ThrottlingException') {
216
+ return { ok: true, detail: `${where}, throttled but reachable`, expires, model: 'ok' }
217
+ }
218
+ return {
219
+ ok: false,
220
+ detail: `${options.model} did not answer: ${failure.message}`,
221
+ remedy: modelRemedy(failure, options.model, region),
222
+ expires,
223
+ }
224
+ }
225
+ },
226
+
227
+ async converse(input: ConverseInput): Promise<ConverseResult> {
228
+ const response = await connection().send(
229
+ new ConverseCommand({
230
+ modelId: options.model,
231
+ system: input.system === '' ? undefined : [{ text: input.system }],
232
+ messages: input.messages.map(toMessage),
233
+ inferenceConfig: { maxTokens: input.maxTokens ?? options.maxTokens ?? 4096 },
234
+ toolConfig:
235
+ input.tools.length === 0
236
+ ? undefined
237
+ : {
238
+ tools: input.tools.map((tool) => ({
239
+ toolSpec: {
240
+ name: tool.name,
241
+ description: tool.description,
242
+ inputSchema: { json: tool.inputSchema as DocumentType },
243
+ },
244
+ })),
245
+ },
246
+ }),
247
+ )
248
+
249
+ const parts: MessagePart[] = []
250
+ for (const item of response.output?.message?.content ?? []) {
251
+ if (typeof item.text === 'string' && item.text !== '') {
252
+ parts.push({ kind: 'text', text: item.text })
253
+ }
254
+ if (item.toolUse?.toolUseId && item.toolUse.name) {
255
+ parts.push({
256
+ kind: 'tool-use',
257
+ id: item.toolUse.toolUseId,
258
+ name: item.toolUse.name,
259
+ input: (item.toolUse.input as Record<string, unknown>) ?? {},
260
+ })
261
+ }
262
+ }
263
+ return {
264
+ message: { role: 'assistant', parts },
265
+ wantsTools: response.stopReason === 'tool_use',
266
+ }
267
+ },
268
+ }
269
+ }
package/src/review.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * What a reviewer sees before approving: the proposal against what is there now.
3
+ *
4
+ * The changeset stores the prepared request, not a snapshot of the old values, so
5
+ * the "before" side is read at review time. That is the right way round — it means
6
+ * the diff shown is against the entity as it stands when someone looks at it, not
7
+ * as it stood when the model was talking, and a proposal whose baseline has moved
8
+ * says so here rather than surprising them after they click Approve.
9
+ */
10
+ import type { ProposedChange, SchemaProposalPort } from './changeset.ts'
11
+ import { type ChangeKind, SCHEMA_KINDS } from './operations.ts'
12
+ import type { ManagementCall } from './tools.ts'
13
+
14
+ export interface FieldChange {
15
+ /** The property alias, or a field name for a type. */
16
+ name: string
17
+ /** Absent for a culture-invariant value. */
18
+ culture?: string | null
19
+ before: unknown
20
+ after: unknown
21
+ }
22
+
23
+ export interface ChangeReview {
24
+ kind: ChangeKind
25
+ /** True when nothing exists yet, so every field is new. */
26
+ creating: boolean
27
+ fields: FieldChange[]
28
+ /** Whole-text diff sides, for a template. */
29
+ source?: { before: string; after: string }
30
+ /** Set when the entity moved after the proposal was made. */
31
+ stale: boolean
32
+ }
33
+
34
+ /** Which read gives the current state, and the part of it that matters. */
35
+ const CURRENT: Partial<Record<ChangeKind, string>> = {
36
+ document: 'GetDocumentById',
37
+ 'document-type': 'GetDocumentTypeById',
38
+ 'data-type': 'GetDataTypeById',
39
+ template: 'GetTemplateById',
40
+ }
41
+
42
+ const digest = (value: unknown): string =>
43
+ new Bun.CryptoHasher('sha256').update(JSON.stringify(value ?? null)).digest('hex')
44
+
45
+ interface ValueEntry {
46
+ alias: string
47
+ value?: unknown
48
+ culture?: string | null
49
+ segment?: string | null
50
+ }
51
+
52
+ interface VariantEntry {
53
+ culture?: string | null
54
+ segment?: string | null
55
+ name?: string
56
+ }
57
+
58
+ const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
59
+
60
+ const variantKey = (variant: VariantEntry) => `${variant.culture ?? ''}|${variant.segment ?? ''}`
61
+
62
+ /**
63
+ * Renames, as fields, so they appear in the list a reviewer reads.
64
+ *
65
+ * A document proposal carries the whole `variants` array because the update
66
+ * handler needs it, which means a rename can ride along inside what looks like a
67
+ * value edit. Compared per culture and segment, so a page that simply has
68
+ * different names in different languages is not reported as renamed.
69
+ */
70
+ export function variantChanges(
71
+ before: readonly VariantEntry[],
72
+ after: readonly VariantEntry[],
73
+ ): FieldChange[] {
74
+ const was = new Map(before.map((variant) => [variantKey(variant), variant.name ?? '']))
75
+ const changes: FieldChange[] = []
76
+ for (const variant of after) {
77
+ const previous = was.get(variantKey(variant))
78
+ const name = variant.name ?? ''
79
+ if (previous === undefined || previous === name) continue
80
+ changes.push({ name: 'Name', culture: variant.culture ?? null, before: previous, after: name })
81
+ }
82
+ return changes
83
+ }
84
+
85
+ /** Values that differ, in the proposal's order, with anything untouched left out. */
86
+ function valueChanges(before: readonly ValueEntry[], after: readonly ValueEntry[]): FieldChange[] {
87
+ const changes: FieldChange[] = []
88
+ for (const entry of after) {
89
+ const previous = before.find(
90
+ (candidate) =>
91
+ candidate.alias === entry.alias &&
92
+ (candidate.culture ?? null) === (entry.culture ?? null) &&
93
+ (candidate.segment ?? null) === (entry.segment ?? null),
94
+ )
95
+ if (same(previous?.value, entry.value)) continue
96
+ changes.push({
97
+ name: entry.alias,
98
+ culture: entry.culture ?? null,
99
+ before: previous?.value,
100
+ after: entry.value,
101
+ })
102
+ }
103
+ return changes
104
+ }
105
+
106
+ /**
107
+ * Fields of a type that differ. Compared as JSON at one level, which reads well
108
+ * for the scalars that matter (name, alias, icon) and honestly for the lists
109
+ * (properties, compositions) without pretending to a structural diff.
110
+ */
111
+ function bodyChanges(before: unknown, after: unknown): FieldChange[] {
112
+ const previous = (before ?? {}) as Record<string, unknown>
113
+ const proposed = (after ?? {}) as Record<string, unknown>
114
+ const changes: FieldChange[] = []
115
+ for (const [name, value] of Object.entries(proposed)) {
116
+ if (same(previous[name], value)) continue
117
+ changes.push({ name, before: previous[name], after: value })
118
+ }
119
+ return changes
120
+ }
121
+
122
+ export async function reviewChange(
123
+ change: ProposedChange,
124
+ options: {
125
+ call: ManagementCall
126
+ operationPath: (operationId: string) => string | undefined
127
+ /** Reads the schema file a TOML proposal would replace. */
128
+ schema?: SchemaProposalPort
129
+ },
130
+ ): Promise<ChangeReview> {
131
+ const creating = change.kind.endsWith('-create')
132
+ const body = (change.body ?? {}) as Record<string, unknown>
133
+
134
+ // A schema proposal is a file, so it reads as one: the TOML on disk beside the
135
+ // TOML that would replace it.
136
+ if (SCHEMA_KINDS.has(change.kind)) {
137
+ const alias = typeof body.alias === 'string' ? body.alias : ''
138
+ const before = (await options.schema?.read(change.kind, alias)) ?? ''
139
+ const after = typeof body.toml === 'string' ? body.toml : ''
140
+ return {
141
+ kind: change.kind,
142
+ creating,
143
+ fields: [],
144
+ source: { before, after },
145
+ stale: change.baseline !== undefined && digest(before) !== change.baseline,
146
+ }
147
+ }
148
+
149
+ if (creating) {
150
+ const fields =
151
+ change.kind === 'document-create'
152
+ ? valueChanges([], (body.values as ValueEntry[]) ?? [])
153
+ : bodyChanges(
154
+ {},
155
+ change.kind === 'template-create' ? { ...body, content: undefined } : body,
156
+ )
157
+ const review: ChangeReview = { kind: change.kind, creating: true, fields, stale: false }
158
+ if (change.kind === 'template-create') {
159
+ review.source = { before: '', after: String(body.content ?? '') }
160
+ }
161
+ return review
162
+ }
163
+
164
+ const operationId = CURRENT[change.kind]
165
+ const template = operationId ? options.operationPath(operationId) : undefined
166
+ if (!operationId || !template) {
167
+ return { kind: change.kind, creating: false, fields: [], stale: false }
168
+ }
169
+
170
+ const path = template.replace('{id}', encodeURIComponent(change.params.id ?? ''))
171
+ const current = await options.call({ method: 'get', path })
172
+ if (current.status !== 200) {
173
+ return { kind: change.kind, creating: false, fields: [], stale: true }
174
+ }
175
+
176
+ if (change.kind === 'template') {
177
+ const before = String((current.body as { content?: string | null }).content ?? '')
178
+ return {
179
+ kind: change.kind,
180
+ creating: false,
181
+ fields: [],
182
+ source: { before, after: String(body.content ?? '') },
183
+ stale: change.baseline !== undefined && digest(before) !== change.baseline,
184
+ }
185
+ }
186
+
187
+ const stale = change.baseline !== undefined && digest(current.body) !== change.baseline
188
+
189
+ if (change.kind === 'document') {
190
+ const now = current.body as { values?: ValueEntry[]; variants?: VariantEntry[] }
191
+ return {
192
+ kind: change.kind,
193
+ creating: false,
194
+ fields: [
195
+ // Renames first: a page's name is not a property value, so without this
196
+ // the diff a reviewer approves would not show one at all.
197
+ ...variantChanges(now.variants ?? [], (body.variants as VariantEntry[]) ?? []),
198
+ ...valueChanges(now.values ?? [], (body.values as ValueEntry[]) ?? []),
199
+ ],
200
+ stale,
201
+ }
202
+ }
203
+
204
+ return { kind: change.kind, creating: false, fields: bodyChanges(current.body, body), stale }
205
+ }