@aws-blocks/bb-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +174 -0
- package/README.md +801 -0
- package/dist/agent.aws.d.ts +7 -0
- package/dist/agent.aws.d.ts.map +1 -0
- package/dist/agent.aws.js +9 -0
- package/dist/agent.d.ts +121 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +588 -0
- package/dist/agent.mock.d.ts +7 -0
- package/dist/agent.mock.d.ts.map +1 -0
- package/dist/agent.mock.js +12 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +40 -0
- package/dist/file-bucket-snapshot-storage.d.ts +49 -0
- package/dist/file-bucket-snapshot-storage.d.ts.map +1 -0
- package/dist/file-bucket-snapshot-storage.js +84 -0
- package/dist/index.aws.d.ts +5 -0
- package/dist/index.aws.d.ts.map +1 -0
- package/dist/index.aws.js +5 -0
- package/dist/index.browser.d.ts +4 -0
- package/dist/index.browser.d.ts.map +1 -0
- package/dist/index.browser.js +8 -0
- package/dist/index.cdk.d.ts +15 -0
- package/dist/index.cdk.d.ts.map +1 -0
- package/dist/index.cdk.js +60 -0
- package/dist/index.hooks.d.ts +122 -0
- package/dist/index.hooks.d.ts.map +1 -0
- package/dist/index.hooks.js +179 -0
- package/dist/index.mock.d.ts +5 -0
- package/dist/index.mock.d.ts.map +1 -0
- package/dist/index.mock.js +5 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +864 -0
- package/dist/model-factory.d.ts +26 -0
- package/dist/model-factory.d.ts.map +1 -0
- package/dist/model-factory.js +197 -0
- package/dist/models.d.ts +83 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +84 -0
- package/dist/providers/canned.d.ts +32 -0
- package/dist/providers/canned.d.ts.map +1 -0
- package/dist/providers/canned.js +187 -0
- package/dist/providers/throwing.d.ts +10 -0
- package/dist/providers/throwing.d.ts.map +1 -0
- package/dist/providers/throwing.js +16 -0
- package/dist/schemas.d.ts +59 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +36 -0
- package/dist/types.d.ts +295 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/package.json +59 -0
- package/src/agent.aws.ts +13 -0
- package/src/agent.mock.ts +16 -0
- package/src/agent.ts +604 -0
- package/src/errors.ts +44 -0
- package/src/file-bucket-snapshot-storage.ts +85 -0
- package/src/index.aws.ts +7 -0
- package/src/index.browser.ts +10 -0
- package/src/index.cdk.ts +70 -0
- package/src/index.hooks.ts +256 -0
- package/src/index.mock.ts +7 -0
- package/src/index.test.ts +1010 -0
- package/src/model-factory.ts +228 -0
- package/src/models.ts +88 -0
- package/src/providers/canned.ts +205 -0
- package/src/providers/throwing.ts +19 -0
- package/src/schemas.ts +40 -0
- package/src/types.ts +311 -0
- package/src/version.ts +3 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { BedrockModel, type Model } from '@strands-agents/sdk';
|
|
5
|
+
import type { BaseModelConfig } from '@strands-agents/sdk';
|
|
6
|
+
import { OpenAIModel } from '@strands-agents/sdk/models/openai';
|
|
7
|
+
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
8
|
+
import { CannedProvider } from './providers/canned.js';
|
|
9
|
+
import { ThrowingProvider } from './providers/throwing.js';
|
|
10
|
+
import type { ModelConfig } from './types.js';
|
|
11
|
+
import { AgentErrors, blocksAgentError } from './errors.js';
|
|
12
|
+
|
|
13
|
+
// TODO: validate model-specific inference config (e.g., some models don't support topP with temperature)
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Checks if a model endpoint is available and the specified model exists.
|
|
17
|
+
* Verifies endpoint/model availability only. Does not guarantee EULA acceptance or feature support (e.g. tool calling).
|
|
18
|
+
* Even calls to verified models can fail at invocation time (e.g. legacy models, quota limits) — always check error logs.
|
|
19
|
+
* For openai-api: pings GET /v1/models and checks if modelId is in the list.
|
|
20
|
+
* For bedrock: verifies model availability via @aws-sdk/client-bedrock (free, no inference cost).
|
|
21
|
+
* For canned: always returns true.
|
|
22
|
+
*/
|
|
23
|
+
/** @internal Injectable client interface for testing. */
|
|
24
|
+
export interface BedrockHealthClient {
|
|
25
|
+
send(command: any): Promise<any>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function checkModelHealth(config: ModelConfig, log: ChildLogger, _testClient?: BedrockHealthClient): Promise<boolean> {
|
|
29
|
+
if (!config || config.provider === 'canned') {
|
|
30
|
+
log.info('Using canned provider (local mock, no real model)');
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if ((config.provider as string) === 'throwing') {
|
|
34
|
+
log.info('Using throwing provider (test-only)');
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
log.info(`Checking model health: ${config.provider}${config.modelId ? ` (${config.modelId})` : ''}`);
|
|
38
|
+
if (config.provider === 'bedrock') {
|
|
39
|
+
const isCrossRegionProfile = config.modelId && /^(us-gov|us|eu|apac)\./.test(config.modelId);
|
|
40
|
+
|
|
41
|
+
const getClient = async (): Promise<BedrockHealthClient> => {
|
|
42
|
+
if (_testClient) return _testClient;
|
|
43
|
+
const { BedrockClient } = await import('@aws-sdk/client-bedrock');
|
|
44
|
+
return new BedrockClient({});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Cross-region inference profiles use GetInferenceProfile instead of GetFoundationModel.
|
|
48
|
+
if (isCrossRegionProfile) {
|
|
49
|
+
try {
|
|
50
|
+
const client = await getClient();
|
|
51
|
+
const command = _testClient
|
|
52
|
+
? { inferenceProfileIdentifier: config.modelId }
|
|
53
|
+
: new (await import('@aws-sdk/client-bedrock')).GetInferenceProfileCommand({ inferenceProfileIdentifier: config.modelId });
|
|
54
|
+
const res = await client.send(command);
|
|
55
|
+
if (res.inferenceProfileName) {
|
|
56
|
+
log.info(`Inference profile '${config.modelId}' available`);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
} catch (err: unknown) {
|
|
61
|
+
const e = err as { name?: string; message?: string };
|
|
62
|
+
log.warn(`Inference profile health check failed for '${config.modelId}': ${e.name ?? e.message}`, { provider: config.provider, modelId: config.modelId });
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const client = await getClient();
|
|
69
|
+
const command = _testClient
|
|
70
|
+
? { modelIdentifier: config.modelId }
|
|
71
|
+
: new (await import('@aws-sdk/client-bedrock')).GetFoundationModelCommand({ modelIdentifier: config.modelId });
|
|
72
|
+
const res = await client.send(command);
|
|
73
|
+
if (res.modelDetails) {
|
|
74
|
+
log.info(`Bedrock model '${config.modelId}' exists in catalog`);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
} catch (err: unknown) {
|
|
79
|
+
const e = err as { name?: string; message?: string };
|
|
80
|
+
// GetFoundationModel throws for unknown models (ValidationException / ResourceNotFoundException).
|
|
81
|
+
if (e.name === 'ValidationException' || e.name === 'ResourceNotFoundException') {
|
|
82
|
+
try {
|
|
83
|
+
const client = await getClient();
|
|
84
|
+
const listCommand = _testClient
|
|
85
|
+
? {}
|
|
86
|
+
: new (await import('@aws-sdk/client-bedrock')).ListFoundationModelsCommand({});
|
|
87
|
+
const list = await client.send(listCommand);
|
|
88
|
+
const available = list.modelSummaries?.map((m: { modelId?: string }) => m.modelId).filter(Boolean) ?? [];
|
|
89
|
+
log.warn(`Bedrock model '${config.modelId}' not found. Available: ${available.slice(0, 10).join(', ')}${available.length > 10 ? ` (+${available.length - 10} more)` : ''}`);
|
|
90
|
+
} catch {
|
|
91
|
+
log.warn(`Bedrock model '${config.modelId}' not found. Could not list available models.`);
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
log.warn(`Bedrock health check failed: ${e.name ?? e.message}. Verify AWS credentials are configured.`, { provider: config.provider, modelId: config.modelId });
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (config.provider === 'openai-api') {
|
|
101
|
+
const endpoint = config.endpoint ?? 'https://api.openai.com/v1';
|
|
102
|
+
const baseUrl = endpoint.endsWith('/') ? endpoint.slice(0, -1) : endpoint;
|
|
103
|
+
const url = `${baseUrl}/models`;
|
|
104
|
+
|
|
105
|
+
const resolvedKey = typeof config.apiKey === 'function'
|
|
106
|
+
? await config.apiKey()
|
|
107
|
+
: config.apiKey ?? process.env.OPENAI_API_KEY;
|
|
108
|
+
|
|
109
|
+
// 1. Check if endpoint is reachable
|
|
110
|
+
let res: Response;
|
|
111
|
+
try {
|
|
112
|
+
res = await fetch(url, {
|
|
113
|
+
method: 'GET',
|
|
114
|
+
headers: resolvedKey ? { Authorization: `Bearer ${resolvedKey}` } : {},
|
|
115
|
+
signal: AbortSignal.timeout(3000),
|
|
116
|
+
});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log.warn(`Endpoint unreachable: ${baseUrl}`, { provider: config.provider, error: (err as Error).message });
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
log.warn(`Endpoint returned HTTP ${res.status}: ${baseUrl}`, { provider: config.provider, status: res.status });
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 2. Endpoint is up — check if specified model exists
|
|
128
|
+
if (!config.modelId) return true;
|
|
129
|
+
|
|
130
|
+
// Parse the model list defensively. A 200 response is not a guarantee of a
|
|
131
|
+
// JSON body: misconfigured proxies, captive portals, or a non-OpenAI server
|
|
132
|
+
// sharing the URL can return HTML or plain text. JSON.parse throws on such
|
|
133
|
+
// bodies — if that escaped, it would abort the model fallback loop in
|
|
134
|
+
// createStrandsAgent() and prevent the implicit canned fallback from ever
|
|
135
|
+
// running. Treat an unparseable response as "unhealthy" (return false) so the
|
|
136
|
+
// next candidate is tried, matching the fetch-failure handling above. Read the
|
|
137
|
+
// raw text first so we can log a short snippet of the offending body, which
|
|
138
|
+
// makes a misconfigured proxy / captive portal obvious during debugging.
|
|
139
|
+
let body: { data?: Array<{ id: string }> };
|
|
140
|
+
let text = '';
|
|
141
|
+
try {
|
|
142
|
+
text = await res.text();
|
|
143
|
+
} catch (err) {
|
|
144
|
+
log.warn(`Failed to read model-list response body: ${baseUrl}`, { provider: config.provider, error: (err as Error).message });
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
body = JSON.parse(text) as { data?: Array<{ id: string }> };
|
|
149
|
+
} catch (err) {
|
|
150
|
+
log.warn(
|
|
151
|
+
`Endpoint returned a non-JSON body, make sure this is a valid OpenAI-compatible server: ${baseUrl}`,
|
|
152
|
+
{ provider: config.provider, error: (err as Error).message, bodySnippet: text.slice(0, 100) },
|
|
153
|
+
);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
const availableModels = body.data?.map(m => m.id) ?? [];
|
|
157
|
+
|
|
158
|
+
if (availableModels.includes(config.modelId)) {
|
|
159
|
+
log.info(`Model '${config.modelId}' available at ${baseUrl}`);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 3. Model not found — log what IS available
|
|
164
|
+
log.warn(`Model '${config.modelId}' not found at ${baseUrl}. Available: ${availableModels.join(', ') || 'none'}`, { provider: config.provider, modelId: config.modelId, availableModels });
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Maps Blocks' ModelConfig to the corresponding Strands model provider.
|
|
173
|
+
* The developer configures one unified ModelConfig shape — this factory
|
|
174
|
+
* translates it to BedrockModel, OpenAIModel, or CannedProvider internally.
|
|
175
|
+
*
|
|
176
|
+
* @see https://strandsagents.com/docs/user-guide/concepts/model-providers/
|
|
177
|
+
*/
|
|
178
|
+
export async function createStrandsModel(config?: ModelConfig, log?: ChildLogger): Promise<Model<BaseModelConfig>> {
|
|
179
|
+
if (!config || config.provider === 'canned') return new CannedProvider();
|
|
180
|
+
|
|
181
|
+
// Test-only provider — throws mid-stream to verify error handling
|
|
182
|
+
if (config.provider === 'throwing' as string) {
|
|
183
|
+
log?.warn('ThrowingProvider is only for internal test purposes');
|
|
184
|
+
return new ThrowingProvider();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (config.provider === 'bedrock') {
|
|
188
|
+
if (!config.modelId) {
|
|
189
|
+
throw blocksAgentError(AgentErrors.InvalidModelConfig, "Model provider 'bedrock' requires modelId.");
|
|
190
|
+
}
|
|
191
|
+
return new BedrockModel({
|
|
192
|
+
modelId: config.modelId,
|
|
193
|
+
...(config.inferenceConfig && {
|
|
194
|
+
temperature: config.inferenceConfig.temperature,
|
|
195
|
+
topP: config.inferenceConfig.topP,
|
|
196
|
+
maxTokens: config.inferenceConfig.maxTokens,
|
|
197
|
+
stopSequences: config.inferenceConfig.stopSequences,
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (config.provider === 'openai-api') {
|
|
203
|
+
if (!config.modelId) {
|
|
204
|
+
throw blocksAgentError(AgentErrors.InvalidModelConfig, "Model provider 'openai-api' requires modelId.");
|
|
205
|
+
}
|
|
206
|
+
// Resolve apiKey: string, async function, or env var fallback
|
|
207
|
+
const apiKey = typeof config.apiKey === 'function' ? await config.apiKey() : config.apiKey;
|
|
208
|
+
if (!apiKey && !process.env.OPENAI_API_KEY) {
|
|
209
|
+
throw blocksAgentError(AgentErrors.InvalidModelConfig, "provider 'openai-api' requires apiKey or OPENAI_API_KEY environment variable.");
|
|
210
|
+
}
|
|
211
|
+
return new OpenAIModel({
|
|
212
|
+
api: 'chat',
|
|
213
|
+
apiKey: apiKey ?? '',
|
|
214
|
+
...(config.endpoint && { clientConfig: { baseURL: config.endpoint } }),
|
|
215
|
+
modelId: config.modelId,
|
|
216
|
+
...(config.inferenceConfig && {
|
|
217
|
+
temperature: config.inferenceConfig.temperature,
|
|
218
|
+
topP: config.inferenceConfig.topP,
|
|
219
|
+
maxTokens: config.inferenceConfig.maxTokens,
|
|
220
|
+
...(config.inferenceConfig.stopSequences && {
|
|
221
|
+
params: { stop: config.inferenceConfig.stopSequences },
|
|
222
|
+
}),
|
|
223
|
+
}),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
throw blocksAgentError(AgentErrors.InvalidModelConfig, `Unknown provider: '${config.provider}'.`);
|
|
228
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import type { ModelConfig } from './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pre-configured Bedrock model presets using cross-region inference profiles.
|
|
8
|
+
* Names are capability-based so the underlying model can be upgraded without breaking user code.
|
|
9
|
+
*/
|
|
10
|
+
export const BedrockModels = {
|
|
11
|
+
/** Highest capability and best performance. Recommended default. Currently: Claude Opus 4.8. */
|
|
12
|
+
DEFAULT: {
|
|
13
|
+
provider: 'bedrock',
|
|
14
|
+
modelId: 'us.anthropic.claude-opus-4-8-20250610-v1:0',
|
|
15
|
+
},
|
|
16
|
+
/** Strong quality/cost balance. Currently: Claude Sonnet 4. */
|
|
17
|
+
BALANCED: {
|
|
18
|
+
provider: 'bedrock',
|
|
19
|
+
modelId: 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
|
20
|
+
},
|
|
21
|
+
/** Fastest and lowest latency. Currently: Claude Haiku 4.5. */
|
|
22
|
+
FAST: {
|
|
23
|
+
provider: 'bedrock',
|
|
24
|
+
modelId: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
|
25
|
+
},
|
|
26
|
+
/** Low cost per token with acceptable quality. Currently: Amazon Nova Pro. */
|
|
27
|
+
BUDGET: {
|
|
28
|
+
provider: 'bedrock',
|
|
29
|
+
modelId: 'us.amazon.nova-pro-v1:0',
|
|
30
|
+
},
|
|
31
|
+
/** Ultra-cheap for simple tasks. Currently: Amazon Nova Lite. */
|
|
32
|
+
MICRO: {
|
|
33
|
+
provider: 'bedrock',
|
|
34
|
+
modelId: 'us.amazon.nova-lite-v1:0',
|
|
35
|
+
},
|
|
36
|
+
} as const satisfies Record<string, ModelConfig>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pre-configured Ollama model presets for local development.
|
|
40
|
+
* These are convenience shortcuts that use the `openai-api` provider under the hood.
|
|
41
|
+
*
|
|
42
|
+
* **Requirements:**
|
|
43
|
+
* - Ollama must be installed and running (`ollama serve`)
|
|
44
|
+
* - The model must be pulled first (`ollama pull <modelId>`)
|
|
45
|
+
* - Assumes the default Ollama endpoint: `http://localhost:11434/v1`
|
|
46
|
+
*
|
|
47
|
+
* If your Ollama runs on a different port or host, use the `openai-api` provider directly:
|
|
48
|
+
* ```ts
|
|
49
|
+
* { provider: 'openai-api', modelId: 'llama3.1:8b', endpoint: 'http://custom-host:11434/v1', apiKey: 'ollama' }
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export const OllamaModels = {
|
|
53
|
+
/** Fast and lightweight for quick iteration. Currently: Llama 3.2 3B (~2 GB, needs 4 GB VRAM). */
|
|
54
|
+
XSMALL: {
|
|
55
|
+
provider: 'openai-api',
|
|
56
|
+
modelId: 'llama3.2:3b',
|
|
57
|
+
endpoint: 'http://localhost:11434/v1',
|
|
58
|
+
apiKey: 'ollama',
|
|
59
|
+
},
|
|
60
|
+
/** Good balance of speed and capability. Currently: Llama 3.1 8B (~4.7 GB, needs 8 GB VRAM). */
|
|
61
|
+
SMALL: {
|
|
62
|
+
provider: 'openai-api',
|
|
63
|
+
modelId: 'llama3.1:8b',
|
|
64
|
+
endpoint: 'http://localhost:11434/v1',
|
|
65
|
+
apiKey: 'ollama',
|
|
66
|
+
},
|
|
67
|
+
/** Strong reasoning at moderate size. Currently: DeepSeek R1 14B (~9 GB, needs 16 GB VRAM). */
|
|
68
|
+
MEDIUM: {
|
|
69
|
+
provider: 'openai-api',
|
|
70
|
+
modelId: 'deepseek-r1:14b',
|
|
71
|
+
endpoint: 'http://localhost:11434/v1',
|
|
72
|
+
apiKey: 'ollama',
|
|
73
|
+
},
|
|
74
|
+
/** High quality for complex tasks. Currently: Llama 3.3 70B (~43 GB, needs 48 GB+ VRAM). */
|
|
75
|
+
LARGE: {
|
|
76
|
+
provider: 'openai-api',
|
|
77
|
+
modelId: 'llama3.3:70b',
|
|
78
|
+
endpoint: 'http://localhost:11434/v1',
|
|
79
|
+
apiKey: 'ollama',
|
|
80
|
+
},
|
|
81
|
+
/** Largest local model. Currently: Llama 4 Scout (~67 GB, needs 80 GB+ VRAM). */
|
|
82
|
+
XLARGE: {
|
|
83
|
+
provider: 'openai-api',
|
|
84
|
+
modelId: 'llama4:16x17b',
|
|
85
|
+
endpoint: 'http://localhost:11434/v1',
|
|
86
|
+
apiKey: 'ollama',
|
|
87
|
+
},
|
|
88
|
+
} as const satisfies Record<string, ModelConfig>;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CannedProvider — a fake Strands model provider for local dev.
|
|
6
|
+
* Returns keyword-based responses without calling any real model.
|
|
7
|
+
* Speaks the same ModelStreamEvent protocol as Bedrock/OpenAI,
|
|
8
|
+
* so Strands processes it identically to a real provider.
|
|
9
|
+
*
|
|
10
|
+
* Tool call support: if the prompt mentions a tool name from the available toolSpecs,
|
|
11
|
+
* emits toolUse events so Strands executes the tool. On the follow-up call (with tool
|
|
12
|
+
* result in messages), emits a simple text summary.
|
|
13
|
+
*
|
|
14
|
+
* @see https://strandsagents.com/docs/user-guide/concepts/model-providers/custom_model_provider/
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Model } from '@strands-agents/sdk';
|
|
18
|
+
import type { Message, ModelStreamEvent, StreamOptions } from '@strands-agents/sdk';
|
|
19
|
+
import { ToolResultBlock } from '@strands-agents/sdk';
|
|
20
|
+
|
|
21
|
+
interface CannedConfig {
|
|
22
|
+
modelId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CANNED_RESPONSES: Record<string, string> = {
|
|
26
|
+
weather: 'The weather is 22°C and sunny. [canned response]',
|
|
27
|
+
order: 'Order #12345 has been shipped and is on its way. [canned response]',
|
|
28
|
+
help: 'I can help you with weather, orders, and general questions. [canned response]',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const DEFAULT_RESPONSE = 'This is a canned mock response. No real model was called. [canned]';
|
|
32
|
+
|
|
33
|
+
function matchResponse(prompt: string): string {
|
|
34
|
+
const lower = prompt.toLowerCase();
|
|
35
|
+
for (const [keyword, response] of Object.entries(CANNED_RESPONSES)) {
|
|
36
|
+
if (lower.includes(keyword)) return response;
|
|
37
|
+
}
|
|
38
|
+
return DEFAULT_RESPONSE;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Match a single word against the prompt on word boundaries (case-insensitive).
|
|
43
|
+
* Uses `\b...\b` rather than substring `includes()` so a tool word like "cat"
|
|
44
|
+
* (from `getCat`) is NOT triggered by an unrelated word like "category", and
|
|
45
|
+
* "pass" (from `getPass`) is not triggered by "password". The word is regex-
|
|
46
|
+
* escaped so punctuation in tool names can't break the pattern.
|
|
47
|
+
*/
|
|
48
|
+
function promptMentionsWord(lowerPrompt: string, word: string): boolean {
|
|
49
|
+
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
50
|
+
return new RegExp(`\\b${escaped}\\b`).test(lowerPrompt);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Find ALL tools mentioned in the prompt (for parallel tool calls). */
|
|
54
|
+
function findAllToolMatches(prompt: string, toolSpecs?: { name: string }[]): string[] {
|
|
55
|
+
if (!toolSpecs?.length) return [];
|
|
56
|
+
const lower = prompt.toLowerCase();
|
|
57
|
+
return toolSpecs.filter(t => {
|
|
58
|
+
const name = t.name.toLowerCase();
|
|
59
|
+
if (promptMentionsWord(lower, name)) return true;
|
|
60
|
+
// Split camelCase into words (getWeather -> "get weather") and match each
|
|
61
|
+
// on word boundaries. Skip short words (<=2 chars) to avoid noise.
|
|
62
|
+
const words = t.name.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().split(' ');
|
|
63
|
+
return words.some(w => w.length > 2 && promptMentionsWord(lower, w));
|
|
64
|
+
}).map(t => t.name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Check if the last message contains a tool result — means we're in the follow-up after a tool call. */
|
|
68
|
+
function hasToolResult(messages: Message[]): boolean {
|
|
69
|
+
const last = messages[messages.length - 1];
|
|
70
|
+
return last?.content?.some((block: any) =>
|
|
71
|
+
'toolResult' in block || block.type === 'toolResultBlock' || ('toolUseId' in block && 'status' in block)
|
|
72
|
+
) ?? false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Extract tool result text from the last message. */
|
|
76
|
+
function getToolResultText(messages: Message[]): string {
|
|
77
|
+
const last = messages[messages.length - 1];
|
|
78
|
+
const results: string[] = [];
|
|
79
|
+
for (const block of last?.content ?? []) {
|
|
80
|
+
const b = block as any;
|
|
81
|
+
if ('toolResult' in b || b.type === 'toolResultBlock' || ('toolUseId' in b && 'status' in b)) {
|
|
82
|
+
const content = b.toolResult?.content ?? b.content ?? [];
|
|
83
|
+
if (!Array.isArray(content)) { results.push(String(content)); continue; }
|
|
84
|
+
results.push(content.map((c: any) => c.text ?? JSON.stringify(c)).join(' '));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return results.join(' | ');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Generate placeholder input from a JSON Schema. Produces values that pass validation. */
|
|
91
|
+
function generatePlaceholderInput(schema: any): any {
|
|
92
|
+
if (!schema || typeof schema !== 'object') return {};
|
|
93
|
+
if (schema.type === 'object' && schema.properties) {
|
|
94
|
+
const result: Record<string, any> = {};
|
|
95
|
+
for (const [key, prop] of Object.entries(schema.properties) as [string, any][]) {
|
|
96
|
+
if (prop.type === 'string') {
|
|
97
|
+
if (prop.enum?.length) result[key] = prop.enum[0];
|
|
98
|
+
else result[key] = 'sample';
|
|
99
|
+
} else if (prop.type === 'number' || prop.type === 'integer') {
|
|
100
|
+
result[key] = 1;
|
|
101
|
+
} else if (prop.type === 'boolean') {
|
|
102
|
+
result[key] = true;
|
|
103
|
+
} else if (prop.type === 'array') {
|
|
104
|
+
result[key] = [];
|
|
105
|
+
} else if (prop.type === 'object') {
|
|
106
|
+
result[key] = generatePlaceholderInput(prop);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Look up a tool's inputSchema from toolSpecs and generate placeholder input. */
|
|
115
|
+
function getToolInput(toolName: string, toolSpecs?: { name: string; inputSchema?: any }[]): string {
|
|
116
|
+
const spec = toolSpecs?.find(t => t.name === toolName);
|
|
117
|
+
if (!spec?.inputSchema) return '{}';
|
|
118
|
+
return JSON.stringify(generatePlaceholderInput(spec.inputSchema));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let toolCallCounter = 0;
|
|
122
|
+
|
|
123
|
+
export class CannedProvider extends Model<CannedConfig> {
|
|
124
|
+
private config: CannedConfig;
|
|
125
|
+
|
|
126
|
+
constructor(config?: Partial<CannedConfig>) {
|
|
127
|
+
super();
|
|
128
|
+
this.config = { modelId: config?.modelId ?? 'canned-mock' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
updateConfig(config: Partial<CannedConfig>): void {
|
|
132
|
+
Object.assign(this.config, config);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getConfig(): CannedConfig {
|
|
136
|
+
return { ...this.config };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async *stream(messages: Message[], options?: StreamOptions): AsyncIterable<ModelStreamEvent> {
|
|
140
|
+
const lastMessage = messages[messages.length - 1];
|
|
141
|
+
const prompt = lastMessage?.content
|
|
142
|
+
?.map((block) => ('text' in block ? block.text : ''))
|
|
143
|
+
.join('') ?? '';
|
|
144
|
+
|
|
145
|
+
// Follow-up after tool execution — Strands sends the tool result back to the model
|
|
146
|
+
if (hasToolResult(messages)) {
|
|
147
|
+
const resultText = getToolResultText(messages);
|
|
148
|
+
yield* this.emitText(`I called the tool. Output: ${resultText} [canned tool response]`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Check if prompt mentions tool names — trigger tool call(s)
|
|
153
|
+
const toolMatches = findAllToolMatches(prompt, options?.toolSpecs);
|
|
154
|
+
if (toolMatches.length > 1) {
|
|
155
|
+
yield* this.emitParallelToolCalls(toolMatches, options?.toolSpecs);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const toolName = toolMatches[0];
|
|
159
|
+
if (toolName) {
|
|
160
|
+
yield* this.emitToolCall(toolName, options?.toolSpecs);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Default: keyword-based text response
|
|
165
|
+
|
|
166
|
+
// Default: keyword-based text response
|
|
167
|
+
yield* this.emitText(matchResponse(prompt));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Emit a text response as ModelStreamEvents. */
|
|
171
|
+
private async *emitText(response: string): AsyncIterable<ModelStreamEvent> {
|
|
172
|
+
yield { type: 'modelMessageStartEvent', role: 'assistant' };
|
|
173
|
+
yield { type: 'modelContentBlockStartEvent' };
|
|
174
|
+
for (const word of response.split(' ')) {
|
|
175
|
+
yield { type: 'modelContentBlockDeltaEvent', delta: { type: 'textDelta', text: word + ' ' } };
|
|
176
|
+
}
|
|
177
|
+
yield { type: 'modelContentBlockStopEvent' };
|
|
178
|
+
yield { type: 'modelMessageStopEvent', stopReason: 'endTurn' };
|
|
179
|
+
yield { type: 'modelMetadataEvent', usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, metrics: { latencyMs: 0 } };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Emit multiple tool calls in one message (parallel execution). */
|
|
183
|
+
private async *emitParallelToolCalls(toolNames: string[], toolSpecs?: { name: string; inputSchema?: any }[]): AsyncIterable<ModelStreamEvent> {
|
|
184
|
+
yield { type: 'modelMessageStartEvent', role: 'assistant' };
|
|
185
|
+
for (const toolName of toolNames) {
|
|
186
|
+
const toolUseId = `canned-tool-${++toolCallCounter}`;
|
|
187
|
+
yield { type: 'modelContentBlockStartEvent', start: { type: 'toolUseStart', name: toolName, toolUseId } };
|
|
188
|
+
yield { type: 'modelContentBlockDeltaEvent', delta: { type: 'toolUseInputDelta', input: getToolInput(toolName, toolSpecs) } };
|
|
189
|
+
yield { type: 'modelContentBlockStopEvent' };
|
|
190
|
+
}
|
|
191
|
+
yield { type: 'modelMessageStopEvent', stopReason: 'toolUse' };
|
|
192
|
+
yield { type: 'modelMetadataEvent', usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, metrics: { latencyMs: 0 } };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Emit a tool call as ModelStreamEvents. Strands executes the tool and calls stream() again with the result. */
|
|
196
|
+
private async *emitToolCall(toolName: string, toolSpecs?: { name: string; inputSchema?: any }[]): AsyncIterable<ModelStreamEvent> {
|
|
197
|
+
const toolUseId = `canned-tool-${++toolCallCounter}`;
|
|
198
|
+
yield { type: 'modelMessageStartEvent', role: 'assistant' };
|
|
199
|
+
yield { type: 'modelContentBlockStartEvent', start: { type: 'toolUseStart', name: toolName, toolUseId } };
|
|
200
|
+
yield { type: 'modelContentBlockDeltaEvent', delta: { type: 'toolUseInputDelta', input: getToolInput(toolName, toolSpecs) } };
|
|
201
|
+
yield { type: 'modelContentBlockStopEvent' };
|
|
202
|
+
yield { type: 'modelMessageStopEvent', stopReason: 'toolUse' };
|
|
203
|
+
yield { type: 'modelMetadataEvent', usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, metrics: { latencyMs: 0 } };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ThrowingProvider — extends CannedProvider but throws mid-stream.
|
|
6
|
+
* Used in unit tests to verify block buffer flush on error.
|
|
7
|
+
*/
|
|
8
|
+
import { CannedProvider } from './canned.js';
|
|
9
|
+
import type { Message, ModelStreamEvent, StreamOptions } from '@strands-agents/sdk';
|
|
10
|
+
|
|
11
|
+
export class ThrowingProvider extends CannedProvider {
|
|
12
|
+
async *stream(_messages: Message[], _options?: StreamOptions): AsyncIterable<ModelStreamEvent> {
|
|
13
|
+
yield { type: 'modelMessageStartEvent', role: 'assistant' };
|
|
14
|
+
yield { type: 'modelContentBlockStartEvent' };
|
|
15
|
+
yield { type: 'modelContentBlockDeltaEvent', delta: { type: 'textDelta', text: 'partial ' } };
|
|
16
|
+
yield { type: 'modelContentBlockDeltaEvent', delta: { type: 'textDelta', text: 'text' } };
|
|
17
|
+
throw new Error('simulated mid-stream failure');
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
/** Schema for conversation metadata stored in DistributedTable (Table 1). */
|
|
7
|
+
export const conversationSchema = z.object({
|
|
8
|
+
userId: z.string(),
|
|
9
|
+
conversationId: z.string(),
|
|
10
|
+
name: z.string(),
|
|
11
|
+
createdAt: z.number(),
|
|
12
|
+
updatedAt: z.number(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** Schema for messages stored in DistributedTable (Table 2). */
|
|
16
|
+
export const messageSchema = z.object({
|
|
17
|
+
conversationId: z.string(),
|
|
18
|
+
messageId: z.string(),
|
|
19
|
+
role: z.enum(['user', 'assistant', 'tool-call', 'tool-result', 'approval', 'interrupt']),
|
|
20
|
+
content: z.string(),
|
|
21
|
+
contentType: z.enum(['text', 'image', 'audio', 'video', 'document']),
|
|
22
|
+
userId: z.string(),
|
|
23
|
+
createdAt: z.number(),
|
|
24
|
+
metadata: z.string(), // JSON: { toolName?, toolInput?, toolOutput?, usage?, latencyMs?, error?, confirmationStatus? }
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Schema for AgentStreamChunk — used by Realtime namespace validation. */
|
|
28
|
+
export const agentStreamChunkSchema = z.object({
|
|
29
|
+
type: z.enum(['text-delta', 'tool-call', 'tool-result', 'done', 'error', 'interrupt']),
|
|
30
|
+
text: z.string().optional(),
|
|
31
|
+
toolName: z.string().optional(),
|
|
32
|
+
input: z.any().optional(),
|
|
33
|
+
error: z.string().optional(),
|
|
34
|
+
interrupts: z.array(z.object({ id: z.string(), name: z.string(), reason: z.any().optional() })).optional(),
|
|
35
|
+
usage: z.object({
|
|
36
|
+
inputTokens: z.number(),
|
|
37
|
+
outputTokens: z.number(),
|
|
38
|
+
totalTokens: z.number(),
|
|
39
|
+
}).optional(),
|
|
40
|
+
});
|