@funkai/agents 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/.generated/req.txt +1 -0
- package/.turbo/turbo-build.log +21 -0
- package/.turbo/turbo-test$colon$coverage.log +109 -0
- package/.turbo/turbo-test.log +141 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +16 -0
- package/ISSUES.md +540 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/banner.svg +97 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/core/agents/base/agent.ts.html +1705 -0
- package/coverage/lcov-report/core/agents/base/index.html +146 -0
- package/coverage/lcov-report/core/agents/base/output.ts.html +256 -0
- package/coverage/lcov-report/core/agents/base/utils.ts.html +694 -0
- package/coverage/lcov-report/core/agents/flow/engine.ts.html +928 -0
- package/coverage/lcov-report/core/agents/flow/flow-agent.ts.html +1462 -0
- package/coverage/lcov-report/core/agents/flow/index.html +146 -0
- package/coverage/lcov-report/core/agents/flow/messages.ts.html +508 -0
- package/coverage/lcov-report/core/agents/flow/steps/factory.ts.html +1975 -0
- package/coverage/lcov-report/core/agents/flow/steps/index.html +116 -0
- package/coverage/lcov-report/core/index.html +131 -0
- package/coverage/lcov-report/core/logger.ts.html +541 -0
- package/coverage/lcov-report/core/models/providers/index.html +116 -0
- package/coverage/lcov-report/core/models/providers/openai.ts.html +337 -0
- package/coverage/lcov-report/core/provider/index.html +131 -0
- package/coverage/lcov-report/core/provider/provider.ts.html +346 -0
- package/coverage/lcov-report/core/provider/usage.ts.html +376 -0
- package/coverage/lcov-report/core/tool.ts.html +577 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +221 -0
- package/coverage/lcov-report/lib/hooks.ts.html +262 -0
- package/coverage/lcov-report/lib/index.html +161 -0
- package/coverage/lcov-report/lib/middleware.ts.html +274 -0
- package/coverage/lcov-report/lib/runnable.ts.html +151 -0
- package/coverage/lcov-report/lib/trace.ts.html +520 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov-report/utils/attempt.ts.html +199 -0
- package/coverage/lcov-report/utils/error.ts.html +421 -0
- package/coverage/lcov-report/utils/index.html +176 -0
- package/coverage/lcov-report/utils/resolve.ts.html +208 -0
- package/coverage/lcov-report/utils/result.ts.html +538 -0
- package/coverage/lcov-report/utils/zod.ts.html +178 -0
- package/coverage/lcov.info +1566 -0
- package/dist/index.d.mts +2883 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2312 -0
- package/dist/index.mjs.map +1 -0
- package/docs/core/agent.md +231 -0
- package/docs/core/hooks.md +95 -0
- package/docs/core/overview.md +87 -0
- package/docs/core/step.md +279 -0
- package/docs/core/tools.md +98 -0
- package/docs/core/workflow.md +235 -0
- package/docs/guides/create-agent.md +224 -0
- package/docs/guides/create-tool.md +137 -0
- package/docs/guides/create-workflow.md +374 -0
- package/docs/overview.md +244 -0
- package/docs/provider/models.md +55 -0
- package/docs/provider/overview.md +106 -0
- package/docs/provider/usage.md +100 -0
- package/docs/research/experimental-context.md +167 -0
- package/docs/research/gap-analysis.md +86 -0
- package/docs/research/prepare-step-and-active-tools.md +138 -0
- package/docs/research/sub-agent-model.md +249 -0
- package/docs/troubleshooting.md +60 -0
- package/logo.svg +17 -0
- package/models.config.json +18 -0
- package/package.json +60 -0
- package/scripts/generate-models.ts +324 -0
- package/src/core/agents/base/agent.test.ts +1522 -0
- package/src/core/agents/base/agent.ts +547 -0
- package/src/core/agents/base/output.test.ts +93 -0
- package/src/core/agents/base/output.ts +57 -0
- package/src/core/agents/base/types.test-d.ts +69 -0
- package/src/core/agents/base/types.ts +503 -0
- package/src/core/agents/base/utils.test.ts +397 -0
- package/src/core/agents/base/utils.ts +197 -0
- package/src/core/agents/flow/engine.test.ts +452 -0
- package/src/core/agents/flow/engine.ts +281 -0
- package/src/core/agents/flow/flow-agent.test.ts +1027 -0
- package/src/core/agents/flow/flow-agent.ts +473 -0
- package/src/core/agents/flow/messages.test.ts +198 -0
- package/src/core/agents/flow/messages.ts +141 -0
- package/src/core/agents/flow/steps/agent.test.ts +280 -0
- package/src/core/agents/flow/steps/agent.ts +87 -0
- package/src/core/agents/flow/steps/all.test.ts +300 -0
- package/src/core/agents/flow/steps/all.ts +73 -0
- package/src/core/agents/flow/steps/builder.ts +124 -0
- package/src/core/agents/flow/steps/each.test.ts +257 -0
- package/src/core/agents/flow/steps/each.ts +61 -0
- package/src/core/agents/flow/steps/factory.test-d.ts +50 -0
- package/src/core/agents/flow/steps/factory.test.ts +1025 -0
- package/src/core/agents/flow/steps/factory.ts +645 -0
- package/src/core/agents/flow/steps/map.test.ts +273 -0
- package/src/core/agents/flow/steps/map.ts +75 -0
- package/src/core/agents/flow/steps/race.test.ts +290 -0
- package/src/core/agents/flow/steps/race.ts +59 -0
- package/src/core/agents/flow/steps/reduce.test.ts +310 -0
- package/src/core/agents/flow/steps/reduce.ts +73 -0
- package/src/core/agents/flow/steps/result.ts +27 -0
- package/src/core/agents/flow/steps/step.test.ts +402 -0
- package/src/core/agents/flow/steps/step.ts +51 -0
- package/src/core/agents/flow/steps/while.test.ts +283 -0
- package/src/core/agents/flow/steps/while.ts +75 -0
- package/src/core/agents/flow/types.ts +348 -0
- package/src/core/logger.test.ts +163 -0
- package/src/core/logger.ts +152 -0
- package/src/core/models/index.test.ts +137 -0
- package/src/core/models/index.ts +152 -0
- package/src/core/models/providers/openai.ts +84 -0
- package/src/core/provider/provider.test.ts +128 -0
- package/src/core/provider/provider.ts +99 -0
- package/src/core/provider/types.ts +98 -0
- package/src/core/provider/usage.test.ts +304 -0
- package/src/core/provider/usage.ts +97 -0
- package/src/core/tool.test.ts +65 -0
- package/src/core/tool.ts +164 -0
- package/src/core/types.ts +66 -0
- package/src/index.ts +95 -0
- package/src/lib/context.test.ts +86 -0
- package/src/lib/context.ts +49 -0
- package/src/lib/hooks.test.ts +102 -0
- package/src/lib/hooks.ts +59 -0
- package/src/lib/middleware.test.ts +122 -0
- package/src/lib/middleware.ts +63 -0
- package/src/lib/runnable.test.ts +41 -0
- package/src/lib/runnable.ts +22 -0
- package/src/lib/trace.test.ts +291 -0
- package/src/lib/trace.ts +145 -0
- package/src/models/index.ts +123 -0
- package/src/models/providers/index.ts +15 -0
- package/src/models/providers/openai.ts +84 -0
- package/src/testing/context.ts +32 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/logger.ts +19 -0
- package/src/utils/attempt.test.ts +127 -0
- package/src/utils/attempt.ts +38 -0
- package/src/utils/error.test.ts +179 -0
- package/src/utils/error.ts +112 -0
- package/src/utils/resolve.test.ts +38 -0
- package/src/utils/resolve.ts +41 -0
- package/src/utils/result.test.ts +79 -0
- package/src/utils/result.ts +151 -0
- package/src/utils/zod.test.ts +69 -0
- package/src/utils/zod.ts +31 -0
- package/tsconfig.json +25 -0
- package/tsdown.config.ts +15 -0
- package/vitest.config.ts +46 -0
package/ISSUES.md
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
# Agent SDK — Issue Log
|
|
2
|
+
|
|
3
|
+
Tracked issues discovered during code review. Each issue is tagged with severity, category, and acceptance criteria for resolution.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<issue id="1" severity="high" category="bug" status="closed">
|
|
8
|
+
|
|
9
|
+
## #1 — StepResult spread fails for non-object types
|
|
10
|
+
|
|
11
|
+
**File:** `src/core/workflows/steps/factory.ts:152`
|
|
12
|
+
**Related:** `src/core/workflows/steps/result.ts:26-28`
|
|
13
|
+
|
|
14
|
+
### Description
|
|
15
|
+
|
|
16
|
+
`executeStep` constructs the success result as:
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
return { ok: true, ...(value as T), step: stepInfo, duration } as StepResult<T>;
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Spreading a primitive (`string`, `number`, `boolean`) produces garbage at runtime. `..."hello"` yields `{0:'h',1:'e',...}`. The `StepResult<T>` type definition (`T & { ok: true; ... }`) is also unsound for primitives since `string & { ok: true }` is effectively `never`.
|
|
23
|
+
|
|
24
|
+
Any step returning a non-object value (`$.step<string>(...)`, `$.step<number>(...)`) will produce broken results that the type system masks.
|
|
25
|
+
|
|
26
|
+
### Acceptance Criteria
|
|
27
|
+
|
|
28
|
+
- [ ] `StepResult<T>` wraps the success value in a named field (e.g. `value: T`) instead of intersecting `T &`
|
|
29
|
+
- [ ] OR `T` is constrained to `Record<string, unknown>` to enforce object-only step results
|
|
30
|
+
- [ ] Existing tests updated to reflect the new shape
|
|
31
|
+
- [ ] A test exists that verifies `$.step<string>(...)` returns the correct value
|
|
32
|
+
|
|
33
|
+
</issue>
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<issue id="2" severity="high" category="bug" status="closed">
|
|
38
|
+
|
|
39
|
+
## #2 — Abort signal not propagated to agents in workflow steps
|
|
40
|
+
|
|
41
|
+
**File:** `src/core/workflows/steps/factory.ts:182-186`
|
|
42
|
+
|
|
43
|
+
### Description
|
|
44
|
+
|
|
45
|
+
When `$.agent()` calls `config.agent.generate(config.input, agentConfig)`, the `agentConfig` merges user config and logger but never includes `ctx.signal`:
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
const agentConfig = {
|
|
49
|
+
...config.config,
|
|
50
|
+
logger: ctx.log.child({ stepId: config.id }),
|
|
51
|
+
// Missing: signal: ctx.signal
|
|
52
|
+
};
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
If the workflow is cancelled via abort signal, agents running inside `$.agent()` steps continue until completion.
|
|
56
|
+
|
|
57
|
+
### Acceptance Criteria
|
|
58
|
+
|
|
59
|
+
- [ ] `$.agent()` passes `ctx.signal` to the agent via `agentConfig.signal`
|
|
60
|
+
- [ ] User-provided `config.config.signal` takes precedence over `ctx.signal` if explicitly set
|
|
61
|
+
- [ ] A test verifies that aborting the workflow signal causes the agent call to receive the signal
|
|
62
|
+
|
|
63
|
+
</issue>
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
<issue id="3" severity="high" category="bug" status="closed">
|
|
68
|
+
|
|
69
|
+
## #3 — Agent `stream()` eagerly consumes entire stream
|
|
70
|
+
|
|
71
|
+
**File:** `src/core/agent/agent.ts:274-303`
|
|
72
|
+
|
|
73
|
+
### Description
|
|
74
|
+
|
|
75
|
+
The `stream()` method fully drains `aiResult.textStream` before returning:
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
const chunks: string[] = [];
|
|
79
|
+
for await (const chunk of aiResult.textStream) {
|
|
80
|
+
chunks.push(chunk);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then creates a "replay stream" from collected chunks. The caller cannot consume text incrementally — `stream()` is functionally identical to `generate()` with extra overhead.
|
|
85
|
+
|
|
86
|
+
### Acceptance Criteria
|
|
87
|
+
|
|
88
|
+
- [ ] `stream()` returns before the full generation completes
|
|
89
|
+
- [ ] The returned stream emits chunks as they arrive from the model
|
|
90
|
+
- [ ] `output` and `messages` are resolved after the stream completes (e.g. via promises or post-stream access)
|
|
91
|
+
- [ ] Existing `stream()` tests updated to verify incremental delivery
|
|
92
|
+
|
|
93
|
+
</issue>
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
<issue id="4" severity="medium" category="bug" status="closed">
|
|
98
|
+
|
|
99
|
+
## #4 — Agent `stream()` ignores structured output
|
|
100
|
+
|
|
101
|
+
**File:** `src/core/agent/agent.ts:280-282`
|
|
102
|
+
|
|
103
|
+
### Description
|
|
104
|
+
|
|
105
|
+
In `stream()`, the output is always set to raw text:
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
const finalText = await aiResult.text;
|
|
109
|
+
const finalOutput = finalText;
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Compare with `generate()` which correctly branches:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
output: (output ? aiResult.output : aiResult.text) as TOutput;
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
When an agent has structured output (e.g. `Output.object({ schema })`), `stream()` returns a raw string instead of the parsed object. The `TOutput` type assertion masks this at compile time.
|
|
119
|
+
|
|
120
|
+
### Acceptance Criteria
|
|
121
|
+
|
|
122
|
+
- [ ] `stream()` checks for structured output the same way `generate()` does
|
|
123
|
+
- [ ] When `output` is configured, `stream()` returns the parsed object, not raw text
|
|
124
|
+
- [ ] A test exists that verifies `stream()` with structured output returns the correct type
|
|
125
|
+
|
|
126
|
+
</issue>
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
<issue id="5" severity="medium" category="bug" status="closed">
|
|
131
|
+
|
|
132
|
+
## #5 — `$.race()` does not cancel losing entries
|
|
133
|
+
|
|
134
|
+
**File:** `src/core/workflows/steps/factory.ts:331-343`
|
|
135
|
+
**Related:** `src/core/workflows/steps/race.ts:5`
|
|
136
|
+
|
|
137
|
+
### Description
|
|
138
|
+
|
|
139
|
+
`RaceConfig` docs say "Losers are cancelled via abort signal" but the implementation is just:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
execute: async () => Promise.race(config.entries),
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
No abort controller is created. No signals are propagated. Losing entries continue running to completion.
|
|
146
|
+
|
|
147
|
+
### Acceptance Criteria
|
|
148
|
+
|
|
149
|
+
- [ ] `$.race()` cancels losing entries when the winner resolves
|
|
150
|
+
- [ ] OR the docs are updated to remove the cancellation claim and document actual behavior
|
|
151
|
+
- [ ] If cancellation is implemented, entries must accept an abort signal (API change to accept factories instead of pre-started promises)
|
|
152
|
+
|
|
153
|
+
</issue>
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
<issue id="6" severity="medium" category="bug" status="closed">
|
|
158
|
+
|
|
159
|
+
## #6 — `$.all()` / `$.race()` entries are pre-started promises
|
|
160
|
+
|
|
161
|
+
**File:** `src/core/workflows/steps/factory.ts:313-329`
|
|
162
|
+
**Related:** `src/core/workflows/steps/all.ts`, `src/core/workflows/steps/race.ts`
|
|
163
|
+
|
|
164
|
+
### Description
|
|
165
|
+
|
|
166
|
+
`AllConfig.entries` and `RaceConfig.entries` are typed as `Promise<any>[]`. By the time `executeStep` records `startedAt`, those promises are already executing. This means:
|
|
167
|
+
|
|
168
|
+
1. Trace `startedAt` timestamp is too late (work already began)
|
|
169
|
+
2. If entries resolved before `$.all()` is called, hooks fire after the fact
|
|
170
|
+
3. `duration` measurement underestimates actual execution time
|
|
171
|
+
|
|
172
|
+
The `AllConfig` JSDoc acknowledges this ("already tracked individually") but the `executeStep` wrapper's timing is still misleading.
|
|
173
|
+
|
|
174
|
+
### Acceptance Criteria
|
|
175
|
+
|
|
176
|
+
- [ ] Entries are changed to factory functions `(() => Promise<T>)[]` so the framework controls start time
|
|
177
|
+
- [ ] OR the trace/timing for `$.all()` / `$.race()` is documented as "coordination overhead only, not total execution time"
|
|
178
|
+
- [ ] If factories are adopted, update `StepBuilder` types and all call sites
|
|
179
|
+
|
|
180
|
+
</issue>
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
<issue id="7" severity="medium" category="logic" status="closed">
|
|
185
|
+
|
|
186
|
+
## #7 — `onStepFinish` never fires on error
|
|
187
|
+
|
|
188
|
+
**File:** `src/core/workflows/steps/factory.ts:167-168`
|
|
189
|
+
|
|
190
|
+
### Description
|
|
191
|
+
|
|
192
|
+
The catch block only fires step-level `onError`. The workflow-level `parentHooks.onStepFinish` is never called for failed steps. This is intentional per the comment, but asymmetric with `onStepStart` (which always fires). There is no `onStepError` hook at the workflow level.
|
|
193
|
+
|
|
194
|
+
Consumers using `onStepFinish` for telemetry (e.g. recording step durations) will silently miss all failed steps.
|
|
195
|
+
|
|
196
|
+
### Acceptance Criteria
|
|
197
|
+
|
|
198
|
+
- [ ] `onStepFinish` fires for both success and error cases (with error info included in the event)
|
|
199
|
+
- [ ] OR a new workflow-level `onStepError` hook is added
|
|
200
|
+
- [ ] The chosen behavior is documented in the `WorkflowConfig` JSDoc
|
|
201
|
+
|
|
202
|
+
</issue>
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
<issue id="8" severity="medium" category="logic" status="closed">
|
|
207
|
+
|
|
208
|
+
## #8 — `finishReason` passed as `stepId` in agent `onStepFinish` hook
|
|
209
|
+
|
|
210
|
+
**File:** `src/core/agent/agent.ts:144, 265`
|
|
211
|
+
|
|
212
|
+
### Description
|
|
213
|
+
|
|
214
|
+
Both `generate()` and `stream()` pass the AI SDK's `event.finishReason` as the `stepId`:
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
onStepFinish: async (event) => {
|
|
218
|
+
config.onStepFinish!({ stepId: event.finishReason });
|
|
219
|
+
};
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`finishReason` is `"stop"`, `"length"`, `"tool-calls"`, etc. — not a step identifier. Consumers expecting a unique step ID will get the finish reason instead.
|
|
223
|
+
|
|
224
|
+
### Acceptance Criteria
|
|
225
|
+
|
|
226
|
+
- [ ] Pass a meaningful step identifier (e.g. counter-based ID, or `agentName:stepIndex`)
|
|
227
|
+
- [ ] OR rename the hook parameter from `stepId` to `finishReason` to match the actual value
|
|
228
|
+
- [ ] Update the `AgentConfig.onStepFinish` type signature accordingly
|
|
229
|
+
|
|
230
|
+
</issue>
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
<issue id="9" severity="low" category="logic" status="closed">
|
|
235
|
+
|
|
236
|
+
## #9 — `withModelMiddleware` wraps model even with empty middleware
|
|
237
|
+
|
|
238
|
+
**File:** `src/lib/middleware.ts:49-52`
|
|
239
|
+
|
|
240
|
+
### Description
|
|
241
|
+
|
|
242
|
+
When devtools is disabled (production) and no user middleware is provided, the function still calls `wrapLanguageModel({ model, middleware: [] })`. This creates an unnecessary wrapper layer.
|
|
243
|
+
|
|
244
|
+
### Acceptance Criteria
|
|
245
|
+
|
|
246
|
+
- [ ] Return the model directly when middleware array is empty
|
|
247
|
+
- [ ] Add a test verifying no wrapping occurs in production with no middleware
|
|
248
|
+
|
|
249
|
+
</issue>
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
<issue id="10" severity="low" category="logic" status="closed">
|
|
254
|
+
|
|
255
|
+
## #10 — Middleware ordering contradicts documentation
|
|
256
|
+
|
|
257
|
+
**File:** `src/lib/middleware.ts:13-14, 45-46`
|
|
258
|
+
|
|
259
|
+
### Description
|
|
260
|
+
|
|
261
|
+
JSDoc says "Additional middleware to apply **after** defaults" but the implementation puts user middleware first:
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
[...options.middleware, ...defaultMiddleware];
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
User middleware is outermost (wraps around devtools), contradicting "after defaults."
|
|
268
|
+
|
|
269
|
+
### Acceptance Criteria
|
|
270
|
+
|
|
271
|
+
- [ ] Either swap the order to `[...defaultMiddleware, ...options.middleware]`
|
|
272
|
+
- [ ] OR update the JSDoc to say "before defaults" / "outermost"
|
|
273
|
+
- [ ] Add a test that verifies middleware execution order
|
|
274
|
+
|
|
275
|
+
</issue>
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
<issue id="11" severity="low" category="improvement" status="closed">
|
|
280
|
+
|
|
281
|
+
## #11 — `snapshotTrace` shallow-clones entries
|
|
282
|
+
|
|
283
|
+
**File:** `src/lib/trace.ts:100`
|
|
284
|
+
|
|
285
|
+
### Description
|
|
286
|
+
|
|
287
|
+
`{ ...entry }` only clones the top-level `TraceEntry` fields. `entry.input`, `entry.output`, and `entry.error` are reference copies. Mutations to original objects after snapshot are visible through the "frozen" trace.
|
|
288
|
+
|
|
289
|
+
### Acceptance Criteria
|
|
290
|
+
|
|
291
|
+
- [ ] Use `structuredClone` for input/output fields (noting `Error` objects need special handling)
|
|
292
|
+
- [ ] OR document that input/output references are shared and only the trace structure is frozen
|
|
293
|
+
|
|
294
|
+
</issue>
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
<issue id="12" severity="medium" category="improvement" status="closed">
|
|
299
|
+
|
|
300
|
+
## #12 — No abort signal checking in sequential step loops
|
|
301
|
+
|
|
302
|
+
**File:** `src/core/workflows/steps/factory.ts:246-298`
|
|
303
|
+
|
|
304
|
+
### Description
|
|
305
|
+
|
|
306
|
+
`$.each()` (line 246), `$.reduce()` (line 266), and `$.while()` (line 293) all loop without checking `ctx.signal.aborted`. If a workflow is cancelled mid-loop, these operations continue iterating through all items. `$.while()` is especially vulnerable since it loops on an arbitrary condition.
|
|
307
|
+
|
|
308
|
+
### Acceptance Criteria
|
|
309
|
+
|
|
310
|
+
- [ ] Each sequential loop checks `ctx.signal.aborted` at the start of each iteration
|
|
311
|
+
- [ ] When aborted, the loop throws an appropriate error (e.g. `AbortError`)
|
|
312
|
+
- [ ] A test verifies that aborting mid-loop stops iteration
|
|
313
|
+
|
|
314
|
+
</issue>
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
<issue id="13" severity="low" category="improvement" status="closed">
|
|
319
|
+
|
|
320
|
+
## #13 — `poolMap` ignores abort signal
|
|
321
|
+
|
|
322
|
+
**File:** `src/core/workflows/steps/factory.ts:371-376`
|
|
323
|
+
|
|
324
|
+
### Description
|
|
325
|
+
|
|
326
|
+
The `poolMap` worker loop (`while (nextIndex < items.length)`) never checks for abort signal cancellation. Workers continue processing items after the workflow has been aborted.
|
|
327
|
+
|
|
328
|
+
### Acceptance Criteria
|
|
329
|
+
|
|
330
|
+
- [ ] `poolMap` accepts the abort signal and checks it before starting each item
|
|
331
|
+
- [ ] When aborted, workers exit cleanly
|
|
332
|
+
|
|
333
|
+
</issue>
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
<issue id="14" severity="low" category="improvement" status="closed">
|
|
338
|
+
|
|
339
|
+
## #14 — `writer.write()` not awaited in workflow stream
|
|
340
|
+
|
|
341
|
+
**File:** `src/core/workflows/workflow.ts:470-472`
|
|
342
|
+
|
|
343
|
+
### Description
|
|
344
|
+
|
|
345
|
+
The `emit` function calls `writer.write(event)` without awaiting the returned promise. If the readable side is cancelled or the internal queue is full, the rejection is unhandled. Additionally, `emit` is typed as synchronous `(event: StepEvent) => void` but `writer.write` is async.
|
|
346
|
+
|
|
347
|
+
### Acceptance Criteria
|
|
348
|
+
|
|
349
|
+
- [ ] Either await the write (and change `emit` to async)
|
|
350
|
+
- [ ] OR add `.catch(() => {})` to swallow write errors silently
|
|
351
|
+
- [ ] The `emit` type signature matches the actual behavior
|
|
352
|
+
|
|
353
|
+
</issue>
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
<issue id="15" severity="low" category="improvement" status="closed">
|
|
358
|
+
|
|
359
|
+
## #15 — `openrouter()` creates a new provider instance on every call
|
|
360
|
+
|
|
361
|
+
**File:** `src/core/provider/provider.ts:47-51`
|
|
362
|
+
|
|
363
|
+
### Description
|
|
364
|
+
|
|
365
|
+
Every call to `openrouter(modelId)` creates a new `OpenRouterProvider` instance. If the provider maintains internal state, caching, or connection pooling, this is wasteful.
|
|
366
|
+
|
|
367
|
+
### Acceptance Criteria
|
|
368
|
+
|
|
369
|
+
- [ ] Cache the provider instance lazily at module scope
|
|
370
|
+
- [ ] Invalidation strategy if `OPENROUTER_API_KEY` changes at runtime (or document that it doesn't)
|
|
371
|
+
|
|
372
|
+
</issue>
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
<issue id="16" severity="low" category="improvement" status="closed">
|
|
377
|
+
|
|
378
|
+
## #16 — `createTool` adds unnecessary async wrapper around execute
|
|
379
|
+
|
|
380
|
+
**File:** `src/core/tool.ts:109`
|
|
381
|
+
|
|
382
|
+
### Description
|
|
383
|
+
|
|
384
|
+
`execute: async (data: TInput) => config.execute(data)` wraps the user's execute function in an extra async layer, adding one unnecessary microtask per tool call. The wrapper exists so `assertTool` can validate the intermediate object, but `config.execute` could be passed directly.
|
|
385
|
+
|
|
386
|
+
### Acceptance Criteria
|
|
387
|
+
|
|
388
|
+
- [ ] Pass `config.execute` directly instead of wrapping
|
|
389
|
+
- [ ] OR document why the wrapper is intentional (if there's a reason beyond assertion)
|
|
390
|
+
|
|
391
|
+
</issue>
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
<issue id="17" severity="medium" category="improvement" status="closed">
|
|
396
|
+
|
|
397
|
+
## #17 — Subagent tool calls do not propagate abort signal
|
|
398
|
+
|
|
399
|
+
**File:** `src/core/agent/utils.ts:54-74`
|
|
400
|
+
|
|
401
|
+
### Description
|
|
402
|
+
|
|
403
|
+
When a subagent is wrapped as a tool via `buildAITools`, the `execute` function calls `runnable.generate(input)` without passing an abort signal. The AI SDK passes `{ abortSignal }` as the second parameter to tool execute functions, but this code ignores it. If the parent agent is cancelled, subagent tool calls continue running.
|
|
404
|
+
|
|
405
|
+
### Acceptance Criteria
|
|
406
|
+
|
|
407
|
+
- [ ] Accept the `abortSignal` from the AI SDK tool execution context
|
|
408
|
+
- [ ] Forward it as `{ signal: abortSignal }` to `runnable.generate()`
|
|
409
|
+
- [ ] A test verifies signal propagation from parent to subagent
|
|
410
|
+
|
|
411
|
+
</issue>
|
|
412
|
+
|
|
413
|
+
---
|
|
414
|
+
|
|
415
|
+
<issue id="18" severity="low" category="improvement" status="closed">
|
|
416
|
+
|
|
417
|
+
## #18 — No runtime guard for `input` schema without `prompt` function
|
|
418
|
+
|
|
419
|
+
**File:** `src/core/agent/utils.ts:107`
|
|
420
|
+
|
|
421
|
+
### Description
|
|
422
|
+
|
|
423
|
+
If a user provides `input` schema but omits `prompt` (or vice versa), the code silently falls through to simple mode. Both fields are independently optional in `AgentConfig` — no type-level or runtime enforcement that they must be provided together.
|
|
424
|
+
|
|
425
|
+
### Acceptance Criteria
|
|
426
|
+
|
|
427
|
+
- [ ] Add a runtime warning or error when `input` is provided without `prompt` (and vice versa)
|
|
428
|
+
- [ ] OR use a discriminated union at the type level to enforce the constraint
|
|
429
|
+
- [ ] A test verifies the guard triggers correctly
|
|
430
|
+
|
|
431
|
+
</issue>
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
<issue id="19" severity="low" category="improvement" status="closed">
|
|
436
|
+
|
|
437
|
+
## #19 — `LanguageModel` type narrowed to `specificationVersion: 'v3'` only
|
|
438
|
+
|
|
439
|
+
**File:** `src/core/provider/types.ts:11`
|
|
440
|
+
|
|
441
|
+
### Description
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
export type LanguageModel = Extract<BaseLanguageModel, { specificationVersion: "v3" }>;
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
This rejects models using future spec versions (v4, etc.). When the AI SDK introduces a new version, models using it won't be assignable. Forward-incompatible.
|
|
448
|
+
|
|
449
|
+
### Acceptance Criteria
|
|
450
|
+
|
|
451
|
+
- [ ] Use the base `LanguageModel` type directly
|
|
452
|
+
- [ ] OR use a union that accommodates known + future versions
|
|
453
|
+
- [ ] OR document this as intentional pinning with a note to update on AI SDK major bumps
|
|
454
|
+
|
|
455
|
+
</issue>
|
|
456
|
+
|
|
457
|
+
---
|
|
458
|
+
|
|
459
|
+
<issue id="20" severity="low" category="improvement" status="closed">
|
|
460
|
+
|
|
461
|
+
## #20 — Duck-typing for `OutputSpec` detection is fragile
|
|
462
|
+
|
|
463
|
+
**File:** `src/core/agent/output.ts:37-40`
|
|
464
|
+
|
|
465
|
+
### Description
|
|
466
|
+
|
|
467
|
+
`resolveOutput` checks `'parseCompleteOutput' in output` to distinguish an `OutputSpec` from a Zod schema. If the AI SDK renames that method, or a Zod schema happens to have a property named `parseCompleteOutput`, detection breaks silently.
|
|
468
|
+
|
|
469
|
+
### Acceptance Criteria
|
|
470
|
+
|
|
471
|
+
- [ ] Use a more robust discriminant (e.g. `instanceof`, brand checking, or a symbol)
|
|
472
|
+
- [ ] OR document the fragility and add a test that catches regressions on AI SDK upgrades
|
|
473
|
+
|
|
474
|
+
</issue>
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
<issue id="21" severity="medium" category="logic" status="closed">
|
|
479
|
+
|
|
480
|
+
## #21 — Zod array element extraction uses private `_zod` internals
|
|
481
|
+
|
|
482
|
+
**File:** `src/core/agent/output.ts:43-50`
|
|
483
|
+
|
|
484
|
+
### Description
|
|
485
|
+
|
|
486
|
+
The code accesses `(output as unknown as Record<string, unknown>)._zod` to extract the element schema from a Zod array. This relies on Zod's internal `_zod` property which can change across versions. If the structure changes, array output detection silently falls back to `Output.object({ schema: output })`, producing incorrect parsing.
|
|
487
|
+
|
|
488
|
+
### Acceptance Criteria
|
|
489
|
+
|
|
490
|
+
- [ ] Use Zod's public API for array element introspection
|
|
491
|
+
- [ ] A test verifies correct array element extraction across the supported Zod version
|
|
492
|
+
- [ ] Add a regression test that catches breakage if Zod internals change
|
|
493
|
+
|
|
494
|
+
</issue>
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
<issue id="22" severity="low" category="improvement" status="closed">
|
|
499
|
+
|
|
500
|
+
## #22 — Orphaned AbortController in workflow `generate()`
|
|
501
|
+
|
|
502
|
+
**File:** `src/core/workflows/workflow.ts:368`
|
|
503
|
+
|
|
504
|
+
### Description
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
const signal = overrides?.signal ?? new AbortController().signal;
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
When no signal is provided, a new `AbortController` is created but its reference is immediately discarded. The signal can never fire. Harmless but wasteful.
|
|
511
|
+
|
|
512
|
+
### Acceptance Criteria
|
|
513
|
+
|
|
514
|
+
- [ ] Only create an AbortController when needed, or pass `undefined` and check `signal?.aborted` in loops
|
|
515
|
+
- [ ] OR keep the current behavior and document it as intentional (a never-firing signal simplifies downstream code that always expects a signal)
|
|
516
|
+
|
|
517
|
+
</issue>
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
<issue id="23" severity="low" category="improvement" status="closed">
|
|
522
|
+
|
|
523
|
+
## #23 — O(N^2) array copying in `attemptEachAsync`
|
|
524
|
+
|
|
525
|
+
**File:** `src/utils/attempt.ts:34-36`
|
|
526
|
+
|
|
527
|
+
### Description
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
async (acc, h) => [...(await acc), await attemptAsync<T, E>(async () => h())];
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Each reduce iteration creates a new array via spread. For N handlers, this is O(N^2) element copies. N is typically 1-2 so the impact is negligible, but the pattern is unnecessarily wasteful.
|
|
534
|
+
|
|
535
|
+
### Acceptance Criteria
|
|
536
|
+
|
|
537
|
+
- [ ] Replace `reduce` with a simple `for` loop using `push`
|
|
538
|
+
- [ ] Behavior and return type remain identical
|
|
539
|
+
|
|
540
|
+
</issue>
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joggr, Inc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<p><strong>@funkai/agents</strong></p>
|
|
3
|
+
<p>Lightweight workflow and agent orchestration framework built on the <a href="https://github.com/vercel/ai">Vercel AI SDK</a>.</p>
|
|
4
|
+
|
|
5
|
+
<a href="https://www.npmjs.com/package/@funkai/agents"><img src="https://img.shields.io/npm/v/@funkai/agents" alt="npm version" /></a>
|
|
6
|
+
<a href="https://github.com/joggrdocs/funkai/blob/main/LICENSE"><img src="https://img.shields.io/github/license/joggrdocs/funkai" alt="License" /></a>
|
|
7
|
+
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- :zap: **Functions all the way down** — `agent`, `tool`, `workflow` are functions that return plain objects.
|
|
13
|
+
- :jigsaw: **Composition over configuration** — Combine small pieces instead of configuring large ones.
|
|
14
|
+
- :shield: **Result, never throw** — Every public method returns `Result<T>`. Pattern-match on `ok` instead of try/catch.
|
|
15
|
+
- :lock: **Closures are state** — Workflow state is just `let` variables in your handler.
|
|
16
|
+
- :mag: **`$` is optional sugar** — The `$` helpers register data flow for observability; plain imperative code works too.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @funkai/agents
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### Agent
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { agent } from "@funkai/agents";
|
|
30
|
+
|
|
31
|
+
const helper = agent({
|
|
32
|
+
name: "helper",
|
|
33
|
+
model: "openai/gpt-4.1",
|
|
34
|
+
system: "You are a helpful assistant.",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const result = await helper.generate("What is TypeScript?");
|
|
38
|
+
|
|
39
|
+
if (!result.ok) {
|
|
40
|
+
console.error(result.error.code, result.error.message);
|
|
41
|
+
} else {
|
|
42
|
+
console.log(result.output);
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Tool
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { tool } from "@funkai/agents";
|
|
50
|
+
import { z } from "zod";
|
|
51
|
+
|
|
52
|
+
const fetchPage = tool({
|
|
53
|
+
description: "Fetch the contents of a web page by URL",
|
|
54
|
+
inputSchema: z.object({ url: z.url() }),
|
|
55
|
+
execute: async ({ url }) => {
|
|
56
|
+
const res = await fetch(url);
|
|
57
|
+
return { url, status: res.status, body: await res.text() };
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Workflow
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { workflow } from "@funkai/agents";
|
|
66
|
+
import { z } from "zod";
|
|
67
|
+
|
|
68
|
+
const research = workflow(
|
|
69
|
+
{
|
|
70
|
+
name: "research",
|
|
71
|
+
input: z.object({ topic: z.string() }),
|
|
72
|
+
output: z.object({ summary: z.string(), sources: z.array(z.string()) }),
|
|
73
|
+
},
|
|
74
|
+
async ({ input, $ }) => {
|
|
75
|
+
let sources: string[] = [];
|
|
76
|
+
|
|
77
|
+
const data = await $.step({
|
|
78
|
+
id: "fetch-sources",
|
|
79
|
+
execute: async () => findSources(input.topic),
|
|
80
|
+
});
|
|
81
|
+
if (data.ok) sources = data.value;
|
|
82
|
+
|
|
83
|
+
const analysis = await $.agent({
|
|
84
|
+
id: "summarize",
|
|
85
|
+
agent: summarizer,
|
|
86
|
+
input: { text: sources.join("\n") },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
summary: analysis.ok ? analysis.output : "Failed to summarize",
|
|
91
|
+
sources,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const result = await research.generate({ topic: "Effect systems" });
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Streaming
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const result = await helper.stream("Explain closures");
|
|
103
|
+
|
|
104
|
+
if (result.ok) {
|
|
105
|
+
for await (const chunk of result.stream) {
|
|
106
|
+
process.stdout.write(chunk);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## API
|
|
112
|
+
|
|
113
|
+
| Export | Description |
|
|
114
|
+
| ------------------------------ | --------------------------------------------------------------------- |
|
|
115
|
+
| `agent(config)` | Create an agent. Returns `{ generate, stream, fn }`. |
|
|
116
|
+
| `tool(config)` | Create a tool for function calling. |
|
|
117
|
+
| `workflow(config, handler)` | Create a workflow with typed I/O and tracked steps. |
|
|
118
|
+
| `createWorkflowEngine(config)` | Create a workflow factory with shared configuration and custom steps. |
|
|
119
|
+
| `openrouter(modelId)` | Shorthand to create an OpenRouter language model from env key. |
|
|
120
|
+
| `createOpenRouter(options?)` | Create a reusable OpenRouter provider instance. |
|
|
121
|
+
|
|
122
|
+
## Documentation
|
|
123
|
+
|
|
124
|
+
For comprehensive documentation, see [docs/overview.md](docs/overview.md).
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
[MIT](../../LICENSE)
|