@renxqoo/renx-code 0.0.8 → 0.0.10
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/README.md +114 -40
- package/bin/renx.cjs +79 -42
- package/bin/renx.exe +0 -0
- package/package.json +10 -28
- package/src/App.tsx +0 -297
- package/src/agent/runtime/event-format.ts +0 -258
- package/src/agent/runtime/model-types.ts +0 -13
- package/src/agent/runtime/runtime.context-usage.test.ts +0 -192
- package/src/agent/runtime/runtime.error-handling.test.ts +0 -235
- package/src/agent/runtime/runtime.simple.test.ts +0 -16
- package/src/agent/runtime/runtime.test.ts +0 -296
- package/src/agent/runtime/runtime.ts +0 -875
- package/src/agent/runtime/runtime.usage-forwarding.test.ts +0 -228
- package/src/agent/runtime/source-modules.test.ts +0 -38
- package/src/agent/runtime/source-modules.ts +0 -370
- package/src/agent/runtime/tool-call-buffer.test.ts +0 -65
- package/src/agent/runtime/tool-call-buffer.ts +0 -60
- package/src/agent/runtime/tool-confirmation.test.ts +0 -56
- package/src/agent/runtime/tool-confirmation.ts +0 -15
- package/src/agent/runtime/types.ts +0 -99
- package/src/commands/slash-commands.test.ts +0 -216
- package/src/commands/slash-commands.ts +0 -64
- package/src/components/chat/assistant-reply.test.tsx +0 -47
- package/src/components/chat/assistant-reply.tsx +0 -136
- package/src/components/chat/assistant-segment.test.ts +0 -99
- package/src/components/chat/assistant-segment.tsx +0 -125
- package/src/components/chat/assistant-tool-group.tsx +0 -900
- package/src/components/chat/code-block.test.tsx +0 -206
- package/src/components/chat/code-block.tsx +0 -313
- package/src/components/chat/prompt-card.tsx +0 -81
- package/src/components/chat/segment-groups.test.ts +0 -52
- package/src/components/chat/segment-groups.ts +0 -106
- package/src/components/chat/turn-item.tsx +0 -39
- package/src/components/conversation-panel.tsx +0 -43
- package/src/components/file-mention-menu.tsx +0 -77
- package/src/components/file-picker-dialog.tsx +0 -206
- package/src/components/footer-hints.tsx +0 -75
- package/src/components/model-picker-dialog.tsx +0 -248
- package/src/components/prompt.tsx +0 -233
- package/src/components/slash-command-menu.tsx +0 -65
- package/src/components/tool-confirm-dialog-content.test.ts +0 -103
- package/src/components/tool-confirm-dialog-content.ts +0 -186
- package/src/components/tool-confirm-dialog.tsx +0 -187
- package/src/components/tool-display-config.ts +0 -119
- package/src/context-usage-regressions.test.ts +0 -26
- package/src/files/attachment-capabilities.test.ts +0 -30
- package/src/files/attachment-capabilities.ts +0 -50
- package/src/files/attachment-content.ts +0 -153
- package/src/files/file-mention-query.test.ts +0 -34
- package/src/files/file-mention-query.ts +0 -32
- package/src/files/prompt-display.ts +0 -13
- package/src/files/types.ts +0 -5
- package/src/files/workspace-files.ts +0 -61
- package/src/hooks/agent-event-handlers.test.ts +0 -207
- package/src/hooks/agent-event-handlers.ts +0 -196
- package/src/hooks/chat-local-replies.fixed.test.ts +0 -119
- package/src/hooks/chat-local-replies.test.ts +0 -153
- package/src/hooks/chat-local-replies.ts +0 -63
- package/src/hooks/turn-updater.test.ts +0 -70
- package/src/hooks/turn-updater.ts +0 -166
- package/src/hooks/use-agent-chat.context.test.ts +0 -10
- package/src/hooks/use-agent-chat.status.test.ts +0 -14
- package/src/hooks/use-agent-chat.test.ts +0 -80
- package/src/hooks/use-agent-chat.ts +0 -621
- package/src/hooks/use-file-mention-menu.ts +0 -196
- package/src/hooks/use-file-picker.ts +0 -185
- package/src/hooks/use-model-picker.ts +0 -196
- package/src/hooks/use-slash-command-menu.ts +0 -154
- package/src/index.tsx +0 -55
- package/src/runtime/clipboard.test.ts +0 -43
- package/src/runtime/clipboard.ts +0 -89
- package/src/runtime/exit.test.ts +0 -177
- package/src/runtime/exit.ts +0 -98
- package/src/runtime/runtime-support.test.ts +0 -31
- package/src/runtime/terminal-theme.test.ts +0 -55
- package/src/runtime/terminal-theme.ts +0 -196
- package/src/types/chat.ts +0 -32
- package/src/types/message-content.ts +0 -48
- package/src/ui/open-code-theme.ts +0 -176
- package/src/ui/opencode-markdown.ts +0 -211
- package/src/ui/theme.simple.test.ts +0 -52
- package/src/ui/theme.test.ts +0 -151
- package/src/ui/theme.ts +0 -152
- package/src/utils/time.test.ts +0 -144
- package/src/utils/time.ts +0 -7
- package/tsconfig.json +0 -30
- package/vendor/agent-root/src/agent/ENTERPRISE_ACCEPTANCE_CHECKLIST.md +0 -95
- package/vendor/agent-root/src/agent/ENTERPRISE_REALTIME.html +0 -1345
- package/vendor/agent-root/src/agent/ENTERPRISE_REALTIME.md +0 -1353
- package/vendor/agent-root/src/agent/ERROR_CONTRACT.md +0 -60
- package/vendor/agent-root/src/agent/TEST_COVERAGE_ANALYSIS.md +0 -278
- package/vendor/agent-root/src/agent/__test__/error-contract.test.ts +0 -72
- package/vendor/agent-root/src/agent/__test__/types.test.ts +0 -137
- package/vendor/agent-root/src/agent/agent/__test__/abort-runtime.test.ts +0 -83
- package/vendor/agent-root/src/agent/agent/__test__/callback-safety.test.ts +0 -34
- package/vendor/agent-root/src/agent/agent/__test__/compaction.test.ts +0 -323
- package/vendor/agent-root/src/agent/agent/__test__/concurrency.test.ts +0 -290
- package/vendor/agent-root/src/agent/agent/__test__/error-normalizer.test.ts +0 -377
- package/vendor/agent-root/src/agent/agent/__test__/error.test.ts +0 -212
- package/vendor/agent-root/src/agent/agent/__test__/fault-injection.test.ts +0 -295
- package/vendor/agent-root/src/agent/agent/__test__/index.test.ts +0 -3607
- package/vendor/agent-root/src/agent/agent/__test__/logger.test.ts +0 -35
- package/vendor/agent-root/src/agent/agent/__test__/message-utils.test.ts +0 -517
- package/vendor/agent-root/src/agent/agent/__test__/telemetry.test.ts +0 -97
- package/vendor/agent-root/src/agent/agent/__test__/timeout-budget.test.ts +0 -479
- package/vendor/agent-root/src/agent/agent/__test__/tool-call-merge.test.ts +0 -80
- package/vendor/agent-root/src/agent/agent/__test__/tool-execution-ledger.test.ts +0 -76
- package/vendor/agent-root/src/agent/agent/__test__/write-buffer.test.ts +0 -173
- package/vendor/agent-root/src/agent/agent/__test__/write-file-session.test.ts +0 -109
- package/vendor/agent-root/src/agent/agent/abort-runtime.ts +0 -71
- package/vendor/agent-root/src/agent/agent/callback-safety.ts +0 -33
- package/vendor/agent-root/src/agent/agent/compaction.ts +0 -291
- package/vendor/agent-root/src/agent/agent/concurrency.ts +0 -103
- package/vendor/agent-root/src/agent/agent/error-normalizer.ts +0 -190
- package/vendor/agent-root/src/agent/agent/error.ts +0 -198
- package/vendor/agent-root/src/agent/agent/index.ts +0 -1772
- package/vendor/agent-root/src/agent/agent/logger.ts +0 -65
- package/vendor/agent-root/src/agent/agent/message-utils.ts +0 -101
- package/vendor/agent-root/src/agent/agent/stream-events.ts +0 -61
- package/vendor/agent-root/src/agent/agent/telemetry.ts +0 -123
- package/vendor/agent-root/src/agent/agent/timeout-budget.ts +0 -227
- package/vendor/agent-root/src/agent/agent/tool-call-merge.ts +0 -111
- package/vendor/agent-root/src/agent/agent/tool-execution-ledger.ts +0 -164
- package/vendor/agent-root/src/agent/agent/write-buffer.ts +0 -188
- package/vendor/agent-root/src/agent/agent/write-file-session.ts +0 -238
- package/vendor/agent-root/src/agent/app/__test__/agent-app-service.test.ts +0 -1053
- package/vendor/agent-root/src/agent/app/__test__/minimal-agent-application.test.ts +0 -158
- package/vendor/agent-root/src/agent/app/__test__/sqlite-agent-app-store.test.ts +0 -437
- package/vendor/agent-root/src/agent/app/agent-app-service.ts +0 -748
- package/vendor/agent-root/src/agent/app/contracts.ts +0 -109
- package/vendor/agent-root/src/agent/app/index.ts +0 -5
- package/vendor/agent-root/src/agent/app/minimal-agent-application.ts +0 -151
- package/vendor/agent-root/src/agent/app/ports.ts +0 -72
- package/vendor/agent-root/src/agent/app/sqlite-agent-app-store.ts +0 -1182
- package/vendor/agent-root/src/agent/app/sqlite-client.ts +0 -177
- package/vendor/agent-root/src/agent/docs/cli-app-layer/00-README.md +0 -36
- package/vendor/agent-root/src/agent/docs/cli-app-layer/01-scope-and-goals.md +0 -33
- package/vendor/agent-root/src/agent/docs/cli-app-layer/02-architecture-overview.md +0 -40
- package/vendor/agent-root/src/agent/docs/cli-app-layer/03-domain-model-and-contracts.md +0 -91
- package/vendor/agent-root/src/agent/docs/cli-app-layer/04-ports-and-interfaces.md +0 -116
- package/vendor/agent-root/src/agent/docs/cli-app-layer/05-run-orchestration-and-state-machine.md +0 -52
- package/vendor/agent-root/src/agent/docs/cli-app-layer/06-cli-commands-and-ux.md +0 -53
- package/vendor/agent-root/src/agent/docs/cli-app-layer/07-storage-design-local.md +0 -52
- package/vendor/agent-root/src/agent/docs/cli-app-layer/08-error-and-observability.md +0 -40
- package/vendor/agent-root/src/agent/docs/cli-app-layer/09-security-and-policy-boundary.md +0 -19
- package/vendor/agent-root/src/agent/docs/cli-app-layer/10-test-plan-and-acceptance.md +0 -28
- package/vendor/agent-root/src/agent/docs/cli-app-layer/11-implementation-phases.md +0 -26
- package/vendor/agent-root/src/agent/docs/cli-app-layer/12-open-questions-and-risks.md +0 -30
- package/vendor/agent-root/src/agent/docs/cli-app-layer/13-sqlite-schema-fields-and-rationale.md +0 -567
- package/vendor/agent-root/src/agent/docs/cli-app-layer/14-project-flow-mermaid.md +0 -583
- package/vendor/agent-root/src/agent/docs/cli-app-layer/15-openclaw-style-project-blueprint.md +0 -972
- package/vendor/agent-root/src/agent/error-contract.ts +0 -154
- package/vendor/agent-root/src/agent/prompts/system.ts +0 -246
- package/vendor/agent-root/src/agent/prompts/system1.ts +0 -208
- package/vendor/agent-root/src/agent/storage/__test__/file-history-store.test.ts +0 -98
- package/vendor/agent-root/src/agent/storage/file-history-store.ts +0 -313
- package/vendor/agent-root/src/agent/storage/file-storage-config.ts +0 -94
- package/vendor/agent-root/src/agent/storage/file-system.ts +0 -31
- package/vendor/agent-root/src/agent/storage/file-write-service.ts +0 -21
- package/vendor/agent-root/src/agent/tool/__test__/base-tool.test.ts +0 -413
- package/vendor/agent-root/src/agent/tool/__test__/bash-policy.test.ts +0 -356
- package/vendor/agent-root/src/agent/tool/__test__/bash.mocked-coverage.test.ts +0 -375
- package/vendor/agent-root/src/agent/tool/__test__/bash.test.ts +0 -372
- package/vendor/agent-root/src/agent/tool/__test__/error.test.ts +0 -108
- package/vendor/agent-root/src/agent/tool/__test__/file-edit-tool.test.ts +0 -258
- package/vendor/agent-root/src/agent/tool/__test__/file-history-tools.test.ts +0 -121
- package/vendor/agent-root/src/agent/tool/__test__/file-read-tool.test.ts +0 -210
- package/vendor/agent-root/src/agent/tool/__test__/glob.test.ts +0 -139
- package/vendor/agent-root/src/agent/tool/__test__/grep.mocked-coverage.test.ts +0 -456
- package/vendor/agent-root/src/agent/tool/__test__/grep.test.ts +0 -192
- package/vendor/agent-root/src/agent/tool/__test__/lsp.test.ts +0 -300
- package/vendor/agent-root/src/agent/tool/__test__/outside-workspace-confirmation.test.ts +0 -214
- package/vendor/agent-root/src/agent/tool/__test__/path-security.test.ts +0 -336
- package/vendor/agent-root/src/agent/tool/__test__/skill-loader.test.ts +0 -494
- package/vendor/agent-root/src/agent/tool/__test__/skill-parser.test.ts +0 -543
- package/vendor/agent-root/src/agent/tool/__test__/skill-tool.test.ts +0 -172
- package/vendor/agent-root/src/agent/tool/__test__/task-concurrency-and-version.test.ts +0 -116
- package/vendor/agent-root/src/agent/tool/__test__/task-create-get-list-update.test.ts +0 -267
- package/vendor/agent-root/src/agent/tool/__test__/task-create.test.ts +0 -519
- package/vendor/agent-root/src/agent/tool/__test__/task-errors.test.ts +0 -225
- package/vendor/agent-root/src/agent/tool/__test__/task-output-blocking.test.ts +0 -223
- package/vendor/agent-root/src/agent/tool/__test__/task-output.test.ts +0 -184
- package/vendor/agent-root/src/agent/tool/__test__/task-parent-abort.test.ts +0 -287
- package/vendor/agent-root/src/agent/tool/__test__/task-real-runner-adapter.test.ts +0 -190
- package/vendor/agent-root/src/agent/tool/__test__/task-run-lifecycle.test.ts +0 -352
- package/vendor/agent-root/src/agent/tool/__test__/task-store-runner-branches.test.ts +0 -395
- package/vendor/agent-root/src/agent/tool/__test__/task-store.test.ts +0 -391
- package/vendor/agent-root/src/agent/tool/__test__/task-subagent-config-integration.test.ts +0 -176
- package/vendor/agent-root/src/agent/tool/__test__/task-subagent-config.test.ts +0 -68
- package/vendor/agent-root/src/agent/tool/__test__/task-tools-core-edges.test.ts +0 -630
- package/vendor/agent-root/src/agent/tool/__test__/task-tools-runtime-edges.test.ts +0 -732
- package/vendor/agent-root/src/agent/tool/__test__/task-types.test.ts +0 -494
- package/vendor/agent-root/src/agent/tool/__test__/task-utils-branches.test.ts +0 -175
- package/vendor/agent-root/src/agent/tool/__test__/tool-manager.test.ts +0 -505
- package/vendor/agent-root/src/agent/tool/__test__/types.test.ts +0 -55
- package/vendor/agent-root/src/agent/tool/__test__/web-fetch.test.ts +0 -244
- package/vendor/agent-root/src/agent/tool/__test__/web-search.test.ts +0 -290
- package/vendor/agent-root/src/agent/tool/__test__/write-file.test.ts +0 -368
- package/vendor/agent-root/src/agent/tool/base-tool.ts +0 -345
- package/vendor/agent-root/src/agent/tool/bash-policy.ts +0 -636
- package/vendor/agent-root/src/agent/tool/bash.ts +0 -688
- package/vendor/agent-root/src/agent/tool/error.ts +0 -131
- package/vendor/agent-root/src/agent/tool/file-edit-tool.ts +0 -264
- package/vendor/agent-root/src/agent/tool/file-history-list.ts +0 -103
- package/vendor/agent-root/src/agent/tool/file-history-restore.ts +0 -149
- package/vendor/agent-root/src/agent/tool/file-read-tool.ts +0 -211
- package/vendor/agent-root/src/agent/tool/glob.ts +0 -171
- package/vendor/agent-root/src/agent/tool/grep.ts +0 -496
- package/vendor/agent-root/src/agent/tool/lsp.ts +0 -481
- package/vendor/agent-root/src/agent/tool/path-security.ts +0 -117
- package/vendor/agent-root/src/agent/tool/search/common.ts +0 -153
- package/vendor/agent-root/src/agent/tool/skill/index.ts +0 -13
- package/vendor/agent-root/src/agent/tool/skill/loader.ts +0 -229
- package/vendor/agent-root/src/agent/tool/skill/parser.ts +0 -124
- package/vendor/agent-root/src/agent/tool/skill/types.ts +0 -27
- package/vendor/agent-root/src/agent/tool/skill-tool.ts +0 -143
- package/vendor/agent-root/src/agent/tool/task-create.ts +0 -186
- package/vendor/agent-root/src/agent/tool/task-errors.ts +0 -42
- package/vendor/agent-root/src/agent/tool/task-get.ts +0 -116
- package/vendor/agent-root/src/agent/tool/task-graph.ts +0 -78
- package/vendor/agent-root/src/agent/tool/task-list.ts +0 -141
- package/vendor/agent-root/src/agent/tool/task-mock-runner-adapter.ts +0 -232
- package/vendor/agent-root/src/agent/tool/task-output.ts +0 -223
- package/vendor/agent-root/src/agent/tool/task-parent-abort.ts +0 -115
- package/vendor/agent-root/src/agent/tool/task-real-runner-adapter.ts +0 -336
- package/vendor/agent-root/src/agent/tool/task-runner-adapter.ts +0 -55
- package/vendor/agent-root/src/agent/tool/task-stop.ts +0 -187
- package/vendor/agent-root/src/agent/tool/task-store.ts +0 -217
- package/vendor/agent-root/src/agent/tool/task-subagent-config.ts +0 -149
- package/vendor/agent-root/src/agent/tool/task-types.ts +0 -264
- package/vendor/agent-root/src/agent/tool/task-update.ts +0 -315
- package/vendor/agent-root/src/agent/tool/task.ts +0 -209
- package/vendor/agent-root/src/agent/tool/tool-manager.ts +0 -361
- package/vendor/agent-root/src/agent/tool/tool-prompts.ts +0 -242
- package/vendor/agent-root/src/agent/tool/types.ts +0 -116
- package/vendor/agent-root/src/agent/tool/web-fetch.ts +0 -227
- package/vendor/agent-root/src/agent/tool/web-search.ts +0 -208
- package/vendor/agent-root/src/agent/tool/write-file.ts +0 -497
- package/vendor/agent-root/src/agent/types.ts +0 -232
- package/vendor/agent-root/src/agent/utils/__tests__/index.test.ts +0 -18
- package/vendor/agent-root/src/agent/utils/__tests__/message-utils.test.ts +0 -610
- package/vendor/agent-root/src/agent/utils/__tests__/message.test.ts +0 -223
- package/vendor/agent-root/src/agent/utils/__tests__/token.test.ts +0 -42
- package/vendor/agent-root/src/agent/utils/index.ts +0 -16
- package/vendor/agent-root/src/agent/utils/message.ts +0 -171
- package/vendor/agent-root/src/agent/utils/token.ts +0 -28
- package/vendor/agent-root/src/config/__tests__/load-config-to-env.test.ts +0 -238
- package/vendor/agent-root/src/config/__tests__/loader.test.ts +0 -361
- package/vendor/agent-root/src/config/__tests__/runtime.test.ts +0 -88
- package/vendor/agent-root/src/config/index.ts +0 -55
- package/vendor/agent-root/src/config/loader.ts +0 -494
- package/vendor/agent-root/src/config/paths.ts +0 -30
- package/vendor/agent-root/src/config/runtime.ts +0 -163
- package/vendor/agent-root/src/config/types.ts +0 -96
- package/vendor/agent-root/src/logger/index.ts +0 -57
- package/vendor/agent-root/src/logger/logger.ts +0 -819
- package/vendor/agent-root/src/logger/types.ts +0 -150
- package/vendor/agent-root/src/providers/__tests__/errors.test.ts +0 -441
- package/vendor/agent-root/src/providers/__tests__/index.test.ts +0 -16
- package/vendor/agent-root/src/providers/__tests__/openai-compatible.options.test.ts +0 -318
- package/vendor/agent-root/src/providers/__tests__/openai-compatible.test.ts +0 -600
- package/vendor/agent-root/src/providers/__tests__/registry.test.ts +0 -523
- package/vendor/agent-root/src/providers/__tests__/responses-adapter.test.ts +0 -298
- package/vendor/agent-root/src/providers/adapters/__tests__/anthropic.test.ts +0 -354
- package/vendor/agent-root/src/providers/adapters/__tests__/kimi.test.ts +0 -58
- package/vendor/agent-root/src/providers/adapters/__tests__/standard.test.ts +0 -261
- package/vendor/agent-root/src/providers/adapters/anthropic.ts +0 -572
- package/vendor/agent-root/src/providers/adapters/base.ts +0 -131
- package/vendor/agent-root/src/providers/adapters/kimi.ts +0 -48
- package/vendor/agent-root/src/providers/adapters/responses.ts +0 -732
- package/vendor/agent-root/src/providers/adapters/standard.ts +0 -120
- package/vendor/agent-root/src/providers/http/__tests__/client.timeout.test.ts +0 -313
- package/vendor/agent-root/src/providers/http/client.ts +0 -289
- package/vendor/agent-root/src/providers/http/stream-parser.ts +0 -109
- package/vendor/agent-root/src/providers/index.ts +0 -76
- package/vendor/agent-root/src/providers/kimi-headers.ts +0 -177
- package/vendor/agent-root/src/providers/openai-compatible.ts +0 -387
- package/vendor/agent-root/src/providers/registry/model-config.ts +0 -477
- package/vendor/agent-root/src/providers/registry/provider-factory.ts +0 -127
- package/vendor/agent-root/src/providers/registry.ts +0 -135
- package/vendor/agent-root/src/providers/types/api.ts +0 -284
- package/vendor/agent-root/src/providers/types/config.ts +0 -58
- package/vendor/agent-root/src/providers/types/errors.ts +0 -323
- package/vendor/agent-root/src/providers/types/index.ts +0 -72
- package/vendor/agent-root/src/providers/types/provider.ts +0 -45
- package/vendor/agent-root/src/providers/types/registry.ts +0 -68
|
@@ -1,3607 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import * as os from 'node:os';
|
|
3
|
-
import * as path from 'node:path';
|
|
4
|
-
import { promises as fs } from 'node:fs';
|
|
5
|
-
import { StatelessAgent } from '../index';
|
|
6
|
-
import type {
|
|
7
|
-
AgentMetric,
|
|
8
|
-
AgentTraceEvent,
|
|
9
|
-
CompactionInfo,
|
|
10
|
-
Message,
|
|
11
|
-
StreamEvent,
|
|
12
|
-
ToolPolicyCheckInfo,
|
|
13
|
-
ToolPolicyDecision,
|
|
14
|
-
} from '../../types';
|
|
15
|
-
import type { ToolManager } from '../../tool/tool-manager';
|
|
16
|
-
import { DefaultToolManager } from '../../tool/tool-manager';
|
|
17
|
-
import { BashTool } from '../../tool/bash';
|
|
18
|
-
import { WriteFileTool } from '../../tool/write-file';
|
|
19
|
-
import type { Chunk, LLMProvider, ToolCall } from '../../../providers';
|
|
20
|
-
import {
|
|
21
|
-
LLMAuthError,
|
|
22
|
-
LLMBadRequestError,
|
|
23
|
-
LLMError,
|
|
24
|
-
LLMNotFoundError,
|
|
25
|
-
LLMPermanentError,
|
|
26
|
-
LLMRateLimitError,
|
|
27
|
-
LLMRetryableError,
|
|
28
|
-
} from '../../../providers';
|
|
29
|
-
import { AgentError } from '../error';
|
|
30
|
-
import type { ToolConcurrencyPolicy } from '../../tool/types';
|
|
31
|
-
import * as compactionModule from '../compaction';
|
|
32
|
-
import { InMemoryToolExecutionLedger } from '../tool-execution-ledger';
|
|
33
|
-
|
|
34
|
-
type ChunkDelta = NonNullable<NonNullable<Chunk['choices']>[number]>['delta'];
|
|
35
|
-
|
|
36
|
-
type AgentPrivate = {
|
|
37
|
-
executeTool: (
|
|
38
|
-
toolCall: ToolCall,
|
|
39
|
-
stepIndex: number,
|
|
40
|
-
callbacks?: {
|
|
41
|
-
onMessage?: (message: Message) => void | Promise<void>;
|
|
42
|
-
onMetric?: (metric: AgentMetric) => void | Promise<void>;
|
|
43
|
-
onTrace?: (event: AgentTraceEvent) => void | Promise<void>;
|
|
44
|
-
onToolPolicy?: (
|
|
45
|
-
info: ToolPolicyCheckInfo
|
|
46
|
-
) => ToolPolicyDecision | Promise<ToolPolicyDecision>;
|
|
47
|
-
},
|
|
48
|
-
abortSignal?: AbortSignal,
|
|
49
|
-
executionId?: string
|
|
50
|
-
) => AsyncGenerator<StreamEvent>;
|
|
51
|
-
processToolCalls: (
|
|
52
|
-
calls: ToolCall[],
|
|
53
|
-
messages: Message[],
|
|
54
|
-
stepIndex: number,
|
|
55
|
-
callbacks?: { onMessage?: (message: Message) => void | Promise<void> }
|
|
56
|
-
) => AsyncGenerator<StreamEvent>;
|
|
57
|
-
convertMessageToLLMMessage: (msg: Message) => unknown;
|
|
58
|
-
safeCallback: (cb: ((arg: string) => Promise<void>) | undefined, arg: string) => Promise<void>;
|
|
59
|
-
safeErrorCallback: (
|
|
60
|
-
cb: ((err: Error) => { retry: boolean }) | undefined,
|
|
61
|
-
err: Error
|
|
62
|
-
) => Promise<{ retry: boolean } | undefined>;
|
|
63
|
-
mergeToolCalls: (
|
|
64
|
-
existing: Array<{ id: string; function: { arguments: string } }>,
|
|
65
|
-
incoming: Array<{ id: string; function: { arguments: string } }>,
|
|
66
|
-
messageId: string
|
|
67
|
-
) => Promise<
|
|
68
|
-
Array<{
|
|
69
|
-
id: string;
|
|
70
|
-
function: { arguments: string };
|
|
71
|
-
}>
|
|
72
|
-
>;
|
|
73
|
-
yieldCheckpoint: (
|
|
74
|
-
executionId: string | undefined,
|
|
75
|
-
step: number,
|
|
76
|
-
last: Message | undefined,
|
|
77
|
-
callbacks?: { onCheckpoint: (cp: unknown) => void }
|
|
78
|
-
) => AsyncGenerator<StreamEvent>;
|
|
79
|
-
sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
80
|
-
normalizeError: (error: unknown) => AgentError;
|
|
81
|
-
throwIfAborted: (signal?: AbortSignal) => void;
|
|
82
|
-
runWithConcurrencyAndLock: <T>(
|
|
83
|
-
tasks: Array<{ lockKey?: string; run: () => Promise<T> }>,
|
|
84
|
-
limit: number
|
|
85
|
-
) => Promise<T[]>;
|
|
86
|
-
resolveToolConcurrencyPolicy: (toolCall: ToolCall) => ToolConcurrencyPolicy;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
type TestDelta = Partial<ChunkDelta> & { finish_reason?: string };
|
|
90
|
-
type TestChunk = Omit<Chunk, 'choices'> & {
|
|
91
|
-
choices?: Array<{
|
|
92
|
-
index: number;
|
|
93
|
-
delta: TestDelta;
|
|
94
|
-
}>;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
function toStream(chunks: TestChunk[]): AsyncGenerator<Chunk> {
|
|
98
|
-
return (async function* () {
|
|
99
|
-
for (const chunk of chunks) {
|
|
100
|
-
yield chunk as Chunk;
|
|
101
|
-
}
|
|
102
|
-
})();
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function toToolCallStream(
|
|
106
|
-
responseId: string,
|
|
107
|
-
toolCallId: string,
|
|
108
|
-
toolName: string,
|
|
109
|
-
args: Record<string, unknown>
|
|
110
|
-
): AsyncGenerator<Chunk> {
|
|
111
|
-
const raw = JSON.stringify(args);
|
|
112
|
-
const cut = Math.max(1, Math.floor(raw.length / 2));
|
|
113
|
-
return toStream([
|
|
114
|
-
{
|
|
115
|
-
id: responseId,
|
|
116
|
-
index: 0,
|
|
117
|
-
choices: [
|
|
118
|
-
{
|
|
119
|
-
index: 0,
|
|
120
|
-
delta: {
|
|
121
|
-
tool_calls: [
|
|
122
|
-
{
|
|
123
|
-
id: toolCallId,
|
|
124
|
-
type: 'function',
|
|
125
|
-
index: 0,
|
|
126
|
-
function: { name: toolName, arguments: raw.slice(0, cut) },
|
|
127
|
-
},
|
|
128
|
-
],
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
],
|
|
132
|
-
},
|
|
133
|
-
{
|
|
134
|
-
index: 0,
|
|
135
|
-
choices: [
|
|
136
|
-
{
|
|
137
|
-
index: 0,
|
|
138
|
-
delta: {
|
|
139
|
-
tool_calls: [
|
|
140
|
-
{
|
|
141
|
-
id: toolCallId,
|
|
142
|
-
type: 'function',
|
|
143
|
-
index: 0,
|
|
144
|
-
function: { name: toolName, arguments: raw.slice(cut) },
|
|
145
|
-
},
|
|
146
|
-
],
|
|
147
|
-
finish_reason: 'tool_calls',
|
|
148
|
-
} as unknown as ChunkDelta,
|
|
149
|
-
},
|
|
150
|
-
],
|
|
151
|
-
},
|
|
152
|
-
]);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async function collectEvents(generator: AsyncGenerator<StreamEvent>): Promise<StreamEvent[]> {
|
|
156
|
-
const events: StreamEvent[] = [];
|
|
157
|
-
for await (const event of generator) {
|
|
158
|
-
events.push(event);
|
|
159
|
-
}
|
|
160
|
-
return events;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function createProvider() {
|
|
164
|
-
return {
|
|
165
|
-
config: {} as Record<string, unknown>,
|
|
166
|
-
generate: vi.fn(),
|
|
167
|
-
generateStream: vi.fn(),
|
|
168
|
-
getTimeTimeout: vi.fn(() => 1),
|
|
169
|
-
getLLMMaxTokens: vi.fn(() => 1),
|
|
170
|
-
getMaxOutputTokens: vi.fn(() => 1),
|
|
171
|
-
} as unknown as LLMProvider;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function createToolManager() {
|
|
175
|
-
return {
|
|
176
|
-
execute: vi.fn(),
|
|
177
|
-
registerTool: vi.fn(),
|
|
178
|
-
getTools: vi.fn(() => []),
|
|
179
|
-
getConcurrencyPolicy: vi.fn(() => ({ mode: 'exclusive' as const })),
|
|
180
|
-
} as unknown as ToolManager;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function createInput() {
|
|
184
|
-
const message: Message = {
|
|
185
|
-
messageId: 'u1',
|
|
186
|
-
type: 'user',
|
|
187
|
-
role: 'user',
|
|
188
|
-
content: 'hello',
|
|
189
|
-
timestamp: Date.now(),
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
return {
|
|
193
|
-
executionId: 'exec_1',
|
|
194
|
-
conversationId: 'conv_1',
|
|
195
|
-
messages: [message],
|
|
196
|
-
maxSteps: 4,
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
describe('StatelessAgent', () => {
|
|
201
|
-
beforeEach(() => {
|
|
202
|
-
vi.clearAllMocks();
|
|
203
|
-
vi.useRealTimers();
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it('runs stream and yields chunk/reasoning/done without tool calls', async () => {
|
|
207
|
-
const provider = createProvider();
|
|
208
|
-
const manager = createToolManager();
|
|
209
|
-
provider.generateStream = vi.fn().mockReturnValue(
|
|
210
|
-
toStream([
|
|
211
|
-
{
|
|
212
|
-
id: 'resp_hello',
|
|
213
|
-
index: 0,
|
|
214
|
-
choices: [{ index: 0, delta: { content: 'Hello' } }],
|
|
215
|
-
},
|
|
216
|
-
{
|
|
217
|
-
index: 0,
|
|
218
|
-
choices: [{ index: 0, delta: { reasoning_content: 'think' } }],
|
|
219
|
-
},
|
|
220
|
-
{
|
|
221
|
-
index: 0,
|
|
222
|
-
usage: {
|
|
223
|
-
prompt_tokens: 10,
|
|
224
|
-
completion_tokens: 5,
|
|
225
|
-
total_tokens: 15,
|
|
226
|
-
},
|
|
227
|
-
},
|
|
228
|
-
{
|
|
229
|
-
index: 0,
|
|
230
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
231
|
-
},
|
|
232
|
-
])
|
|
233
|
-
);
|
|
234
|
-
|
|
235
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
236
|
-
maxRetryCount: 3,
|
|
237
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
238
|
-
});
|
|
239
|
-
const onMessage = vi.fn();
|
|
240
|
-
const events = await collectEvents(
|
|
241
|
-
agent.runStream(createInput(), { onMessage, onCheckpoint: vi.fn() })
|
|
242
|
-
);
|
|
243
|
-
|
|
244
|
-
expect(events.map((e) => e.type)).toEqual(['progress', 'chunk', 'reasoning_chunk', 'done']);
|
|
245
|
-
expect(events[3]?.data).toMatchObject({ finishReason: 'stop', steps: 1 });
|
|
246
|
-
expect(onMessage).toHaveBeenCalledOnce();
|
|
247
|
-
expect(onMessage.mock.calls[0]?.[0]).toMatchObject({
|
|
248
|
-
role: 'assistant',
|
|
249
|
-
content: 'Hello',
|
|
250
|
-
reasoning_content: 'think',
|
|
251
|
-
metadata: {
|
|
252
|
-
responseId: 'resp_hello',
|
|
253
|
-
},
|
|
254
|
-
usage: {
|
|
255
|
-
prompt_tokens: 10,
|
|
256
|
-
completion_tokens: 5,
|
|
257
|
-
total_tokens: 15,
|
|
258
|
-
},
|
|
259
|
-
});
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it('captures usage from a usage-only tail chunk after finish_reason', async () => {
|
|
263
|
-
const provider = createProvider();
|
|
264
|
-
const manager = createToolManager();
|
|
265
|
-
provider.generateStream = vi.fn().mockReturnValue(
|
|
266
|
-
toStream([
|
|
267
|
-
{
|
|
268
|
-
index: 0,
|
|
269
|
-
choices: [{ index: 0, delta: { content: 'ok' } }],
|
|
270
|
-
},
|
|
271
|
-
{
|
|
272
|
-
index: 0,
|
|
273
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
274
|
-
},
|
|
275
|
-
{
|
|
276
|
-
index: 0,
|
|
277
|
-
choices: [],
|
|
278
|
-
usage: {
|
|
279
|
-
prompt_tokens: 12,
|
|
280
|
-
completion_tokens: 8,
|
|
281
|
-
total_tokens: 20,
|
|
282
|
-
},
|
|
283
|
-
},
|
|
284
|
-
])
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
288
|
-
maxRetryCount: 3,
|
|
289
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
290
|
-
});
|
|
291
|
-
const onMessage = vi.fn();
|
|
292
|
-
|
|
293
|
-
await collectEvents(agent.runStream(createInput(), { onMessage, onCheckpoint: vi.fn() }));
|
|
294
|
-
|
|
295
|
-
expect(onMessage).toHaveBeenCalledOnce();
|
|
296
|
-
expect(onMessage.mock.calls[0]?.[0]).toMatchObject({
|
|
297
|
-
role: 'assistant',
|
|
298
|
-
content: 'ok',
|
|
299
|
-
usage: {
|
|
300
|
-
prompt_tokens: 12,
|
|
301
|
-
completion_tokens: 8,
|
|
302
|
-
total_tokens: 20,
|
|
303
|
-
},
|
|
304
|
-
});
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it('uses previous_response_id and keeps tool-call/tool-result pairs in the delta', async () => {
|
|
308
|
-
const provider = createProvider();
|
|
309
|
-
const manager = createToolManager();
|
|
310
|
-
manager.execute = vi.fn().mockResolvedValue({
|
|
311
|
-
success: true,
|
|
312
|
-
output: '{"temperature":26}',
|
|
313
|
-
});
|
|
314
|
-
provider.generateStream = vi
|
|
315
|
-
.fn()
|
|
316
|
-
.mockReturnValueOnce(
|
|
317
|
-
toToolCallStream('resp_tool_1', 'call_1', 'lookup_weather', {
|
|
318
|
-
city: 'Shanghai',
|
|
319
|
-
})
|
|
320
|
-
)
|
|
321
|
-
.mockReturnValueOnce(
|
|
322
|
-
toStream([
|
|
323
|
-
{
|
|
324
|
-
id: 'resp_tool_2',
|
|
325
|
-
index: 0,
|
|
326
|
-
choices: [{ index: 0, delta: { content: 'done' } }],
|
|
327
|
-
},
|
|
328
|
-
{
|
|
329
|
-
index: 0,
|
|
330
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
331
|
-
},
|
|
332
|
-
])
|
|
333
|
-
);
|
|
334
|
-
|
|
335
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
336
|
-
maxRetryCount: 3,
|
|
337
|
-
enableServerSideContinuation: true,
|
|
338
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
339
|
-
});
|
|
340
|
-
const onMessage = vi.fn();
|
|
341
|
-
|
|
342
|
-
await collectEvents(agent.runStream(createInput(), { onMessage, onCheckpoint: vi.fn() }));
|
|
343
|
-
|
|
344
|
-
const generateStreamCalls = (
|
|
345
|
-
provider.generateStream as unknown as { mock: { calls: unknown[][] } }
|
|
346
|
-
).mock.calls;
|
|
347
|
-
expect(generateStreamCalls).toHaveLength(2);
|
|
348
|
-
expect(generateStreamCalls[0]?.[0]).toMatchObject([{ role: 'user', content: 'hello' }]);
|
|
349
|
-
expect(generateStreamCalls[0]?.[1]).toMatchObject({
|
|
350
|
-
prompt_cache_key: 'conv_1',
|
|
351
|
-
});
|
|
352
|
-
expect(generateStreamCalls[0]?.[1] ?? {}).not.toHaveProperty('previous_response_id');
|
|
353
|
-
expect(generateStreamCalls[1]?.[0]).toMatchObject([
|
|
354
|
-
{
|
|
355
|
-
role: 'assistant',
|
|
356
|
-
content: '',
|
|
357
|
-
tool_calls: [
|
|
358
|
-
{
|
|
359
|
-
id: 'call_1',
|
|
360
|
-
type: 'function',
|
|
361
|
-
function: {
|
|
362
|
-
name: 'lookup_weather',
|
|
363
|
-
arguments: '{"city":"Shanghai"}',
|
|
364
|
-
},
|
|
365
|
-
},
|
|
366
|
-
],
|
|
367
|
-
},
|
|
368
|
-
{
|
|
369
|
-
role: 'tool',
|
|
370
|
-
tool_call_id: 'call_1',
|
|
371
|
-
content: '{"temperature":26}',
|
|
372
|
-
},
|
|
373
|
-
]);
|
|
374
|
-
expect(generateStreamCalls[1]?.[1]).toMatchObject({
|
|
375
|
-
previous_response_id: 'resp_tool_1',
|
|
376
|
-
prompt_cache_key: 'conv_1',
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
const assistantMessages = onMessage.mock.calls
|
|
380
|
-
.map((call) => call[0] as Message)
|
|
381
|
-
.filter((message) => message.role === 'assistant');
|
|
382
|
-
expect(assistantMessages[0]?.metadata).toMatchObject({
|
|
383
|
-
responseId: 'resp_tool_1',
|
|
384
|
-
continuationMode: 'full',
|
|
385
|
-
});
|
|
386
|
-
expect(assistantMessages[1]?.metadata).toMatchObject({
|
|
387
|
-
responseId: 'resp_tool_2',
|
|
388
|
-
continuationMode: 'incremental',
|
|
389
|
-
previousResponseIdUsed: 'resp_tool_1',
|
|
390
|
-
continuationBaselineMessageCount: 1,
|
|
391
|
-
continuationDeltaMessageCount: 2,
|
|
392
|
-
});
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
it('reuses previous_response_id across runs when history is append-only', async () => {
|
|
396
|
-
const provider = createProvider();
|
|
397
|
-
const manager = createToolManager();
|
|
398
|
-
provider.generateStream = vi
|
|
399
|
-
.fn()
|
|
400
|
-
.mockReturnValueOnce(
|
|
401
|
-
toStream([
|
|
402
|
-
{
|
|
403
|
-
id: 'resp_prev_run',
|
|
404
|
-
index: 0,
|
|
405
|
-
choices: [{ index: 0, delta: { content: 'Hello there' } }],
|
|
406
|
-
},
|
|
407
|
-
{
|
|
408
|
-
index: 0,
|
|
409
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
410
|
-
},
|
|
411
|
-
])
|
|
412
|
-
)
|
|
413
|
-
.mockReturnValueOnce(
|
|
414
|
-
toStream([
|
|
415
|
-
{
|
|
416
|
-
id: 'resp_next_run',
|
|
417
|
-
index: 0,
|
|
418
|
-
choices: [{ index: 0, delta: { content: 'Welcome back' } }],
|
|
419
|
-
},
|
|
420
|
-
{
|
|
421
|
-
index: 0,
|
|
422
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
423
|
-
},
|
|
424
|
-
])
|
|
425
|
-
);
|
|
426
|
-
|
|
427
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
428
|
-
maxRetryCount: 3,
|
|
429
|
-
enableServerSideContinuation: true,
|
|
430
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
431
|
-
});
|
|
432
|
-
const firstRunMessages: Message[] = [];
|
|
433
|
-
await collectEvents(
|
|
434
|
-
agent.runStream(createInput(), {
|
|
435
|
-
onMessage: async (message) => {
|
|
436
|
-
firstRunMessages.push(message);
|
|
437
|
-
},
|
|
438
|
-
onCheckpoint: vi.fn(),
|
|
439
|
-
})
|
|
440
|
-
);
|
|
441
|
-
|
|
442
|
-
const previousAssistant = firstRunMessages.find((message) => message.role === 'assistant');
|
|
443
|
-
expect(previousAssistant?.metadata).toMatchObject({
|
|
444
|
-
responseId: 'resp_prev_run',
|
|
445
|
-
continuationMode: 'full',
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
const historyMessages = [...createInput().messages, previousAssistant as Message];
|
|
449
|
-
await collectEvents(
|
|
450
|
-
agent.runStream(
|
|
451
|
-
{
|
|
452
|
-
executionId: 'exec_2',
|
|
453
|
-
conversationId: 'conv_1',
|
|
454
|
-
messages: [
|
|
455
|
-
...historyMessages,
|
|
456
|
-
{
|
|
457
|
-
messageId: 'u2',
|
|
458
|
-
type: 'user',
|
|
459
|
-
role: 'user',
|
|
460
|
-
content: 'What can you do now?',
|
|
461
|
-
timestamp: Date.now(),
|
|
462
|
-
},
|
|
463
|
-
],
|
|
464
|
-
maxSteps: 4,
|
|
465
|
-
},
|
|
466
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
467
|
-
)
|
|
468
|
-
);
|
|
469
|
-
|
|
470
|
-
const generateStreamCalls = (
|
|
471
|
-
provider.generateStream as unknown as { mock: { calls: unknown[][] } }
|
|
472
|
-
).mock.calls;
|
|
473
|
-
expect(generateStreamCalls).toHaveLength(2);
|
|
474
|
-
expect(generateStreamCalls[1]?.[0]).toMatchObject([
|
|
475
|
-
{
|
|
476
|
-
role: 'user',
|
|
477
|
-
content: 'What can you do now?',
|
|
478
|
-
},
|
|
479
|
-
]);
|
|
480
|
-
expect(generateStreamCalls[1]?.[1]).toMatchObject({
|
|
481
|
-
previous_response_id: 'resp_prev_run',
|
|
482
|
-
prompt_cache_key: 'conv_1',
|
|
483
|
-
});
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
it('falls back to full replay when non-input config changes', async () => {
|
|
487
|
-
const provider = createProvider();
|
|
488
|
-
const manager = createToolManager();
|
|
489
|
-
provider.generateStream = vi
|
|
490
|
-
.fn()
|
|
491
|
-
.mockReturnValueOnce(
|
|
492
|
-
toStream([
|
|
493
|
-
{
|
|
494
|
-
id: 'resp_config_base',
|
|
495
|
-
index: 0,
|
|
496
|
-
choices: [{ index: 0, delta: { content: 'base' } }],
|
|
497
|
-
},
|
|
498
|
-
{
|
|
499
|
-
index: 0,
|
|
500
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
501
|
-
},
|
|
502
|
-
])
|
|
503
|
-
)
|
|
504
|
-
.mockReturnValueOnce(
|
|
505
|
-
toStream([
|
|
506
|
-
{
|
|
507
|
-
id: 'resp_config_new',
|
|
508
|
-
index: 0,
|
|
509
|
-
choices: [{ index: 0, delta: { content: 'new' } }],
|
|
510
|
-
},
|
|
511
|
-
{
|
|
512
|
-
index: 0,
|
|
513
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
514
|
-
},
|
|
515
|
-
])
|
|
516
|
-
);
|
|
517
|
-
|
|
518
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
519
|
-
maxRetryCount: 3,
|
|
520
|
-
enableServerSideContinuation: true,
|
|
521
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
522
|
-
});
|
|
523
|
-
const firstRunMessages: Message[] = [];
|
|
524
|
-
await collectEvents(
|
|
525
|
-
agent.runStream(createInput(), {
|
|
526
|
-
onMessage: async (message) => {
|
|
527
|
-
firstRunMessages.push(message);
|
|
528
|
-
},
|
|
529
|
-
onCheckpoint: vi.fn(),
|
|
530
|
-
})
|
|
531
|
-
);
|
|
532
|
-
|
|
533
|
-
const previousAssistant = firstRunMessages.find((message) => message.role === 'assistant');
|
|
534
|
-
await collectEvents(
|
|
535
|
-
agent.runStream(
|
|
536
|
-
{
|
|
537
|
-
executionId: 'exec_3',
|
|
538
|
-
conversationId: 'conv_1',
|
|
539
|
-
messages: [
|
|
540
|
-
...createInput().messages,
|
|
541
|
-
previousAssistant as Message,
|
|
542
|
-
{
|
|
543
|
-
messageId: 'u3',
|
|
544
|
-
type: 'user',
|
|
545
|
-
role: 'user',
|
|
546
|
-
content: 'next question',
|
|
547
|
-
timestamp: Date.now(),
|
|
548
|
-
},
|
|
549
|
-
],
|
|
550
|
-
config: { temperature: 0.2 },
|
|
551
|
-
maxSteps: 4,
|
|
552
|
-
},
|
|
553
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
554
|
-
)
|
|
555
|
-
);
|
|
556
|
-
|
|
557
|
-
const generateStreamCalls = (
|
|
558
|
-
provider.generateStream as unknown as { mock: { calls: unknown[][] } }
|
|
559
|
-
).mock.calls;
|
|
560
|
-
expect(generateStreamCalls).toHaveLength(2);
|
|
561
|
-
expect(generateStreamCalls[1]?.[0]).toMatchObject([
|
|
562
|
-
{ role: 'user', content: 'hello' },
|
|
563
|
-
{ role: 'assistant', content: 'base' },
|
|
564
|
-
{ role: 'user', content: 'next question' },
|
|
565
|
-
]);
|
|
566
|
-
expect(generateStreamCalls[1]?.[1] ?? {}).not.toHaveProperty('previous_response_id');
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
it('falls back to full replay when prior context prefix no longer matches', async () => {
|
|
570
|
-
const provider = createProvider();
|
|
571
|
-
const manager = createToolManager();
|
|
572
|
-
provider.generateStream = vi
|
|
573
|
-
.fn()
|
|
574
|
-
.mockReturnValueOnce(
|
|
575
|
-
toStream([
|
|
576
|
-
{
|
|
577
|
-
id: 'resp_prefix_base',
|
|
578
|
-
index: 0,
|
|
579
|
-
choices: [{ index: 0, delta: { content: 'base' } }],
|
|
580
|
-
},
|
|
581
|
-
{
|
|
582
|
-
index: 0,
|
|
583
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
584
|
-
},
|
|
585
|
-
])
|
|
586
|
-
)
|
|
587
|
-
.mockReturnValueOnce(
|
|
588
|
-
toStream([
|
|
589
|
-
{
|
|
590
|
-
id: 'resp_prefix_new',
|
|
591
|
-
index: 0,
|
|
592
|
-
choices: [{ index: 0, delta: { content: 'new' } }],
|
|
593
|
-
},
|
|
594
|
-
{
|
|
595
|
-
index: 0,
|
|
596
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
597
|
-
},
|
|
598
|
-
])
|
|
599
|
-
);
|
|
600
|
-
|
|
601
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
602
|
-
maxRetryCount: 3,
|
|
603
|
-
enableServerSideContinuation: true,
|
|
604
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
605
|
-
});
|
|
606
|
-
const firstRunMessages: Message[] = [];
|
|
607
|
-
await collectEvents(
|
|
608
|
-
agent.runStream(createInput(), {
|
|
609
|
-
onMessage: async (message) => {
|
|
610
|
-
firstRunMessages.push(message);
|
|
611
|
-
},
|
|
612
|
-
onCheckpoint: vi.fn(),
|
|
613
|
-
})
|
|
614
|
-
);
|
|
615
|
-
|
|
616
|
-
const previousAssistant = firstRunMessages.find((message) => message.role === 'assistant');
|
|
617
|
-
await collectEvents(
|
|
618
|
-
agent.runStream(
|
|
619
|
-
{
|
|
620
|
-
executionId: 'exec_4',
|
|
621
|
-
conversationId: 'conv_1',
|
|
622
|
-
messages: [
|
|
623
|
-
{
|
|
624
|
-
messageId: 'u1_changed',
|
|
625
|
-
type: 'user',
|
|
626
|
-
role: 'user',
|
|
627
|
-
content: 'hello changed',
|
|
628
|
-
timestamp: Date.now(),
|
|
629
|
-
},
|
|
630
|
-
previousAssistant as Message,
|
|
631
|
-
{
|
|
632
|
-
messageId: 'u4',
|
|
633
|
-
type: 'user',
|
|
634
|
-
role: 'user',
|
|
635
|
-
content: 'next question',
|
|
636
|
-
timestamp: Date.now(),
|
|
637
|
-
},
|
|
638
|
-
],
|
|
639
|
-
maxSteps: 4,
|
|
640
|
-
},
|
|
641
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
642
|
-
)
|
|
643
|
-
);
|
|
644
|
-
|
|
645
|
-
const generateStreamCalls = (
|
|
646
|
-
provider.generateStream as unknown as { mock: { calls: unknown[][] } }
|
|
647
|
-
).mock.calls;
|
|
648
|
-
expect(generateStreamCalls).toHaveLength(2);
|
|
649
|
-
expect(generateStreamCalls[1]?.[0]).toMatchObject([
|
|
650
|
-
{ role: 'user', content: 'hello changed' },
|
|
651
|
-
{ role: 'assistant', content: 'base' },
|
|
652
|
-
{ role: 'user', content: 'next question' },
|
|
653
|
-
]);
|
|
654
|
-
expect(generateStreamCalls[1]?.[1] ?? {}).not.toHaveProperty('previous_response_id');
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
it('filters empty assistant-text messages before calling generateStream', async () => {
|
|
658
|
-
const provider = createProvider();
|
|
659
|
-
const manager = createToolManager();
|
|
660
|
-
provider.generateStream = vi.fn().mockReturnValue(
|
|
661
|
-
toStream([
|
|
662
|
-
{
|
|
663
|
-
index: 0,
|
|
664
|
-
choices: [{ index: 0, delta: { content: 'ok' } }],
|
|
665
|
-
},
|
|
666
|
-
{
|
|
667
|
-
index: 0,
|
|
668
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
669
|
-
},
|
|
670
|
-
])
|
|
671
|
-
);
|
|
672
|
-
|
|
673
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
674
|
-
maxRetryCount: 3,
|
|
675
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
676
|
-
});
|
|
677
|
-
await collectEvents(
|
|
678
|
-
agent.runStream(
|
|
679
|
-
{
|
|
680
|
-
...createInput(),
|
|
681
|
-
messages: [
|
|
682
|
-
createInput().messages[0]!,
|
|
683
|
-
{
|
|
684
|
-
messageId: 'empty_assistant',
|
|
685
|
-
role: 'assistant',
|
|
686
|
-
type: 'assistant-text',
|
|
687
|
-
content: '',
|
|
688
|
-
reasoning_content: '',
|
|
689
|
-
timestamp: Date.now(),
|
|
690
|
-
},
|
|
691
|
-
],
|
|
692
|
-
},
|
|
693
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
694
|
-
)
|
|
695
|
-
);
|
|
696
|
-
|
|
697
|
-
const generateStreamCalls = (
|
|
698
|
-
provider.generateStream as unknown as { mock: { calls: unknown[][] } }
|
|
699
|
-
).mock.calls;
|
|
700
|
-
expect(generateStreamCalls).toHaveLength(1);
|
|
701
|
-
const llmMessages = generateStreamCalls[0]?.[0] as Array<{ role: string; content: unknown }>;
|
|
702
|
-
expect(llmMessages).toHaveLength(1);
|
|
703
|
-
expect(llmMessages[0]).toMatchObject({ role: 'user', content: 'hello' });
|
|
704
|
-
});
|
|
705
|
-
|
|
706
|
-
it('passes abortSignal to llm generateStream config', async () => {
|
|
707
|
-
const provider = createProvider();
|
|
708
|
-
const manager = createToolManager();
|
|
709
|
-
provider.generateStream = vi.fn().mockReturnValue(
|
|
710
|
-
toStream([
|
|
711
|
-
{
|
|
712
|
-
index: 0,
|
|
713
|
-
choices: [{ index: 0, delta: { content: 'ok' } }],
|
|
714
|
-
},
|
|
715
|
-
{
|
|
716
|
-
index: 0,
|
|
717
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
718
|
-
},
|
|
719
|
-
])
|
|
720
|
-
);
|
|
721
|
-
|
|
722
|
-
const controller = new AbortController();
|
|
723
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
724
|
-
maxRetryCount: 3,
|
|
725
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
726
|
-
});
|
|
727
|
-
await collectEvents(
|
|
728
|
-
agent.runStream(
|
|
729
|
-
{
|
|
730
|
-
...createInput(),
|
|
731
|
-
abortSignal: controller.signal,
|
|
732
|
-
config: { temperature: 0.1 },
|
|
733
|
-
},
|
|
734
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
735
|
-
)
|
|
736
|
-
);
|
|
737
|
-
|
|
738
|
-
const generateStreamCalls = (
|
|
739
|
-
provider.generateStream as unknown as { mock: { calls: unknown[][] } }
|
|
740
|
-
).mock.calls;
|
|
741
|
-
expect(generateStreamCalls).toHaveLength(1);
|
|
742
|
-
const callConfig = generateStreamCalls[0]?.[1] as {
|
|
743
|
-
temperature?: number;
|
|
744
|
-
abortSignal?: AbortSignal;
|
|
745
|
-
prompt_cache_key?: string;
|
|
746
|
-
};
|
|
747
|
-
expect(callConfig.temperature).toBe(0.1);
|
|
748
|
-
expect(callConfig.abortSignal).toBe(controller.signal);
|
|
749
|
-
expect(callConfig.prompt_cache_key).toBe('conv_1');
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
it('preserves explicit prompt_cache_key when provided by caller', async () => {
|
|
753
|
-
const provider = createProvider();
|
|
754
|
-
const manager = createToolManager();
|
|
755
|
-
provider.generateStream = vi.fn().mockReturnValue(
|
|
756
|
-
toStream([
|
|
757
|
-
{
|
|
758
|
-
index: 0,
|
|
759
|
-
choices: [{ index: 0, delta: { content: 'ok' } }],
|
|
760
|
-
},
|
|
761
|
-
{
|
|
762
|
-
index: 0,
|
|
763
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
764
|
-
},
|
|
765
|
-
])
|
|
766
|
-
);
|
|
767
|
-
|
|
768
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
769
|
-
maxRetryCount: 3,
|
|
770
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
771
|
-
});
|
|
772
|
-
await collectEvents(
|
|
773
|
-
agent.runStream(
|
|
774
|
-
{
|
|
775
|
-
...createInput(),
|
|
776
|
-
config: {
|
|
777
|
-
prompt_cache_key: 'explicit-cache-key',
|
|
778
|
-
},
|
|
779
|
-
},
|
|
780
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
781
|
-
)
|
|
782
|
-
);
|
|
783
|
-
|
|
784
|
-
const generateStreamCalls = (
|
|
785
|
-
provider.generateStream as unknown as { mock: { calls: unknown[][] } }
|
|
786
|
-
).mock.calls;
|
|
787
|
-
expect(generateStreamCalls).toHaveLength(1);
|
|
788
|
-
expect(generateStreamCalls[0]?.[1]).toMatchObject({
|
|
789
|
-
prompt_cache_key: 'explicit-cache-key',
|
|
790
|
-
});
|
|
791
|
-
});
|
|
792
|
-
|
|
793
|
-
it('passes top-level tools into llm generateStream config', async () => {
|
|
794
|
-
const provider = createProvider();
|
|
795
|
-
const manager = createToolManager();
|
|
796
|
-
provider.generateStream = vi.fn().mockReturnValue(
|
|
797
|
-
toStream([
|
|
798
|
-
{
|
|
799
|
-
index: 0,
|
|
800
|
-
choices: [{ index: 0, delta: { content: 'ok' } }],
|
|
801
|
-
},
|
|
802
|
-
{
|
|
803
|
-
index: 0,
|
|
804
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
805
|
-
},
|
|
806
|
-
])
|
|
807
|
-
);
|
|
808
|
-
|
|
809
|
-
const tools = [
|
|
810
|
-
{
|
|
811
|
-
type: 'function',
|
|
812
|
-
function: {
|
|
813
|
-
name: 'bash',
|
|
814
|
-
description: 'Execute shell command',
|
|
815
|
-
parameters: {
|
|
816
|
-
type: 'object',
|
|
817
|
-
properties: {
|
|
818
|
-
command: { type: 'string' },
|
|
819
|
-
},
|
|
820
|
-
required: ['command'],
|
|
821
|
-
},
|
|
822
|
-
},
|
|
823
|
-
},
|
|
824
|
-
];
|
|
825
|
-
|
|
826
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
827
|
-
maxRetryCount: 3,
|
|
828
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
829
|
-
});
|
|
830
|
-
await collectEvents(
|
|
831
|
-
agent.runStream(
|
|
832
|
-
{
|
|
833
|
-
...createInput(),
|
|
834
|
-
tools,
|
|
835
|
-
},
|
|
836
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
837
|
-
)
|
|
838
|
-
);
|
|
839
|
-
|
|
840
|
-
const generateStreamCalls = (
|
|
841
|
-
provider.generateStream as unknown as { mock: { calls: unknown[][] } }
|
|
842
|
-
).mock.calls;
|
|
843
|
-
expect(generateStreamCalls).toHaveLength(1);
|
|
844
|
-
const callConfig = generateStreamCalls[0]?.[1] as { tools?: unknown[] };
|
|
845
|
-
expect(callConfig.tools).toEqual(tools);
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
it('uses toolManager schemas when input.tools is omitted', async () => {
|
|
849
|
-
const provider = createProvider();
|
|
850
|
-
const manager = new DefaultToolManager();
|
|
851
|
-
manager.registerTool(new BashTool());
|
|
852
|
-
provider.generateStream = vi.fn().mockReturnValue(
|
|
853
|
-
toStream([
|
|
854
|
-
{
|
|
855
|
-
index: 0,
|
|
856
|
-
choices: [{ index: 0, delta: { content: 'ok' } }],
|
|
857
|
-
},
|
|
858
|
-
{
|
|
859
|
-
index: 0,
|
|
860
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
861
|
-
},
|
|
862
|
-
])
|
|
863
|
-
);
|
|
864
|
-
|
|
865
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
866
|
-
maxRetryCount: 3,
|
|
867
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
868
|
-
});
|
|
869
|
-
await collectEvents(
|
|
870
|
-
agent.runStream(
|
|
871
|
-
{
|
|
872
|
-
...createInput(),
|
|
873
|
-
},
|
|
874
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
875
|
-
)
|
|
876
|
-
);
|
|
877
|
-
|
|
878
|
-
const generateStreamCalls = (
|
|
879
|
-
provider.generateStream as unknown as { mock: { calls: unknown[][] } }
|
|
880
|
-
).mock.calls;
|
|
881
|
-
expect(generateStreamCalls).toHaveLength(1);
|
|
882
|
-
const callConfig = generateStreamCalls[0]?.[1] as {
|
|
883
|
-
tools?: Array<{ function?: { name?: string } }>;
|
|
884
|
-
};
|
|
885
|
-
expect(callConfig.tools?.some((tool) => tool.function?.name === 'bash')).toBe(true);
|
|
886
|
-
});
|
|
887
|
-
|
|
888
|
-
it('injects systemPrompt as system message when input has no system role message', async () => {
|
|
889
|
-
const provider = createProvider();
|
|
890
|
-
const manager = createToolManager();
|
|
891
|
-
provider.generateStream = vi.fn().mockReturnValue(
|
|
892
|
-
toStream([
|
|
893
|
-
{
|
|
894
|
-
index: 0,
|
|
895
|
-
choices: [{ index: 0, delta: { content: 'ok' } }],
|
|
896
|
-
},
|
|
897
|
-
{
|
|
898
|
-
index: 0,
|
|
899
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
900
|
-
},
|
|
901
|
-
])
|
|
902
|
-
);
|
|
903
|
-
|
|
904
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
905
|
-
maxRetryCount: 3,
|
|
906
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
907
|
-
});
|
|
908
|
-
await collectEvents(
|
|
909
|
-
agent.runStream(
|
|
910
|
-
{
|
|
911
|
-
...createInput(),
|
|
912
|
-
systemPrompt: 'You are a strict code assistant',
|
|
913
|
-
},
|
|
914
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
915
|
-
)
|
|
916
|
-
);
|
|
917
|
-
|
|
918
|
-
const generateStreamCalls = (
|
|
919
|
-
provider.generateStream as unknown as { mock: { calls: unknown[][] } }
|
|
920
|
-
).mock.calls;
|
|
921
|
-
const llmMessages = generateStreamCalls[0]?.[0] as Array<{ role: string; content: unknown }>;
|
|
922
|
-
expect(llmMessages[0]).toMatchObject({
|
|
923
|
-
role: 'system',
|
|
924
|
-
content: 'You are a strict code assistant',
|
|
925
|
-
});
|
|
926
|
-
});
|
|
927
|
-
|
|
928
|
-
it('emits max_steps done event when loop exits by step budget', async () => {
|
|
929
|
-
const provider = createProvider();
|
|
930
|
-
const manager = createToolManager();
|
|
931
|
-
|
|
932
|
-
provider.generateStream = vi.fn().mockReturnValue(
|
|
933
|
-
toStream([
|
|
934
|
-
{
|
|
935
|
-
index: 0,
|
|
936
|
-
choices: [
|
|
937
|
-
{
|
|
938
|
-
index: 0,
|
|
939
|
-
delta: {
|
|
940
|
-
tool_calls: [
|
|
941
|
-
{
|
|
942
|
-
id: 'tool_max_steps_1',
|
|
943
|
-
type: 'function',
|
|
944
|
-
index: 0,
|
|
945
|
-
function: { name: 'bash', arguments: '{"command":"echo hi"}' },
|
|
946
|
-
},
|
|
947
|
-
],
|
|
948
|
-
finish_reason: 'tool_calls',
|
|
949
|
-
} as unknown as ChunkDelta,
|
|
950
|
-
},
|
|
951
|
-
],
|
|
952
|
-
},
|
|
953
|
-
])
|
|
954
|
-
);
|
|
955
|
-
manager.execute = vi.fn().mockResolvedValue({ success: true, output: 'ok' });
|
|
956
|
-
|
|
957
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
958
|
-
maxRetryCount: 3,
|
|
959
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
960
|
-
});
|
|
961
|
-
const events = await collectEvents(
|
|
962
|
-
agent.runStream(
|
|
963
|
-
{
|
|
964
|
-
...createInput(),
|
|
965
|
-
maxSteps: 1,
|
|
966
|
-
},
|
|
967
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
968
|
-
)
|
|
969
|
-
);
|
|
970
|
-
|
|
971
|
-
const doneEvent = events.find((event) => event.type === 'done');
|
|
972
|
-
expect(doneEvent).toMatchObject({
|
|
973
|
-
type: 'done',
|
|
974
|
-
data: {
|
|
975
|
-
finishReason: 'max_steps',
|
|
976
|
-
steps: 1,
|
|
977
|
-
},
|
|
978
|
-
});
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
it('emits executionId on all progress events including per-tool progress', async () => {
|
|
982
|
-
const provider = createProvider();
|
|
983
|
-
const manager = createToolManager();
|
|
984
|
-
|
|
985
|
-
provider.generateStream = vi
|
|
986
|
-
.fn()
|
|
987
|
-
.mockReturnValueOnce(
|
|
988
|
-
toStream([
|
|
989
|
-
{
|
|
990
|
-
index: 0,
|
|
991
|
-
choices: [
|
|
992
|
-
{
|
|
993
|
-
index: 0,
|
|
994
|
-
delta: {
|
|
995
|
-
tool_calls: [
|
|
996
|
-
{
|
|
997
|
-
id: 'tool_progress_1',
|
|
998
|
-
type: 'function',
|
|
999
|
-
index: 0,
|
|
1000
|
-
function: { name: 'bash', arguments: '{"command":"echo 1"}' },
|
|
1001
|
-
},
|
|
1002
|
-
{
|
|
1003
|
-
id: 'tool_progress_2',
|
|
1004
|
-
type: 'function',
|
|
1005
|
-
index: 1,
|
|
1006
|
-
function: { name: 'bash', arguments: '{"command":"echo 2"}' },
|
|
1007
|
-
},
|
|
1008
|
-
],
|
|
1009
|
-
finish_reason: 'tool_calls',
|
|
1010
|
-
} as unknown as ChunkDelta,
|
|
1011
|
-
},
|
|
1012
|
-
],
|
|
1013
|
-
},
|
|
1014
|
-
])
|
|
1015
|
-
)
|
|
1016
|
-
.mockReturnValueOnce(
|
|
1017
|
-
toStream([
|
|
1018
|
-
{
|
|
1019
|
-
index: 0,
|
|
1020
|
-
choices: [{ index: 0, delta: { content: 'done' } }],
|
|
1021
|
-
},
|
|
1022
|
-
{
|
|
1023
|
-
index: 0,
|
|
1024
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
1025
|
-
},
|
|
1026
|
-
])
|
|
1027
|
-
);
|
|
1028
|
-
manager.execute = vi.fn().mockResolvedValue({ success: true, output: 'ok' });
|
|
1029
|
-
|
|
1030
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
1031
|
-
const events = await collectEvents(
|
|
1032
|
-
agent.runStream(
|
|
1033
|
-
{
|
|
1034
|
-
...createInput(),
|
|
1035
|
-
executionId: 'exec_progress_1',
|
|
1036
|
-
maxSteps: 3,
|
|
1037
|
-
},
|
|
1038
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
1039
|
-
)
|
|
1040
|
-
);
|
|
1041
|
-
const progressEvents = events.filter((event) => event.type === 'progress');
|
|
1042
|
-
expect(progressEvents.length).toBeGreaterThan(0);
|
|
1043
|
-
for (const progressEvent of progressEvents) {
|
|
1044
|
-
expect(progressEvent.data).toMatchObject({
|
|
1045
|
-
executionId: 'exec_progress_1',
|
|
1046
|
-
});
|
|
1047
|
-
}
|
|
1048
|
-
});
|
|
1049
|
-
|
|
1050
|
-
it('merges tool call fragments by index when follow-up chunk omits id/name', async () => {
|
|
1051
|
-
const provider = createProvider();
|
|
1052
|
-
const manager = createToolManager();
|
|
1053
|
-
|
|
1054
|
-
provider.generateStream = vi
|
|
1055
|
-
.fn()
|
|
1056
|
-
.mockReturnValueOnce(
|
|
1057
|
-
toStream([
|
|
1058
|
-
{
|
|
1059
|
-
index: 0,
|
|
1060
|
-
choices: [
|
|
1061
|
-
{
|
|
1062
|
-
index: 0,
|
|
1063
|
-
delta: {
|
|
1064
|
-
tool_calls: [
|
|
1065
|
-
{
|
|
1066
|
-
id: 'call_fragment_1',
|
|
1067
|
-
type: 'function',
|
|
1068
|
-
index: 0,
|
|
1069
|
-
function: { name: 'bash', arguments: '{' },
|
|
1070
|
-
},
|
|
1071
|
-
],
|
|
1072
|
-
},
|
|
1073
|
-
},
|
|
1074
|
-
],
|
|
1075
|
-
},
|
|
1076
|
-
{
|
|
1077
|
-
index: 0,
|
|
1078
|
-
choices: [
|
|
1079
|
-
{
|
|
1080
|
-
index: 0,
|
|
1081
|
-
delta: {
|
|
1082
|
-
tool_calls: [
|
|
1083
|
-
{
|
|
1084
|
-
index: 0,
|
|
1085
|
-
function: { arguments: '"command":"ls -la"}' },
|
|
1086
|
-
},
|
|
1087
|
-
],
|
|
1088
|
-
finish_reason: 'tool_calls',
|
|
1089
|
-
} as unknown as ChunkDelta,
|
|
1090
|
-
},
|
|
1091
|
-
],
|
|
1092
|
-
},
|
|
1093
|
-
])
|
|
1094
|
-
)
|
|
1095
|
-
.mockReturnValueOnce(
|
|
1096
|
-
toStream([
|
|
1097
|
-
{
|
|
1098
|
-
index: 0,
|
|
1099
|
-
choices: [{ index: 0, delta: { content: 'done' } }],
|
|
1100
|
-
},
|
|
1101
|
-
{
|
|
1102
|
-
index: 0,
|
|
1103
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
1104
|
-
},
|
|
1105
|
-
])
|
|
1106
|
-
);
|
|
1107
|
-
manager.execute = vi.fn().mockResolvedValue({ success: true, output: 'ok' });
|
|
1108
|
-
|
|
1109
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
1110
|
-
const events = await collectEvents(
|
|
1111
|
-
agent.runStream(createInput(), { onMessage: vi.fn(), onCheckpoint: vi.fn() })
|
|
1112
|
-
);
|
|
1113
|
-
|
|
1114
|
-
expect(manager.execute).toHaveBeenCalledTimes(1);
|
|
1115
|
-
expect(
|
|
1116
|
-
(manager.execute as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]
|
|
1117
|
-
).toMatchObject({
|
|
1118
|
-
id: 'call_fragment_1',
|
|
1119
|
-
function: {
|
|
1120
|
-
name: 'bash',
|
|
1121
|
-
arguments: '{"command":"ls -la"}',
|
|
1122
|
-
},
|
|
1123
|
-
});
|
|
1124
|
-
expect(events.filter((event) => event.type === 'tool_result')).toHaveLength(1);
|
|
1125
|
-
});
|
|
1126
|
-
|
|
1127
|
-
it('returns invalid tool arguments back to llm without replaying broken arguments upstream', async () => {
|
|
1128
|
-
const provider = createProvider();
|
|
1129
|
-
const manager = createToolManager();
|
|
1130
|
-
const toolCallId = 'call_invalid_args_1';
|
|
1131
|
-
const invalidToolOutput =
|
|
1132
|
-
'Invalid arguments format for tool glob: JSON Parse error: Unexpected EOF';
|
|
1133
|
-
|
|
1134
|
-
provider.generateStream = vi
|
|
1135
|
-
.fn()
|
|
1136
|
-
.mockReturnValueOnce(
|
|
1137
|
-
toStream([
|
|
1138
|
-
{
|
|
1139
|
-
index: 0,
|
|
1140
|
-
choices: [
|
|
1141
|
-
{
|
|
1142
|
-
index: 0,
|
|
1143
|
-
delta: {
|
|
1144
|
-
tool_calls: [
|
|
1145
|
-
{
|
|
1146
|
-
id: toolCallId,
|
|
1147
|
-
type: 'function',
|
|
1148
|
-
index: 0,
|
|
1149
|
-
function: { name: 'glob', arguments: '' },
|
|
1150
|
-
},
|
|
1151
|
-
],
|
|
1152
|
-
finish_reason: 'tool_calls',
|
|
1153
|
-
} as unknown as ChunkDelta,
|
|
1154
|
-
},
|
|
1155
|
-
],
|
|
1156
|
-
},
|
|
1157
|
-
])
|
|
1158
|
-
)
|
|
1159
|
-
.mockImplementationOnce(
|
|
1160
|
-
(
|
|
1161
|
-
messages: Array<{
|
|
1162
|
-
role: string;
|
|
1163
|
-
content?: unknown;
|
|
1164
|
-
tool_call_id?: string;
|
|
1165
|
-
tool_calls?: ToolCall[];
|
|
1166
|
-
}>
|
|
1167
|
-
) => {
|
|
1168
|
-
const assistantToolCallMessage = messages.find(
|
|
1169
|
-
(message) => message.role === 'assistant' && Array.isArray(message.tool_calls)
|
|
1170
|
-
);
|
|
1171
|
-
const invalidToolCall = assistantToolCallMessage?.tool_calls?.find(
|
|
1172
|
-
(toolCall) => toolCall.id === toolCallId
|
|
1173
|
-
);
|
|
1174
|
-
const toolResultMessage = messages.find(
|
|
1175
|
-
(message) => message.role === 'tool' && message.tool_call_id === toolCallId
|
|
1176
|
-
);
|
|
1177
|
-
|
|
1178
|
-
expect(toolResultMessage).toMatchObject({
|
|
1179
|
-
content: invalidToolOutput,
|
|
1180
|
-
tool_call_id: toolCallId,
|
|
1181
|
-
});
|
|
1182
|
-
|
|
1183
|
-
if (invalidToolCall?.function.arguments === '') {
|
|
1184
|
-
throw new LLMBadRequestError(
|
|
1185
|
-
`400 Bad Request - invalid params, invalid function arguments json string, tool_call_id: ${toolCallId} (2013)`
|
|
1186
|
-
);
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
expect(invalidToolCall).toMatchObject({
|
|
1190
|
-
id: toolCallId,
|
|
1191
|
-
function: {
|
|
1192
|
-
name: 'glob',
|
|
1193
|
-
arguments: '{}',
|
|
1194
|
-
},
|
|
1195
|
-
});
|
|
1196
|
-
|
|
1197
|
-
return toStream([
|
|
1198
|
-
{
|
|
1199
|
-
index: 0,
|
|
1200
|
-
choices: [{ index: 0, delta: { content: 'retry with valid args' } }],
|
|
1201
|
-
},
|
|
1202
|
-
{
|
|
1203
|
-
index: 0,
|
|
1204
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
1205
|
-
},
|
|
1206
|
-
]);
|
|
1207
|
-
}
|
|
1208
|
-
);
|
|
1209
|
-
manager.execute = vi.fn().mockResolvedValue({
|
|
1210
|
-
success: false,
|
|
1211
|
-
output: invalidToolOutput,
|
|
1212
|
-
});
|
|
1213
|
-
|
|
1214
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
1215
|
-
maxRetryCount: 3,
|
|
1216
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
1217
|
-
});
|
|
1218
|
-
|
|
1219
|
-
const events = await collectEvents(
|
|
1220
|
-
agent.runStream(createInput(), { onMessage: vi.fn(), onCheckpoint: vi.fn() })
|
|
1221
|
-
);
|
|
1222
|
-
|
|
1223
|
-
expect(provider.generateStream).toHaveBeenCalledTimes(2);
|
|
1224
|
-
expect(events.some((event) => event.type === 'error')).toBe(false);
|
|
1225
|
-
expect(events.filter((event) => event.type === 'tool_result')).toHaveLength(1);
|
|
1226
|
-
expect(events.at(-1)).toMatchObject({
|
|
1227
|
-
type: 'done',
|
|
1228
|
-
data: {
|
|
1229
|
-
finishReason: 'stop',
|
|
1230
|
-
steps: 2,
|
|
1231
|
-
},
|
|
1232
|
-
});
|
|
1233
|
-
});
|
|
1234
|
-
|
|
1235
|
-
it('enforces llm timeout budget and emits timeout error event', async () => {
|
|
1236
|
-
vi.useFakeTimers();
|
|
1237
|
-
const provider = createProvider();
|
|
1238
|
-
const manager = createToolManager();
|
|
1239
|
-
|
|
1240
|
-
provider.generateStream = vi
|
|
1241
|
-
.fn()
|
|
1242
|
-
.mockImplementation((_messages: unknown, _options?: { abortSignal?: AbortSignal }) =>
|
|
1243
|
-
(async function* () {
|
|
1244
|
-
await new Promise<void>((resolve) => setTimeout(resolve, 30));
|
|
1245
|
-
yield {
|
|
1246
|
-
index: 0,
|
|
1247
|
-
choices: [{ index: 0, delta: { content: 'late chunk' } }],
|
|
1248
|
-
} as Chunk;
|
|
1249
|
-
yield {
|
|
1250
|
-
index: 0,
|
|
1251
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
1252
|
-
} as Chunk;
|
|
1253
|
-
})()
|
|
1254
|
-
);
|
|
1255
|
-
|
|
1256
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
1257
|
-
maxRetryCount: 1,
|
|
1258
|
-
timeoutBudgetMs: 20,
|
|
1259
|
-
llmTimeoutRatio: 1,
|
|
1260
|
-
});
|
|
1261
|
-
const eventsPromise = collectEvents(
|
|
1262
|
-
agent.runStream(
|
|
1263
|
-
{
|
|
1264
|
-
...createInput(),
|
|
1265
|
-
maxSteps: 2,
|
|
1266
|
-
},
|
|
1267
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
1268
|
-
)
|
|
1269
|
-
);
|
|
1270
|
-
|
|
1271
|
-
await vi.advanceTimersByTimeAsync(40);
|
|
1272
|
-
const events = await eventsPromise;
|
|
1273
|
-
const errorEvent = events.find((event) => event.type === 'error');
|
|
1274
|
-
expect(errorEvent).toMatchObject({
|
|
1275
|
-
type: 'error',
|
|
1276
|
-
data: {
|
|
1277
|
-
errorCode: 'AGENT_TIMEOUT_BUDGET_EXCEEDED',
|
|
1278
|
-
category: 'timeout',
|
|
1279
|
-
},
|
|
1280
|
-
});
|
|
1281
|
-
|
|
1282
|
-
const generateStreamCalls = (
|
|
1283
|
-
provider.generateStream as unknown as { mock: { calls: unknown[][] } }
|
|
1284
|
-
).mock.calls;
|
|
1285
|
-
const callConfig = generateStreamCalls[0]?.[1] as { abortSignal?: AbortSignal };
|
|
1286
|
-
expect(callConfig.abortSignal).toBeDefined();
|
|
1287
|
-
});
|
|
1288
|
-
|
|
1289
|
-
it('applies tool timeout budget through toolAbortSignal', async () => {
|
|
1290
|
-
vi.useFakeTimers();
|
|
1291
|
-
const provider = createProvider();
|
|
1292
|
-
const manager = createToolManager();
|
|
1293
|
-
|
|
1294
|
-
provider.generateStream = vi
|
|
1295
|
-
.fn()
|
|
1296
|
-
.mockReturnValueOnce(
|
|
1297
|
-
toStream([
|
|
1298
|
-
{
|
|
1299
|
-
index: 0,
|
|
1300
|
-
choices: [
|
|
1301
|
-
{
|
|
1302
|
-
index: 0,
|
|
1303
|
-
delta: {
|
|
1304
|
-
tool_calls: [
|
|
1305
|
-
{
|
|
1306
|
-
id: 'tool_budget_1',
|
|
1307
|
-
type: 'function',
|
|
1308
|
-
index: 0,
|
|
1309
|
-
function: { name: 'bash', arguments: '{"command":"sleep 1"}' },
|
|
1310
|
-
},
|
|
1311
|
-
],
|
|
1312
|
-
finish_reason: 'tool_calls',
|
|
1313
|
-
} as unknown as ChunkDelta,
|
|
1314
|
-
},
|
|
1315
|
-
],
|
|
1316
|
-
},
|
|
1317
|
-
])
|
|
1318
|
-
)
|
|
1319
|
-
.mockReturnValueOnce(
|
|
1320
|
-
toStream([
|
|
1321
|
-
{
|
|
1322
|
-
index: 0,
|
|
1323
|
-
choices: [{ index: 0, delta: { content: 'done' } }],
|
|
1324
|
-
},
|
|
1325
|
-
{
|
|
1326
|
-
index: 0,
|
|
1327
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
1328
|
-
},
|
|
1329
|
-
])
|
|
1330
|
-
);
|
|
1331
|
-
|
|
1332
|
-
manager.execute = vi.fn().mockImplementation(async (_toolCall, options) => {
|
|
1333
|
-
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
|
1334
|
-
if (options.toolAbortSignal?.aborted) {
|
|
1335
|
-
return {
|
|
1336
|
-
success: false,
|
|
1337
|
-
error: { message: 'tool stage budget exceeded' },
|
|
1338
|
-
};
|
|
1339
|
-
}
|
|
1340
|
-
return { success: true, output: 'ok' };
|
|
1341
|
-
});
|
|
1342
|
-
|
|
1343
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
1344
|
-
maxRetryCount: 1,
|
|
1345
|
-
timeoutBudgetMs: 30,
|
|
1346
|
-
llmTimeoutRatio: 0.9,
|
|
1347
|
-
});
|
|
1348
|
-
|
|
1349
|
-
const eventsPromise = collectEvents(
|
|
1350
|
-
agent.runStream(
|
|
1351
|
-
{
|
|
1352
|
-
...createInput(),
|
|
1353
|
-
maxSteps: 4,
|
|
1354
|
-
},
|
|
1355
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
1356
|
-
)
|
|
1357
|
-
);
|
|
1358
|
-
await vi.advanceTimersByTimeAsync(50);
|
|
1359
|
-
const events = await eventsPromise;
|
|
1360
|
-
|
|
1361
|
-
const toolResult = events.find((event) => event.type === 'tool_result');
|
|
1362
|
-
expect(toolResult).toMatchObject({
|
|
1363
|
-
type: 'tool_result',
|
|
1364
|
-
data: {
|
|
1365
|
-
tool_call_id: 'tool_budget_1',
|
|
1366
|
-
content: 'tool stage budget exceeded',
|
|
1367
|
-
},
|
|
1368
|
-
});
|
|
1369
|
-
expect(events.at(-1)).toMatchObject({
|
|
1370
|
-
type: 'done',
|
|
1371
|
-
data: { finishReason: 'stop' },
|
|
1372
|
-
});
|
|
1373
|
-
});
|
|
1374
|
-
|
|
1375
|
-
it('emits structured metrics, trace events, and log contexts for successful run', async () => {
|
|
1376
|
-
const provider = createProvider();
|
|
1377
|
-
const manager = createToolManager();
|
|
1378
|
-
provider.generateStream = vi.fn().mockReturnValue(
|
|
1379
|
-
toStream([
|
|
1380
|
-
{
|
|
1381
|
-
index: 0,
|
|
1382
|
-
choices: [{ index: 0, delta: { content: 'telemetry-ok' } }],
|
|
1383
|
-
},
|
|
1384
|
-
{
|
|
1385
|
-
index: 0,
|
|
1386
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
1387
|
-
},
|
|
1388
|
-
])
|
|
1389
|
-
);
|
|
1390
|
-
|
|
1391
|
-
const logger = {
|
|
1392
|
-
debug: vi.fn(),
|
|
1393
|
-
info: vi.fn(),
|
|
1394
|
-
warn: vi.fn(),
|
|
1395
|
-
error: vi.fn(),
|
|
1396
|
-
};
|
|
1397
|
-
const onMetric = vi.fn(async (_metric: AgentMetric) => undefined);
|
|
1398
|
-
const onTrace = vi.fn(async (_event: AgentTraceEvent) => undefined);
|
|
1399
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3, logger });
|
|
1400
|
-
|
|
1401
|
-
await collectEvents(
|
|
1402
|
-
agent.runStream(createInput(), {
|
|
1403
|
-
onMessage: vi.fn(),
|
|
1404
|
-
onCheckpoint: vi.fn(),
|
|
1405
|
-
onMetric,
|
|
1406
|
-
onTrace,
|
|
1407
|
-
})
|
|
1408
|
-
);
|
|
1409
|
-
|
|
1410
|
-
expect(onMetric).toHaveBeenCalledWith(
|
|
1411
|
-
expect.objectContaining({
|
|
1412
|
-
name: 'agent.llm.duration_ms',
|
|
1413
|
-
unit: 'ms',
|
|
1414
|
-
tags: expect.objectContaining({ executionId: 'exec_1', stepIndex: 1 }),
|
|
1415
|
-
})
|
|
1416
|
-
);
|
|
1417
|
-
expect(onMetric).toHaveBeenCalledWith(
|
|
1418
|
-
expect.objectContaining({
|
|
1419
|
-
name: 'agent.run.duration_ms',
|
|
1420
|
-
unit: 'ms',
|
|
1421
|
-
tags: expect.objectContaining({ executionId: 'exec_1', outcome: 'done' }),
|
|
1422
|
-
})
|
|
1423
|
-
);
|
|
1424
|
-
|
|
1425
|
-
const traceEvents = onTrace.mock.calls.map((call) => call[0] as AgentTraceEvent);
|
|
1426
|
-
expect(
|
|
1427
|
-
traceEvents.some(
|
|
1428
|
-
(event) =>
|
|
1429
|
-
event.name === 'agent.run' && event.phase === 'start' && event.traceId === 'exec_1'
|
|
1430
|
-
)
|
|
1431
|
-
).toBe(true);
|
|
1432
|
-
expect(traceEvents.some((event) => event.name === 'agent.run' && event.phase === 'end')).toBe(
|
|
1433
|
-
true
|
|
1434
|
-
);
|
|
1435
|
-
|
|
1436
|
-
const infoCalls = logger.info.mock.calls as Array<[string, Record<string, unknown>]>;
|
|
1437
|
-
expect(
|
|
1438
|
-
infoCalls.some(
|
|
1439
|
-
([message, context]) => message === '[Agent] run.start' && context.executionId === 'exec_1'
|
|
1440
|
-
)
|
|
1441
|
-
).toBe(true);
|
|
1442
|
-
expect(
|
|
1443
|
-
infoCalls.some(
|
|
1444
|
-
([message, context]) =>
|
|
1445
|
-
message === '[Agent] run.finish' &&
|
|
1446
|
-
context.executionId === 'exec_1' &&
|
|
1447
|
-
typeof context.latencyMs === 'number'
|
|
1448
|
-
)
|
|
1449
|
-
).toBe(true);
|
|
1450
|
-
});
|
|
1451
|
-
|
|
1452
|
-
it('marks llm step metric success=false when llm stream throws unknown error', async () => {
|
|
1453
|
-
const provider = createProvider();
|
|
1454
|
-
const manager = createToolManager();
|
|
1455
|
-
provider.generateStream = vi.fn().mockImplementation(() =>
|
|
1456
|
-
(async function* () {
|
|
1457
|
-
yield* [] as Chunk[];
|
|
1458
|
-
throw new Error('network interrupted');
|
|
1459
|
-
})()
|
|
1460
|
-
);
|
|
1461
|
-
|
|
1462
|
-
const onMetric = vi.fn(async (_metric: AgentMetric) => undefined);
|
|
1463
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
1464
|
-
const events = await collectEvents(
|
|
1465
|
-
agent.runStream(createInput(), {
|
|
1466
|
-
onMessage: vi.fn(),
|
|
1467
|
-
onCheckpoint: vi.fn(),
|
|
1468
|
-
onError: async () => ({ retry: false }),
|
|
1469
|
-
onMetric,
|
|
1470
|
-
})
|
|
1471
|
-
);
|
|
1472
|
-
|
|
1473
|
-
expect(events.at(-1)).toMatchObject({
|
|
1474
|
-
type: 'error',
|
|
1475
|
-
data: { message: 'network interrupted' },
|
|
1476
|
-
});
|
|
1477
|
-
|
|
1478
|
-
const llmMetric = onMetric.mock.calls
|
|
1479
|
-
.map((call) => call[0] as AgentMetric)
|
|
1480
|
-
.find((metric) => metric.name === 'agent.llm.duration_ms');
|
|
1481
|
-
expect(llmMetric).toBeDefined();
|
|
1482
|
-
expect(llmMetric?.tags?.success).toBe('false');
|
|
1483
|
-
});
|
|
1484
|
-
|
|
1485
|
-
it('marks tool stage metric success=false when tool execution throws unknown error', async () => {
|
|
1486
|
-
const provider = createProvider();
|
|
1487
|
-
const manager = createToolManager();
|
|
1488
|
-
provider.generateStream = vi.fn().mockReturnValue(
|
|
1489
|
-
toStream([
|
|
1490
|
-
{
|
|
1491
|
-
index: 0,
|
|
1492
|
-
choices: [
|
|
1493
|
-
{
|
|
1494
|
-
index: 0,
|
|
1495
|
-
delta: {
|
|
1496
|
-
tool_calls: [
|
|
1497
|
-
{
|
|
1498
|
-
id: 'tool_chaos_1',
|
|
1499
|
-
type: 'function',
|
|
1500
|
-
index: 0,
|
|
1501
|
-
function: { name: 'bash', arguments: '{"command":"echo chaos"}' },
|
|
1502
|
-
},
|
|
1503
|
-
],
|
|
1504
|
-
finish_reason: 'tool_calls',
|
|
1505
|
-
} as unknown as ChunkDelta,
|
|
1506
|
-
},
|
|
1507
|
-
],
|
|
1508
|
-
},
|
|
1509
|
-
])
|
|
1510
|
-
);
|
|
1511
|
-
manager.execute = vi.fn().mockRejectedValue(new Error('tool crashed'));
|
|
1512
|
-
|
|
1513
|
-
const onMetric = vi.fn(async (_metric: AgentMetric) => undefined);
|
|
1514
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
1515
|
-
const events = await collectEvents(
|
|
1516
|
-
agent.runStream(createInput(), {
|
|
1517
|
-
onMessage: vi.fn(),
|
|
1518
|
-
onCheckpoint: vi.fn(),
|
|
1519
|
-
onError: async () => ({ retry: false }),
|
|
1520
|
-
onMetric,
|
|
1521
|
-
})
|
|
1522
|
-
);
|
|
1523
|
-
|
|
1524
|
-
expect(events.at(-1)).toMatchObject({
|
|
1525
|
-
type: 'error',
|
|
1526
|
-
data: { message: 'tool crashed' },
|
|
1527
|
-
});
|
|
1528
|
-
|
|
1529
|
-
const toolStageMetric = onMetric.mock.calls
|
|
1530
|
-
.map((call) => call[0] as AgentMetric)
|
|
1531
|
-
.find((metric) => metric.name === 'agent.tool.stage.duration_ms');
|
|
1532
|
-
expect(toolStageMetric).toBeDefined();
|
|
1533
|
-
expect(toolStageMetric?.tags?.success).toBe('false');
|
|
1534
|
-
});
|
|
1535
|
-
|
|
1536
|
-
it('stops immediately when abortSignal is already aborted', async () => {
|
|
1537
|
-
const provider = createProvider();
|
|
1538
|
-
const manager = createToolManager();
|
|
1539
|
-
provider.generateStream = vi.fn();
|
|
1540
|
-
|
|
1541
|
-
const controller = new AbortController();
|
|
1542
|
-
controller.abort();
|
|
1543
|
-
|
|
1544
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
1545
|
-
const events = await collectEvents(
|
|
1546
|
-
agent.runStream(
|
|
1547
|
-
{
|
|
1548
|
-
...createInput(),
|
|
1549
|
-
abortSignal: controller.signal,
|
|
1550
|
-
},
|
|
1551
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
1552
|
-
)
|
|
1553
|
-
);
|
|
1554
|
-
|
|
1555
|
-
expect(events.map((event) => event.type)).toEqual(['error']);
|
|
1556
|
-
expect(events[0]).toMatchObject({
|
|
1557
|
-
type: 'error',
|
|
1558
|
-
data: {
|
|
1559
|
-
name: 'AgentAbortedError',
|
|
1560
|
-
code: 1002,
|
|
1561
|
-
errorCode: 'AGENT_ABORTED',
|
|
1562
|
-
category: 'abort',
|
|
1563
|
-
retryable: false,
|
|
1564
|
-
httpStatus: 499,
|
|
1565
|
-
message: 'Operation aborted',
|
|
1566
|
-
},
|
|
1567
|
-
});
|
|
1568
|
-
expect(provider.generateStream).not.toHaveBeenCalled();
|
|
1569
|
-
});
|
|
1570
|
-
|
|
1571
|
-
it('isolates message state across concurrent runs on the same instance', async () => {
|
|
1572
|
-
const provider = createProvider();
|
|
1573
|
-
const manager = createToolManager();
|
|
1574
|
-
provider.generateStream = vi.fn().mockImplementation((messages: Array<{ content: string }>) => {
|
|
1575
|
-
const marker = messages[0]?.content || 'unknown';
|
|
1576
|
-
return toStream([
|
|
1577
|
-
{
|
|
1578
|
-
index: 0,
|
|
1579
|
-
choices: [{ index: 0, delta: { content: `reply:${marker}` } }],
|
|
1580
|
-
},
|
|
1581
|
-
{
|
|
1582
|
-
index: 0,
|
|
1583
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
1584
|
-
},
|
|
1585
|
-
]);
|
|
1586
|
-
});
|
|
1587
|
-
|
|
1588
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
1589
|
-
const onMessageA = vi.fn();
|
|
1590
|
-
const onMessageB = vi.fn();
|
|
1591
|
-
|
|
1592
|
-
await Promise.all([
|
|
1593
|
-
collectEvents(
|
|
1594
|
-
agent.runStream(
|
|
1595
|
-
{
|
|
1596
|
-
...createInput(),
|
|
1597
|
-
executionId: 'exec_A',
|
|
1598
|
-
messages: [
|
|
1599
|
-
{
|
|
1600
|
-
messageId: 'a1',
|
|
1601
|
-
type: 'user',
|
|
1602
|
-
role: 'user',
|
|
1603
|
-
content: 'A',
|
|
1604
|
-
timestamp: 1,
|
|
1605
|
-
},
|
|
1606
|
-
],
|
|
1607
|
-
},
|
|
1608
|
-
{ onMessage: onMessageA, onCheckpoint: vi.fn() }
|
|
1609
|
-
)
|
|
1610
|
-
),
|
|
1611
|
-
collectEvents(
|
|
1612
|
-
agent.runStream(
|
|
1613
|
-
{
|
|
1614
|
-
...createInput(),
|
|
1615
|
-
executionId: 'exec_B',
|
|
1616
|
-
messages: [
|
|
1617
|
-
{
|
|
1618
|
-
messageId: 'b1',
|
|
1619
|
-
type: 'user',
|
|
1620
|
-
role: 'user',
|
|
1621
|
-
content: 'B',
|
|
1622
|
-
timestamp: 2,
|
|
1623
|
-
},
|
|
1624
|
-
],
|
|
1625
|
-
},
|
|
1626
|
-
{ onMessage: onMessageB, onCheckpoint: vi.fn() }
|
|
1627
|
-
)
|
|
1628
|
-
),
|
|
1629
|
-
]);
|
|
1630
|
-
|
|
1631
|
-
const calledUserContents = (
|
|
1632
|
-
provider.generateStream as unknown as { mock: { calls: unknown[][] } }
|
|
1633
|
-
).mock.calls
|
|
1634
|
-
.map((call) => (call[0] as Array<{ content: string }>)?.[0]?.content ?? '')
|
|
1635
|
-
.sort();
|
|
1636
|
-
expect(calledUserContents).toEqual(['A', 'B']);
|
|
1637
|
-
expect(onMessageA.mock.calls[0]?.[0]).toMatchObject({ content: 'reply:A' });
|
|
1638
|
-
expect(onMessageB.mock.calls[0]?.[0]).toMatchObject({ content: 'reply:B' });
|
|
1639
|
-
});
|
|
1640
|
-
|
|
1641
|
-
it('calls compact when needsCompaction is true and uses compacted messages for llm', async () => {
|
|
1642
|
-
const provider = createProvider();
|
|
1643
|
-
const manager = createToolManager();
|
|
1644
|
-
provider.generateStream = vi.fn().mockReturnValue(
|
|
1645
|
-
toStream([
|
|
1646
|
-
{
|
|
1647
|
-
index: 0,
|
|
1648
|
-
choices: [{ index: 0, delta: { content: 'ok' } }],
|
|
1649
|
-
},
|
|
1650
|
-
{
|
|
1651
|
-
index: 0,
|
|
1652
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
1653
|
-
},
|
|
1654
|
-
])
|
|
1655
|
-
);
|
|
1656
|
-
|
|
1657
|
-
const compactSpy = vi.spyOn(compactionModule, 'compact').mockResolvedValue({
|
|
1658
|
-
messages: [
|
|
1659
|
-
{
|
|
1660
|
-
messageId: 'cmp_1',
|
|
1661
|
-
type: 'user',
|
|
1662
|
-
role: 'user',
|
|
1663
|
-
content: 'compacted input',
|
|
1664
|
-
timestamp: 1,
|
|
1665
|
-
},
|
|
1666
|
-
],
|
|
1667
|
-
summaryMessage: null,
|
|
1668
|
-
removedMessageIds: ['u1'],
|
|
1669
|
-
});
|
|
1670
|
-
|
|
1671
|
-
const onCompaction = vi.fn();
|
|
1672
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
1673
|
-
enableCompaction: true,
|
|
1674
|
-
compactionTriggerRatio: 0,
|
|
1675
|
-
});
|
|
1676
|
-
const events = await collectEvents(
|
|
1677
|
-
agent.runStream(createInput(), { onMessage: vi.fn(), onCheckpoint: vi.fn(), onCompaction })
|
|
1678
|
-
);
|
|
1679
|
-
|
|
1680
|
-
expect(compactSpy).toHaveBeenCalledOnce();
|
|
1681
|
-
const firstCallArgs = (provider.generateStream as unknown as { mock: { calls: unknown[][] } })
|
|
1682
|
-
.mock.calls[0];
|
|
1683
|
-
const llmMessages = firstCallArgs?.[0] as Array<{ content: string }>;
|
|
1684
|
-
expect(llmMessages[0]?.content).toBe('compacted input');
|
|
1685
|
-
expect(events.some((event) => event.type === 'compaction')).toBe(true);
|
|
1686
|
-
expect(onCompaction).toHaveBeenCalledOnce();
|
|
1687
|
-
expect(onCompaction.mock.calls[0]?.[0] as CompactionInfo).toMatchObject({
|
|
1688
|
-
executionId: 'exec_1',
|
|
1689
|
-
stepIndex: 1,
|
|
1690
|
-
removedMessageIds: ['u1'],
|
|
1691
|
-
messageCountBefore: 1,
|
|
1692
|
-
messageCountAfter: 1,
|
|
1693
|
-
});
|
|
1694
|
-
compactSpy.mockRestore();
|
|
1695
|
-
});
|
|
1696
|
-
|
|
1697
|
-
it('continues execution when compaction throws error', async () => {
|
|
1698
|
-
const provider = createProvider();
|
|
1699
|
-
const manager = createToolManager();
|
|
1700
|
-
provider.generateStream = vi.fn().mockReturnValue(
|
|
1701
|
-
toStream([
|
|
1702
|
-
{
|
|
1703
|
-
index: 0,
|
|
1704
|
-
choices: [{ index: 0, delta: { content: 'still works' } }],
|
|
1705
|
-
},
|
|
1706
|
-
{
|
|
1707
|
-
index: 0,
|
|
1708
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
1709
|
-
},
|
|
1710
|
-
])
|
|
1711
|
-
);
|
|
1712
|
-
|
|
1713
|
-
const compactSpy = vi
|
|
1714
|
-
.spyOn(compactionModule, 'compact')
|
|
1715
|
-
.mockRejectedValue(new Error('compact failed'));
|
|
1716
|
-
const logger = { error: vi.fn() };
|
|
1717
|
-
|
|
1718
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
1719
|
-
enableCompaction: true,
|
|
1720
|
-
compactionTriggerRatio: 0,
|
|
1721
|
-
logger,
|
|
1722
|
-
});
|
|
1723
|
-
const events = await collectEvents(
|
|
1724
|
-
agent.runStream(createInput(), { onMessage: vi.fn(), onCheckpoint: vi.fn() })
|
|
1725
|
-
);
|
|
1726
|
-
|
|
1727
|
-
expect(compactSpy).toHaveBeenCalledOnce();
|
|
1728
|
-
expect(events.at(-1)).toMatchObject({
|
|
1729
|
-
type: 'done',
|
|
1730
|
-
data: { finishReason: 'stop', steps: 1 },
|
|
1731
|
-
});
|
|
1732
|
-
expect(logger.error).toHaveBeenCalled();
|
|
1733
|
-
compactSpy.mockRestore();
|
|
1734
|
-
});
|
|
1735
|
-
|
|
1736
|
-
it('processes tool calls, emits checkpoint, and continues to next step', async () => {
|
|
1737
|
-
const provider = createProvider();
|
|
1738
|
-
const manager = createToolManager();
|
|
1739
|
-
provider.generateStream = vi
|
|
1740
|
-
.fn()
|
|
1741
|
-
.mockReturnValueOnce(
|
|
1742
|
-
toStream([
|
|
1743
|
-
{
|
|
1744
|
-
index: 0,
|
|
1745
|
-
choices: [
|
|
1746
|
-
{
|
|
1747
|
-
index: 0,
|
|
1748
|
-
delta: {
|
|
1749
|
-
tool_calls: [
|
|
1750
|
-
{
|
|
1751
|
-
id: 'call_1',
|
|
1752
|
-
type: 'function',
|
|
1753
|
-
index: 0,
|
|
1754
|
-
function: { name: 'bash', arguments: '{"a":' },
|
|
1755
|
-
},
|
|
1756
|
-
],
|
|
1757
|
-
},
|
|
1758
|
-
},
|
|
1759
|
-
],
|
|
1760
|
-
},
|
|
1761
|
-
{
|
|
1762
|
-
index: 0,
|
|
1763
|
-
choices: [
|
|
1764
|
-
{
|
|
1765
|
-
index: 0,
|
|
1766
|
-
delta: {
|
|
1767
|
-
tool_calls: [
|
|
1768
|
-
{
|
|
1769
|
-
id: 'call_1',
|
|
1770
|
-
type: 'function',
|
|
1771
|
-
index: 0,
|
|
1772
|
-
function: { name: 'bash', arguments: '1}' },
|
|
1773
|
-
},
|
|
1774
|
-
],
|
|
1775
|
-
finish_reason: 'tool_calls',
|
|
1776
|
-
} as unknown as ChunkDelta,
|
|
1777
|
-
},
|
|
1778
|
-
],
|
|
1779
|
-
},
|
|
1780
|
-
])
|
|
1781
|
-
)
|
|
1782
|
-
.mockReturnValueOnce(
|
|
1783
|
-
toStream([
|
|
1784
|
-
{
|
|
1785
|
-
index: 0,
|
|
1786
|
-
choices: [{ index: 0, delta: { content: 'final' } }],
|
|
1787
|
-
},
|
|
1788
|
-
{
|
|
1789
|
-
index: 0,
|
|
1790
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
1791
|
-
},
|
|
1792
|
-
])
|
|
1793
|
-
);
|
|
1794
|
-
|
|
1795
|
-
manager.execute = vi.fn().mockImplementation(async (_toolCall, options) => {
|
|
1796
|
-
options.onChunk?.({ type: 'stdout', data: 'streamed' });
|
|
1797
|
-
const decision = await options.onConfirm?.({
|
|
1798
|
-
toolCallId: 'call_1',
|
|
1799
|
-
toolName: 'bash',
|
|
1800
|
-
arguments: '{"a":1}',
|
|
1801
|
-
});
|
|
1802
|
-
expect(decision).toEqual({ approved: true, message: 'ok' });
|
|
1803
|
-
return { success: true, output: 'tool-output' };
|
|
1804
|
-
});
|
|
1805
|
-
|
|
1806
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
1807
|
-
const onMessage = vi.fn();
|
|
1808
|
-
const onCheckpoint = vi.fn();
|
|
1809
|
-
const toolChunkSpy = vi.fn();
|
|
1810
|
-
agent.on('tool_chunk', toolChunkSpy);
|
|
1811
|
-
agent.on(
|
|
1812
|
-
'tool_confirm',
|
|
1813
|
-
(info: { resolve: (decision: { approved: boolean; message?: string }) => void }) => {
|
|
1814
|
-
info.resolve({ approved: true, message: 'ok' });
|
|
1815
|
-
}
|
|
1816
|
-
);
|
|
1817
|
-
|
|
1818
|
-
const events = await collectEvents(agent.runStream(createInput(), { onMessage, onCheckpoint }));
|
|
1819
|
-
|
|
1820
|
-
expect(events.some((event) => event.type === 'tool_call')).toBe(true);
|
|
1821
|
-
expect(events.some((event) => event.type === 'tool_result')).toBe(true);
|
|
1822
|
-
expect(events.some((event) => event.type === 'checkpoint')).toBe(true);
|
|
1823
|
-
expect(events.at(-1)).toMatchObject({
|
|
1824
|
-
type: 'done',
|
|
1825
|
-
data: { finishReason: 'stop', steps: 2 },
|
|
1826
|
-
});
|
|
1827
|
-
expect(onMessage).toHaveBeenCalledTimes(3);
|
|
1828
|
-
expect(onCheckpoint).toHaveBeenCalledTimes(1);
|
|
1829
|
-
expect(toolChunkSpy).toHaveBeenCalledTimes(1);
|
|
1830
|
-
expect(manager.execute).toHaveBeenCalledTimes(1);
|
|
1831
|
-
});
|
|
1832
|
-
|
|
1833
|
-
it('adds write buffer info to tool result when write_file arguments are truncated', async () => {
|
|
1834
|
-
const provider = createProvider();
|
|
1835
|
-
const manager = createToolManager();
|
|
1836
|
-
provider.generateStream = vi.fn().mockReturnValue(
|
|
1837
|
-
toStream([
|
|
1838
|
-
{
|
|
1839
|
-
index: 0,
|
|
1840
|
-
choices: [
|
|
1841
|
-
{
|
|
1842
|
-
index: 0,
|
|
1843
|
-
delta: {
|
|
1844
|
-
tool_calls: [
|
|
1845
|
-
{
|
|
1846
|
-
id: 'wf_call_1',
|
|
1847
|
-
type: 'function',
|
|
1848
|
-
index: 0,
|
|
1849
|
-
function: {
|
|
1850
|
-
name: 'write_file',
|
|
1851
|
-
arguments: '{"path":"a.txt","content":"partial',
|
|
1852
|
-
},
|
|
1853
|
-
},
|
|
1854
|
-
],
|
|
1855
|
-
finish_reason: 'tool_calls',
|
|
1856
|
-
} as unknown as ChunkDelta,
|
|
1857
|
-
},
|
|
1858
|
-
],
|
|
1859
|
-
},
|
|
1860
|
-
])
|
|
1861
|
-
);
|
|
1862
|
-
|
|
1863
|
-
manager.execute = vi.fn().mockResolvedValue({
|
|
1864
|
-
success: false,
|
|
1865
|
-
error: {
|
|
1866
|
-
name: 'InvalidArgumentsError',
|
|
1867
|
-
message: 'Invalid arguments format for tool write_file',
|
|
1868
|
-
},
|
|
1869
|
-
});
|
|
1870
|
-
|
|
1871
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
1872
|
-
const events = await collectEvents(
|
|
1873
|
-
agent.runStream(
|
|
1874
|
-
{
|
|
1875
|
-
...createInput(),
|
|
1876
|
-
maxSteps: 1,
|
|
1877
|
-
},
|
|
1878
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
1879
|
-
)
|
|
1880
|
-
);
|
|
1881
|
-
|
|
1882
|
-
const toolResultEvent = events.find((event) => event.type === 'tool_result');
|
|
1883
|
-
expect(toolResultEvent).toBeDefined();
|
|
1884
|
-
const toolResultContent = (toolResultEvent?.data as Message).content as string;
|
|
1885
|
-
const payload = JSON.parse(toolResultContent) as {
|
|
1886
|
-
ok: boolean;
|
|
1887
|
-
code: string;
|
|
1888
|
-
message: string;
|
|
1889
|
-
buffer?: { bufferId: string };
|
|
1890
|
-
nextAction: string;
|
|
1891
|
-
};
|
|
1892
|
-
expect(payload.ok).toBe(false);
|
|
1893
|
-
expect(payload.code).toBe('WRITE_FILE_PARTIAL_BUFFERED');
|
|
1894
|
-
expect(payload.message).toContain('Invalid arguments format for tool write_file');
|
|
1895
|
-
expect(payload.buffer?.bufferId).toBe('wf_call_1');
|
|
1896
|
-
expect(payload.nextAction).toBe('finalize');
|
|
1897
|
-
|
|
1898
|
-
const writeBufferCacheDir = path.resolve(process.cwd(), '.renx', 'write-file');
|
|
1899
|
-
const cacheEntries = await fs.readdir(writeBufferCacheDir).catch(() => []);
|
|
1900
|
-
await Promise.all(
|
|
1901
|
-
cacheEntries
|
|
1902
|
-
.filter((entry) => entry.includes('_wf_call_1_'))
|
|
1903
|
-
.map((entry) => fs.rm(path.join(writeBufferCacheDir, entry), { force: true }))
|
|
1904
|
-
);
|
|
1905
|
-
});
|
|
1906
|
-
|
|
1907
|
-
it('supports streamed write_file direct/finalize across multiple llm turns', async () => {
|
|
1908
|
-
const provider = createProvider();
|
|
1909
|
-
|
|
1910
|
-
const allowedDir = await fs.mkdtemp(path.join(os.tmpdir(), 'renx-index-write-e2e-'));
|
|
1911
|
-
const bufferDir = await fs.mkdtemp(path.join(os.tmpdir(), 'renx-index-write-buffer-'));
|
|
1912
|
-
const targetPath = path.join(allowedDir, 'streamed-write.txt');
|
|
1913
|
-
const fullContent = 'abcdefghijklmnop';
|
|
1914
|
-
|
|
1915
|
-
try {
|
|
1916
|
-
const manager = new DefaultToolManager();
|
|
1917
|
-
manager.registerTool(
|
|
1918
|
-
new WriteFileTool({
|
|
1919
|
-
allowedDirectories: [allowedDir],
|
|
1920
|
-
bufferBaseDir: bufferDir,
|
|
1921
|
-
maxChunkBytes: 8,
|
|
1922
|
-
})
|
|
1923
|
-
);
|
|
1924
|
-
|
|
1925
|
-
const buildToolCallStream = (toolCallId: string, args: Record<string, unknown>) => {
|
|
1926
|
-
const raw = JSON.stringify(args);
|
|
1927
|
-
const cut = Math.max(1, Math.floor(raw.length / 2));
|
|
1928
|
-
return toStream([
|
|
1929
|
-
{
|
|
1930
|
-
index: 0,
|
|
1931
|
-
choices: [
|
|
1932
|
-
{
|
|
1933
|
-
index: 0,
|
|
1934
|
-
delta: {
|
|
1935
|
-
tool_calls: [
|
|
1936
|
-
{
|
|
1937
|
-
id: toolCallId,
|
|
1938
|
-
type: 'function',
|
|
1939
|
-
index: 0,
|
|
1940
|
-
function: { name: 'write_file', arguments: raw.slice(0, cut) },
|
|
1941
|
-
},
|
|
1942
|
-
],
|
|
1943
|
-
},
|
|
1944
|
-
},
|
|
1945
|
-
],
|
|
1946
|
-
},
|
|
1947
|
-
{
|
|
1948
|
-
index: 0,
|
|
1949
|
-
choices: [
|
|
1950
|
-
{
|
|
1951
|
-
index: 0,
|
|
1952
|
-
delta: {
|
|
1953
|
-
tool_calls: [
|
|
1954
|
-
{
|
|
1955
|
-
id: toolCallId,
|
|
1956
|
-
type: 'function',
|
|
1957
|
-
index: 0,
|
|
1958
|
-
function: { name: 'write_file', arguments: raw.slice(cut) },
|
|
1959
|
-
},
|
|
1960
|
-
],
|
|
1961
|
-
finish_reason: 'tool_calls',
|
|
1962
|
-
} as unknown as ChunkDelta,
|
|
1963
|
-
},
|
|
1964
|
-
],
|
|
1965
|
-
},
|
|
1966
|
-
]);
|
|
1967
|
-
};
|
|
1968
|
-
|
|
1969
|
-
provider.generateStream = vi
|
|
1970
|
-
.fn()
|
|
1971
|
-
.mockReturnValueOnce(
|
|
1972
|
-
buildToolCallStream('wf_direct_1', {
|
|
1973
|
-
path: targetPath,
|
|
1974
|
-
mode: 'direct',
|
|
1975
|
-
content: fullContent,
|
|
1976
|
-
})
|
|
1977
|
-
)
|
|
1978
|
-
.mockReturnValueOnce(
|
|
1979
|
-
buildToolCallStream('wf_finalize_2', {
|
|
1980
|
-
path: targetPath,
|
|
1981
|
-
mode: 'finalize',
|
|
1982
|
-
bufferId: 'wf_direct_1',
|
|
1983
|
-
})
|
|
1984
|
-
)
|
|
1985
|
-
.mockReturnValueOnce(
|
|
1986
|
-
toStream([
|
|
1987
|
-
{
|
|
1988
|
-
index: 0,
|
|
1989
|
-
choices: [{ index: 0, delta: { content: 'done' } }],
|
|
1990
|
-
},
|
|
1991
|
-
{
|
|
1992
|
-
index: 0,
|
|
1993
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
1994
|
-
},
|
|
1995
|
-
])
|
|
1996
|
-
);
|
|
1997
|
-
|
|
1998
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
1999
|
-
const events = await collectEvents(
|
|
2000
|
-
agent.runStream(
|
|
2001
|
-
{
|
|
2002
|
-
...createInput(),
|
|
2003
|
-
maxSteps: 6,
|
|
2004
|
-
},
|
|
2005
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
2006
|
-
)
|
|
2007
|
-
);
|
|
2008
|
-
|
|
2009
|
-
const toolResults = events.filter((event) => event.type === 'tool_result');
|
|
2010
|
-
expect(toolResults).toHaveLength(2);
|
|
2011
|
-
|
|
2012
|
-
const payloads = toolResults.map(
|
|
2013
|
-
(event) =>
|
|
2014
|
-
JSON.parse((event.data as Message).content as string) as {
|
|
2015
|
-
code: string;
|
|
2016
|
-
nextAction: string;
|
|
2017
|
-
}
|
|
2018
|
-
);
|
|
2019
|
-
expect(payloads.map((payload) => payload.code)).toEqual([
|
|
2020
|
-
'WRITE_FILE_PARTIAL_BUFFERED',
|
|
2021
|
-
'WRITE_FILE_FINALIZE_OK',
|
|
2022
|
-
]);
|
|
2023
|
-
expect(payloads.map((payload) => payload.nextAction)).toEqual(['finalize', 'none']);
|
|
2024
|
-
expect(events.at(-1)).toMatchObject({
|
|
2025
|
-
type: 'done',
|
|
2026
|
-
data: { finishReason: 'stop' },
|
|
2027
|
-
});
|
|
2028
|
-
|
|
2029
|
-
expect(await fs.readFile(targetPath, 'utf8')).toBe(fullContent);
|
|
2030
|
-
} finally {
|
|
2031
|
-
await fs.rm(allowedDir, { recursive: true, force: true });
|
|
2032
|
-
await fs.rm(bufferDir, { recursive: true, force: true });
|
|
2033
|
-
}
|
|
2034
|
-
});
|
|
2035
|
-
|
|
2036
|
-
it('supports streamed write_file finalize by bufferId without path after an oversized direct write', async () => {
|
|
2037
|
-
const provider = createProvider();
|
|
2038
|
-
|
|
2039
|
-
const allowedDir = await fs.mkdtemp(path.join(os.tmpdir(), 'renx-index-finalize-id-'));
|
|
2040
|
-
const bufferDir = await fs.mkdtemp(path.join(os.tmpdir(), 'renx-index-finalize-buffer-'));
|
|
2041
|
-
const targetPath = path.join(allowedDir, 'streamed-finalize-by-id.txt');
|
|
2042
|
-
const fullContent = 'abcdefghijklmnop';
|
|
2043
|
-
|
|
2044
|
-
try {
|
|
2045
|
-
const manager = new DefaultToolManager();
|
|
2046
|
-
manager.registerTool(
|
|
2047
|
-
new WriteFileTool({
|
|
2048
|
-
allowedDirectories: [allowedDir],
|
|
2049
|
-
bufferBaseDir: bufferDir,
|
|
2050
|
-
maxChunkBytes: 8,
|
|
2051
|
-
})
|
|
2052
|
-
);
|
|
2053
|
-
|
|
2054
|
-
const buildToolCallStream = (toolCallId: string, args: Record<string, unknown>) => {
|
|
2055
|
-
const raw = JSON.stringify(args);
|
|
2056
|
-
const cut = Math.max(1, Math.floor(raw.length / 2));
|
|
2057
|
-
return toStream([
|
|
2058
|
-
{
|
|
2059
|
-
index: 0,
|
|
2060
|
-
choices: [
|
|
2061
|
-
{
|
|
2062
|
-
index: 0,
|
|
2063
|
-
delta: {
|
|
2064
|
-
tool_calls: [
|
|
2065
|
-
{
|
|
2066
|
-
id: toolCallId,
|
|
2067
|
-
type: 'function',
|
|
2068
|
-
index: 0,
|
|
2069
|
-
function: { name: 'write_file', arguments: raw.slice(0, cut) },
|
|
2070
|
-
},
|
|
2071
|
-
],
|
|
2072
|
-
},
|
|
2073
|
-
},
|
|
2074
|
-
],
|
|
2075
|
-
},
|
|
2076
|
-
{
|
|
2077
|
-
index: 0,
|
|
2078
|
-
choices: [
|
|
2079
|
-
{
|
|
2080
|
-
index: 0,
|
|
2081
|
-
delta: {
|
|
2082
|
-
tool_calls: [
|
|
2083
|
-
{
|
|
2084
|
-
id: toolCallId,
|
|
2085
|
-
type: 'function',
|
|
2086
|
-
index: 0,
|
|
2087
|
-
function: { name: 'write_file', arguments: raw.slice(cut) },
|
|
2088
|
-
},
|
|
2089
|
-
],
|
|
2090
|
-
finish_reason: 'tool_calls',
|
|
2091
|
-
} as unknown as ChunkDelta,
|
|
2092
|
-
},
|
|
2093
|
-
],
|
|
2094
|
-
},
|
|
2095
|
-
]);
|
|
2096
|
-
};
|
|
2097
|
-
|
|
2098
|
-
provider.generateStream = vi
|
|
2099
|
-
.fn()
|
|
2100
|
-
.mockReturnValueOnce(
|
|
2101
|
-
buildToolCallStream('wf_direct_finalize_id_1', {
|
|
2102
|
-
path: targetPath,
|
|
2103
|
-
mode: 'direct',
|
|
2104
|
-
content: fullContent,
|
|
2105
|
-
})
|
|
2106
|
-
)
|
|
2107
|
-
.mockReturnValueOnce(
|
|
2108
|
-
buildToolCallStream('wf_finalize_by_id_2', {
|
|
2109
|
-
mode: 'finalize',
|
|
2110
|
-
bufferId: 'wf_direct_finalize_id_1',
|
|
2111
|
-
})
|
|
2112
|
-
)
|
|
2113
|
-
.mockReturnValueOnce(
|
|
2114
|
-
toStream([
|
|
2115
|
-
{
|
|
2116
|
-
index: 0,
|
|
2117
|
-
choices: [{ index: 0, delta: { content: 'done' } }],
|
|
2118
|
-
},
|
|
2119
|
-
{
|
|
2120
|
-
index: 0,
|
|
2121
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
2122
|
-
},
|
|
2123
|
-
])
|
|
2124
|
-
);
|
|
2125
|
-
|
|
2126
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
2127
|
-
const events = await collectEvents(
|
|
2128
|
-
agent.runStream(
|
|
2129
|
-
{
|
|
2130
|
-
...createInput(),
|
|
2131
|
-
maxSteps: 5,
|
|
2132
|
-
},
|
|
2133
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn() }
|
|
2134
|
-
)
|
|
2135
|
-
);
|
|
2136
|
-
|
|
2137
|
-
const toolResults = events.filter((event) => event.type === 'tool_result');
|
|
2138
|
-
expect(toolResults).toHaveLength(2);
|
|
2139
|
-
|
|
2140
|
-
const payloads = toolResults.map(
|
|
2141
|
-
(event) =>
|
|
2142
|
-
JSON.parse((event.data as Message).content as string) as {
|
|
2143
|
-
code: string;
|
|
2144
|
-
nextAction: string;
|
|
2145
|
-
}
|
|
2146
|
-
);
|
|
2147
|
-
expect(payloads.map((payload) => payload.code)).toEqual([
|
|
2148
|
-
'WRITE_FILE_PARTIAL_BUFFERED',
|
|
2149
|
-
'WRITE_FILE_FINALIZE_OK',
|
|
2150
|
-
]);
|
|
2151
|
-
expect(payloads.map((payload) => payload.nextAction)).toEqual(['finalize', 'none']);
|
|
2152
|
-
expect(await fs.readFile(targetPath, 'utf8')).toBe(fullContent);
|
|
2153
|
-
} finally {
|
|
2154
|
-
await fs.rm(allowedDir, { recursive: true, force: true });
|
|
2155
|
-
await fs.rm(bufferDir, { recursive: true, force: true });
|
|
2156
|
-
}
|
|
2157
|
-
});
|
|
2158
|
-
|
|
2159
|
-
it('executeTool uses UnknownError message when tool fails without explicit error object', async () => {
|
|
2160
|
-
const provider = createProvider();
|
|
2161
|
-
const manager = createToolManager();
|
|
2162
|
-
manager.execute = vi.fn().mockImplementation(async (_toolCall, options) => {
|
|
2163
|
-
options.onChunk?.({ type: 'stdout', data: 'chunk-1' });
|
|
2164
|
-
const decision = await options.onConfirm?.({
|
|
2165
|
-
toolCallId: 'call_2',
|
|
2166
|
-
toolName: 'bash',
|
|
2167
|
-
arguments: '{}',
|
|
2168
|
-
});
|
|
2169
|
-
expect(decision).toEqual({ approved: true, message: 'approved' });
|
|
2170
|
-
return { success: false };
|
|
2171
|
-
});
|
|
2172
|
-
|
|
2173
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
2174
|
-
const onMessage = vi.fn();
|
|
2175
|
-
const toolChunkSpy = vi.fn();
|
|
2176
|
-
agent.on('tool_chunk', toolChunkSpy);
|
|
2177
|
-
agent.on(
|
|
2178
|
-
'tool_confirm',
|
|
2179
|
-
(info: { resolve: (decision: { approved: boolean; message?: string }) => void }) => {
|
|
2180
|
-
info.resolve({ approved: true, message: 'approved' });
|
|
2181
|
-
}
|
|
2182
|
-
);
|
|
2183
|
-
|
|
2184
|
-
const toolEvents = await collectEvents(
|
|
2185
|
-
(agent as unknown as AgentPrivate).executeTool(
|
|
2186
|
-
{
|
|
2187
|
-
id: 'call_2',
|
|
2188
|
-
type: 'function',
|
|
2189
|
-
index: 0,
|
|
2190
|
-
function: { name: 'bash', arguments: '{}' },
|
|
2191
|
-
},
|
|
2192
|
-
2,
|
|
2193
|
-
{ onMessage }
|
|
2194
|
-
)
|
|
2195
|
-
);
|
|
2196
|
-
|
|
2197
|
-
expect(toolEvents).toHaveLength(1);
|
|
2198
|
-
expect(toolEvents[0]).toMatchObject({
|
|
2199
|
-
type: 'tool_result',
|
|
2200
|
-
data: { role: 'tool', content: 'Unknown error', tool_call_id: 'call_2' },
|
|
2201
|
-
});
|
|
2202
|
-
expect(onMessage).toHaveBeenCalledOnce();
|
|
2203
|
-
expect(toolChunkSpy).toHaveBeenCalledOnce();
|
|
2204
|
-
});
|
|
2205
|
-
|
|
2206
|
-
it('executeTool forwards onToolPolicy hook to tool executor context', async () => {
|
|
2207
|
-
const provider = createProvider();
|
|
2208
|
-
const manager = createToolManager();
|
|
2209
|
-
manager.execute = vi.fn().mockImplementation(async (_toolCall, options) => {
|
|
2210
|
-
const policyDecision = await options.onPolicyCheck?.({
|
|
2211
|
-
toolCallId: 'call_policy',
|
|
2212
|
-
toolName: 'bash',
|
|
2213
|
-
arguments: '{"command":"rm -rf /"}',
|
|
2214
|
-
parsedArguments: { command: 'rm -rf /' },
|
|
2215
|
-
});
|
|
2216
|
-
expect(policyDecision).toEqual({
|
|
2217
|
-
allowed: false,
|
|
2218
|
-
code: 'DANGEROUS_COMMAND',
|
|
2219
|
-
message: 'rm blocked',
|
|
2220
|
-
});
|
|
2221
|
-
return {
|
|
2222
|
-
success: false,
|
|
2223
|
-
error: {
|
|
2224
|
-
name: 'ToolPolicyDeniedError',
|
|
2225
|
-
message: 'Tool bash blocked by policy [DANGEROUS_COMMAND]: rm blocked',
|
|
2226
|
-
},
|
|
2227
|
-
};
|
|
2228
|
-
});
|
|
2229
|
-
|
|
2230
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
2231
|
-
const onToolPolicy = vi.fn().mockResolvedValue({
|
|
2232
|
-
allowed: false,
|
|
2233
|
-
code: 'DANGEROUS_COMMAND',
|
|
2234
|
-
message: 'rm blocked',
|
|
2235
|
-
});
|
|
2236
|
-
|
|
2237
|
-
const events = await collectEvents(
|
|
2238
|
-
(agent as unknown as AgentPrivate).executeTool(
|
|
2239
|
-
{
|
|
2240
|
-
id: 'call_policy',
|
|
2241
|
-
type: 'function',
|
|
2242
|
-
index: 0,
|
|
2243
|
-
function: { name: 'bash', arguments: '{"command":"rm -rf /"}' },
|
|
2244
|
-
},
|
|
2245
|
-
1,
|
|
2246
|
-
{ onMessage: vi.fn(), onToolPolicy }
|
|
2247
|
-
)
|
|
2248
|
-
);
|
|
2249
|
-
|
|
2250
|
-
expect(onToolPolicy).toHaveBeenCalledWith({
|
|
2251
|
-
toolCallId: 'call_policy',
|
|
2252
|
-
toolName: 'bash',
|
|
2253
|
-
arguments: '{"command":"rm -rf /"}',
|
|
2254
|
-
parsedArguments: { command: 'rm -rf /' },
|
|
2255
|
-
});
|
|
2256
|
-
expect(events[0]).toMatchObject({
|
|
2257
|
-
type: 'tool_result',
|
|
2258
|
-
data: { content: 'Tool bash blocked by policy [DANGEROUS_COMMAND]: rm blocked' },
|
|
2259
|
-
});
|
|
2260
|
-
});
|
|
2261
|
-
|
|
2262
|
-
it('executeTool resolves confirmation through tool_confirm event', async () => {
|
|
2263
|
-
const provider = createProvider();
|
|
2264
|
-
const manager = createToolManager();
|
|
2265
|
-
manager.execute = vi.fn().mockImplementation(async (_toolCall, options) => {
|
|
2266
|
-
options.onChunk?.({ type: 'stdout', data: 'chunk-2' });
|
|
2267
|
-
const decision = await options.onConfirm?.({
|
|
2268
|
-
toolCallId: 'call_3',
|
|
2269
|
-
toolName: 'bash',
|
|
2270
|
-
arguments: '{}',
|
|
2271
|
-
});
|
|
2272
|
-
return decision?.approved ? { success: true, output: 'approved-output' } : { success: false };
|
|
2273
|
-
});
|
|
2274
|
-
|
|
2275
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
2276
|
-
const onMessage = vi.fn();
|
|
2277
|
-
const toolChunkSpy = vi.fn();
|
|
2278
|
-
agent.on('tool_chunk', toolChunkSpy);
|
|
2279
|
-
agent.on(
|
|
2280
|
-
'tool_confirm',
|
|
2281
|
-
(info: { resolve: (decision: { approved: boolean; message?: string }) => void }) => {
|
|
2282
|
-
info.resolve({ approved: true, message: 'approved' });
|
|
2283
|
-
}
|
|
2284
|
-
);
|
|
2285
|
-
|
|
2286
|
-
const toolEvents = await collectEvents(
|
|
2287
|
-
(agent as unknown as AgentPrivate).executeTool(
|
|
2288
|
-
{
|
|
2289
|
-
id: 'call_3',
|
|
2290
|
-
type: 'function',
|
|
2291
|
-
index: 0,
|
|
2292
|
-
function: { name: 'bash', arguments: '{}' },
|
|
2293
|
-
},
|
|
2294
|
-
3,
|
|
2295
|
-
{ onMessage }
|
|
2296
|
-
)
|
|
2297
|
-
);
|
|
2298
|
-
|
|
2299
|
-
expect(toolEvents[0]).toMatchObject({
|
|
2300
|
-
type: 'tool_result',
|
|
2301
|
-
data: { content: 'approved-output', tool_call_id: 'call_3' },
|
|
2302
|
-
});
|
|
2303
|
-
expect(onMessage).toHaveBeenCalledOnce();
|
|
2304
|
-
expect(toolChunkSpy).toHaveBeenCalledOnce();
|
|
2305
|
-
});
|
|
2306
|
-
|
|
2307
|
-
it('executeTool handles pre-aborted signal at confirmation stage', async () => {
|
|
2308
|
-
const provider = createProvider();
|
|
2309
|
-
const manager = createToolManager();
|
|
2310
|
-
const abortController = new AbortController();
|
|
2311
|
-
manager.execute = vi.fn().mockImplementation(async (_toolCall, options) => {
|
|
2312
|
-
abortController.abort();
|
|
2313
|
-
const decision = await options.onConfirm?.({
|
|
2314
|
-
toolCallId: 'call_abort_confirm',
|
|
2315
|
-
toolName: 'bash',
|
|
2316
|
-
arguments: '{}',
|
|
2317
|
-
});
|
|
2318
|
-
expect(decision).toEqual({ approved: false, message: 'Operation aborted' });
|
|
2319
|
-
return {
|
|
2320
|
-
success: false,
|
|
2321
|
-
error: { message: decision?.message || 'Operation aborted' },
|
|
2322
|
-
};
|
|
2323
|
-
});
|
|
2324
|
-
|
|
2325
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
2326
|
-
const toolEvents = await collectEvents(
|
|
2327
|
-
(agent as unknown as AgentPrivate).executeTool(
|
|
2328
|
-
{
|
|
2329
|
-
id: 'call_abort_confirm',
|
|
2330
|
-
type: 'function',
|
|
2331
|
-
index: 0,
|
|
2332
|
-
function: { name: 'bash', arguments: '{}' },
|
|
2333
|
-
},
|
|
2334
|
-
1,
|
|
2335
|
-
{ onMessage: vi.fn() },
|
|
2336
|
-
abortController.signal
|
|
2337
|
-
)
|
|
2338
|
-
);
|
|
2339
|
-
|
|
2340
|
-
expect(toolEvents[0]).toMatchObject({
|
|
2341
|
-
type: 'tool_result',
|
|
2342
|
-
data: { content: 'Operation aborted', tool_call_id: 'call_abort_confirm' },
|
|
2343
|
-
});
|
|
2344
|
-
});
|
|
2345
|
-
|
|
2346
|
-
it('processToolCalls executes tools and appends tool message', async () => {
|
|
2347
|
-
const provider = createProvider();
|
|
2348
|
-
const manager = createToolManager();
|
|
2349
|
-
manager.execute = vi.fn().mockResolvedValue({ success: true, output: 'tool-ok' });
|
|
2350
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
2351
|
-
const messages: Message[] = [
|
|
2352
|
-
{
|
|
2353
|
-
messageId: 'm0',
|
|
2354
|
-
type: 'assistant-text',
|
|
2355
|
-
role: 'assistant',
|
|
2356
|
-
content: 'before',
|
|
2357
|
-
timestamp: 1,
|
|
2358
|
-
},
|
|
2359
|
-
];
|
|
2360
|
-
|
|
2361
|
-
const events = await collectEvents(
|
|
2362
|
-
(agent as unknown as AgentPrivate).processToolCalls(
|
|
2363
|
-
[
|
|
2364
|
-
{
|
|
2365
|
-
id: 'tool_1',
|
|
2366
|
-
type: 'function',
|
|
2367
|
-
index: 0,
|
|
2368
|
-
function: { name: 'bash', arguments: '{"command":"echo ok"}' },
|
|
2369
|
-
},
|
|
2370
|
-
],
|
|
2371
|
-
messages,
|
|
2372
|
-
1,
|
|
2373
|
-
{ onMessage: vi.fn() }
|
|
2374
|
-
)
|
|
2375
|
-
);
|
|
2376
|
-
|
|
2377
|
-
expect(events.map((e) => e.type)).toEqual(['progress', 'tool_result']);
|
|
2378
|
-
expect(messages.at(-1)).toMatchObject({
|
|
2379
|
-
role: 'tool',
|
|
2380
|
-
content: 'tool-ok',
|
|
2381
|
-
tool_call_id: 'tool_1',
|
|
2382
|
-
});
|
|
2383
|
-
});
|
|
2384
|
-
|
|
2385
|
-
it('reuses cached tool result for duplicate executionId + toolCallId across reruns', async () => {
|
|
2386
|
-
const provider = createProvider();
|
|
2387
|
-
const manager = createToolManager();
|
|
2388
|
-
const toolCallId = 'tool_idempotent_1';
|
|
2389
|
-
manager.execute = vi.fn().mockResolvedValue({ success: true, output: 'tool-once' });
|
|
2390
|
-
|
|
2391
|
-
const toolCallStream = () =>
|
|
2392
|
-
toStream([
|
|
2393
|
-
{
|
|
2394
|
-
index: 0,
|
|
2395
|
-
choices: [
|
|
2396
|
-
{
|
|
2397
|
-
index: 0,
|
|
2398
|
-
delta: {
|
|
2399
|
-
tool_calls: [
|
|
2400
|
-
{
|
|
2401
|
-
id: toolCallId,
|
|
2402
|
-
type: 'function',
|
|
2403
|
-
index: 0,
|
|
2404
|
-
function: { name: 'bash', arguments: '{"command":"echo once"}' },
|
|
2405
|
-
},
|
|
2406
|
-
],
|
|
2407
|
-
finish_reason: 'tool_calls',
|
|
2408
|
-
} as unknown as ChunkDelta,
|
|
2409
|
-
},
|
|
2410
|
-
],
|
|
2411
|
-
},
|
|
2412
|
-
]);
|
|
2413
|
-
|
|
2414
|
-
const doneStream = () =>
|
|
2415
|
-
toStream([
|
|
2416
|
-
{
|
|
2417
|
-
index: 0,
|
|
2418
|
-
choices: [{ index: 0, delta: { content: 'done' } }],
|
|
2419
|
-
},
|
|
2420
|
-
{
|
|
2421
|
-
index: 0,
|
|
2422
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
2423
|
-
},
|
|
2424
|
-
]);
|
|
2425
|
-
|
|
2426
|
-
provider.generateStream = vi
|
|
2427
|
-
.fn()
|
|
2428
|
-
.mockReturnValueOnce(toolCallStream())
|
|
2429
|
-
.mockReturnValueOnce(doneStream())
|
|
2430
|
-
.mockReturnValueOnce(toolCallStream())
|
|
2431
|
-
.mockReturnValueOnce(doneStream());
|
|
2432
|
-
|
|
2433
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
2434
|
-
maxRetryCount: 3,
|
|
2435
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
2436
|
-
});
|
|
2437
|
-
|
|
2438
|
-
const runInput = () => ({
|
|
2439
|
-
...createInput(),
|
|
2440
|
-
executionId: 'exec_idempotent_1',
|
|
2441
|
-
maxSteps: 3,
|
|
2442
|
-
});
|
|
2443
|
-
|
|
2444
|
-
const firstEvents = await collectEvents(
|
|
2445
|
-
agent.runStream(runInput(), { onMessage: vi.fn(), onCheckpoint: vi.fn() })
|
|
2446
|
-
);
|
|
2447
|
-
const secondEvents = await collectEvents(
|
|
2448
|
-
agent.runStream(runInput(), { onMessage: vi.fn(), onCheckpoint: vi.fn() })
|
|
2449
|
-
);
|
|
2450
|
-
|
|
2451
|
-
const firstToolResult = firstEvents.find((event) => event.type === 'tool_result');
|
|
2452
|
-
const secondToolResult = secondEvents.find((event) => event.type === 'tool_result');
|
|
2453
|
-
expect(firstToolResult).toMatchObject({
|
|
2454
|
-
type: 'tool_result',
|
|
2455
|
-
data: { content: 'tool-once', tool_call_id: toolCallId },
|
|
2456
|
-
});
|
|
2457
|
-
expect(secondToolResult).toMatchObject({
|
|
2458
|
-
type: 'tool_result',
|
|
2459
|
-
data: { content: 'tool-once', tool_call_id: toolCallId },
|
|
2460
|
-
});
|
|
2461
|
-
expect(manager.execute).toHaveBeenCalledTimes(1);
|
|
2462
|
-
});
|
|
2463
|
-
|
|
2464
|
-
it('deduplicates concurrent duplicate tool execution for same executionId + toolCallId', async () => {
|
|
2465
|
-
const provider = createProvider();
|
|
2466
|
-
const manager = createToolManager();
|
|
2467
|
-
const toolCallId = 'tool_idempotent_race_1';
|
|
2468
|
-
|
|
2469
|
-
manager.execute = vi.fn().mockImplementation(
|
|
2470
|
-
async () =>
|
|
2471
|
-
new Promise((resolve) => {
|
|
2472
|
-
setTimeout(() => resolve({ success: true, output: 'tool-race-once' }), 20);
|
|
2473
|
-
})
|
|
2474
|
-
);
|
|
2475
|
-
|
|
2476
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
2477
|
-
maxRetryCount: 3,
|
|
2478
|
-
toolExecutionLedger: new InMemoryToolExecutionLedger(),
|
|
2479
|
-
});
|
|
2480
|
-
const agentPrivate = agent as unknown as AgentPrivate;
|
|
2481
|
-
const toolCall: ToolCall = {
|
|
2482
|
-
id: toolCallId,
|
|
2483
|
-
type: 'function',
|
|
2484
|
-
index: 0,
|
|
2485
|
-
function: { name: 'bash', arguments: '{"command":"echo race"}' },
|
|
2486
|
-
};
|
|
2487
|
-
|
|
2488
|
-
const [eventsA, eventsB] = await Promise.all([
|
|
2489
|
-
collectEvents(
|
|
2490
|
-
agentPrivate.executeTool(
|
|
2491
|
-
toolCall,
|
|
2492
|
-
1,
|
|
2493
|
-
{ onMessage: vi.fn() },
|
|
2494
|
-
undefined,
|
|
2495
|
-
'exec_idempotent_race_1'
|
|
2496
|
-
)
|
|
2497
|
-
),
|
|
2498
|
-
collectEvents(
|
|
2499
|
-
agentPrivate.executeTool(
|
|
2500
|
-
toolCall,
|
|
2501
|
-
1,
|
|
2502
|
-
{ onMessage: vi.fn() },
|
|
2503
|
-
undefined,
|
|
2504
|
-
'exec_idempotent_race_1'
|
|
2505
|
-
)
|
|
2506
|
-
),
|
|
2507
|
-
]);
|
|
2508
|
-
const toolResultA = eventsA.find((event) => event.type === 'tool_result');
|
|
2509
|
-
const toolResultB = eventsB.find((event) => event.type === 'tool_result');
|
|
2510
|
-
|
|
2511
|
-
expect(toolResultA).toMatchObject({
|
|
2512
|
-
type: 'tool_result',
|
|
2513
|
-
data: { content: 'tool-race-once', tool_call_id: toolCallId },
|
|
2514
|
-
});
|
|
2515
|
-
expect(toolResultB).toMatchObject({
|
|
2516
|
-
type: 'tool_result',
|
|
2517
|
-
data: { content: 'tool-race-once', tool_call_id: toolCallId },
|
|
2518
|
-
});
|
|
2519
|
-
expect(manager.execute).toHaveBeenCalledTimes(1);
|
|
2520
|
-
});
|
|
2521
|
-
|
|
2522
|
-
it('does not cache tool result by default without external ledger', async () => {
|
|
2523
|
-
const provider = createProvider();
|
|
2524
|
-
const manager = createToolManager();
|
|
2525
|
-
manager.execute = vi.fn().mockResolvedValue({ success: true, output: 'tool-no-cache' });
|
|
2526
|
-
|
|
2527
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
2528
|
-
const agentPrivate = agent as unknown as AgentPrivate;
|
|
2529
|
-
const toolCall: ToolCall = {
|
|
2530
|
-
id: 'tool_no_cache_1',
|
|
2531
|
-
type: 'function',
|
|
2532
|
-
index: 0,
|
|
2533
|
-
function: { name: 'bash', arguments: '{"command":"echo nc"}' },
|
|
2534
|
-
};
|
|
2535
|
-
|
|
2536
|
-
await collectEvents(
|
|
2537
|
-
agentPrivate.executeTool(toolCall, 1, { onMessage: vi.fn() }, undefined, 'exec_no_cache_1')
|
|
2538
|
-
);
|
|
2539
|
-
await collectEvents(
|
|
2540
|
-
agentPrivate.executeTool(toolCall, 1, { onMessage: vi.fn() }, undefined, 'exec_no_cache_1')
|
|
2541
|
-
);
|
|
2542
|
-
|
|
2543
|
-
expect(manager.execute).toHaveBeenCalledTimes(2);
|
|
2544
|
-
});
|
|
2545
|
-
|
|
2546
|
-
it('processToolCalls supports bounded concurrency when configured', async () => {
|
|
2547
|
-
vi.useFakeTimers();
|
|
2548
|
-
const provider = createProvider();
|
|
2549
|
-
const manager = createToolManager();
|
|
2550
|
-
(
|
|
2551
|
-
manager as unknown as { getConcurrencyPolicy: (toolCall: ToolCall) => ToolConcurrencyPolicy }
|
|
2552
|
-
).getConcurrencyPolicy = vi.fn(() => ({ mode: 'parallel-safe' }));
|
|
2553
|
-
let inFlight = 0;
|
|
2554
|
-
let maxInFlight = 0;
|
|
2555
|
-
manager.execute = vi.fn().mockImplementation(async (toolCall: ToolCall) => {
|
|
2556
|
-
inFlight += 1;
|
|
2557
|
-
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
2558
|
-
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
|
2559
|
-
inFlight -= 1;
|
|
2560
|
-
return { success: true, output: `ok-${toolCall.id}` };
|
|
2561
|
-
});
|
|
2562
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
2563
|
-
maxRetryCount: 3,
|
|
2564
|
-
maxConcurrentToolCalls: 2,
|
|
2565
|
-
});
|
|
2566
|
-
const messages: Message[] = [
|
|
2567
|
-
{
|
|
2568
|
-
messageId: 'm0',
|
|
2569
|
-
type: 'assistant-text',
|
|
2570
|
-
role: 'assistant',
|
|
2571
|
-
content: 'before',
|
|
2572
|
-
timestamp: 1,
|
|
2573
|
-
},
|
|
2574
|
-
];
|
|
2575
|
-
|
|
2576
|
-
const eventsPromise = collectEvents(
|
|
2577
|
-
(agent as unknown as AgentPrivate).processToolCalls(
|
|
2578
|
-
[
|
|
2579
|
-
{
|
|
2580
|
-
id: 'tool_1',
|
|
2581
|
-
type: 'function',
|
|
2582
|
-
index: 0,
|
|
2583
|
-
function: { name: 'bash', arguments: '{"command":"echo 1"}' },
|
|
2584
|
-
},
|
|
2585
|
-
{
|
|
2586
|
-
id: 'tool_2',
|
|
2587
|
-
type: 'function',
|
|
2588
|
-
index: 1,
|
|
2589
|
-
function: { name: 'bash', arguments: '{"command":"echo 2"}' },
|
|
2590
|
-
},
|
|
2591
|
-
],
|
|
2592
|
-
messages,
|
|
2593
|
-
1,
|
|
2594
|
-
{ onMessage: vi.fn() }
|
|
2595
|
-
)
|
|
2596
|
-
);
|
|
2597
|
-
|
|
2598
|
-
await vi.advanceTimersByTimeAsync(20);
|
|
2599
|
-
const events = await eventsPromise;
|
|
2600
|
-
|
|
2601
|
-
expect(maxInFlight).toBe(2);
|
|
2602
|
-
expect(events.map((e) => e.type)).toEqual([
|
|
2603
|
-
'progress',
|
|
2604
|
-
'progress',
|
|
2605
|
-
'tool_result',
|
|
2606
|
-
'tool_result',
|
|
2607
|
-
]);
|
|
2608
|
-
expect(messages.at(-2)).toMatchObject({ tool_call_id: 'tool_1' });
|
|
2609
|
-
expect(messages.at(-1)).toMatchObject({ tool_call_id: 'tool_2' });
|
|
2610
|
-
});
|
|
2611
|
-
|
|
2612
|
-
it('processToolCalls enforces lockKey to avoid conflicting concurrent tools', async () => {
|
|
2613
|
-
vi.useFakeTimers();
|
|
2614
|
-
const provider = createProvider();
|
|
2615
|
-
const manager = createToolManager();
|
|
2616
|
-
(
|
|
2617
|
-
manager as unknown as { getConcurrencyPolicy: (toolCall: ToolCall) => ToolConcurrencyPolicy }
|
|
2618
|
-
).getConcurrencyPolicy = vi.fn((toolCall: ToolCall) => ({
|
|
2619
|
-
mode: 'parallel-safe',
|
|
2620
|
-
lockKey: toolCall.id === 'tool_3' ? 'other-file' : 'same-file',
|
|
2621
|
-
}));
|
|
2622
|
-
|
|
2623
|
-
let inFlight = 0;
|
|
2624
|
-
let maxInFlight = 0;
|
|
2625
|
-
manager.execute = vi.fn().mockImplementation(async (_toolCall: ToolCall) => {
|
|
2626
|
-
inFlight += 1;
|
|
2627
|
-
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
2628
|
-
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
|
2629
|
-
inFlight -= 1;
|
|
2630
|
-
return { success: true, output: 'ok' };
|
|
2631
|
-
});
|
|
2632
|
-
|
|
2633
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
2634
|
-
maxRetryCount: 3,
|
|
2635
|
-
maxConcurrentToolCalls: 3,
|
|
2636
|
-
});
|
|
2637
|
-
const messages: Message[] = [
|
|
2638
|
-
{
|
|
2639
|
-
messageId: 'm0',
|
|
2640
|
-
type: 'assistant-text',
|
|
2641
|
-
role: 'assistant',
|
|
2642
|
-
content: 'before',
|
|
2643
|
-
timestamp: 1,
|
|
2644
|
-
},
|
|
2645
|
-
];
|
|
2646
|
-
|
|
2647
|
-
const eventsPromise = collectEvents(
|
|
2648
|
-
(agent as unknown as AgentPrivate).processToolCalls(
|
|
2649
|
-
[
|
|
2650
|
-
{
|
|
2651
|
-
id: 'tool_1',
|
|
2652
|
-
type: 'function',
|
|
2653
|
-
index: 0,
|
|
2654
|
-
function: { name: 'bash', arguments: '{"command":"echo 1"}' },
|
|
2655
|
-
},
|
|
2656
|
-
{
|
|
2657
|
-
id: 'tool_2',
|
|
2658
|
-
type: 'function',
|
|
2659
|
-
index: 1,
|
|
2660
|
-
function: { name: 'bash', arguments: '{"command":"echo 2"}' },
|
|
2661
|
-
},
|
|
2662
|
-
{
|
|
2663
|
-
id: 'tool_3',
|
|
2664
|
-
type: 'function',
|
|
2665
|
-
index: 2,
|
|
2666
|
-
function: { name: 'bash', arguments: '{"command":"echo 3"}' },
|
|
2667
|
-
},
|
|
2668
|
-
],
|
|
2669
|
-
messages,
|
|
2670
|
-
1,
|
|
2671
|
-
{ onMessage: vi.fn() }
|
|
2672
|
-
)
|
|
2673
|
-
);
|
|
2674
|
-
|
|
2675
|
-
await vi.advanceTimersByTimeAsync(20);
|
|
2676
|
-
await vi.advanceTimersByTimeAsync(20);
|
|
2677
|
-
await eventsPromise;
|
|
2678
|
-
|
|
2679
|
-
expect(maxInFlight).toBe(2);
|
|
2680
|
-
expect(manager.execute).toHaveBeenCalledTimes(3);
|
|
2681
|
-
});
|
|
2682
|
-
|
|
2683
|
-
it('processToolCalls builds mixed exclusive/parallel execution waves', async () => {
|
|
2684
|
-
const provider = createProvider();
|
|
2685
|
-
const manager = createToolManager();
|
|
2686
|
-
manager.execute = vi.fn().mockImplementation(async (toolCall: ToolCall) => {
|
|
2687
|
-
return { success: true, output: `ok-${toolCall.id}` };
|
|
2688
|
-
});
|
|
2689
|
-
|
|
2690
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
2691
|
-
maxRetryCount: 3,
|
|
2692
|
-
maxConcurrentToolCalls: 3,
|
|
2693
|
-
toolConcurrencyPolicyResolver: (toolCall: ToolCall) =>
|
|
2694
|
-
toolCall.id === 'tool_exclusive' ? { mode: 'exclusive' } : { mode: 'parallel-safe' },
|
|
2695
|
-
});
|
|
2696
|
-
|
|
2697
|
-
const messages: Message[] = [
|
|
2698
|
-
{
|
|
2699
|
-
messageId: 'm0',
|
|
2700
|
-
type: 'assistant-text',
|
|
2701
|
-
role: 'assistant',
|
|
2702
|
-
content: 'before',
|
|
2703
|
-
timestamp: 1,
|
|
2704
|
-
},
|
|
2705
|
-
];
|
|
2706
|
-
|
|
2707
|
-
const events = await collectEvents(
|
|
2708
|
-
(agent as unknown as AgentPrivate).processToolCalls(
|
|
2709
|
-
[
|
|
2710
|
-
{
|
|
2711
|
-
id: 'tool_exclusive',
|
|
2712
|
-
type: 'function',
|
|
2713
|
-
index: 0,
|
|
2714
|
-
function: { name: 'bash', arguments: '{"command":"echo e"}' },
|
|
2715
|
-
},
|
|
2716
|
-
{
|
|
2717
|
-
id: 'tool_parallel',
|
|
2718
|
-
type: 'function',
|
|
2719
|
-
index: 1,
|
|
2720
|
-
function: { name: 'bash', arguments: '{"command":"echo p"}' },
|
|
2721
|
-
},
|
|
2722
|
-
],
|
|
2723
|
-
messages,
|
|
2724
|
-
1,
|
|
2725
|
-
{ onMessage: vi.fn() }
|
|
2726
|
-
)
|
|
2727
|
-
);
|
|
2728
|
-
|
|
2729
|
-
expect(events.map((e) => e.type)).toEqual([
|
|
2730
|
-
'progress',
|
|
2731
|
-
'progress',
|
|
2732
|
-
'tool_result',
|
|
2733
|
-
'tool_result',
|
|
2734
|
-
]);
|
|
2735
|
-
expect(messages.at(-2)).toMatchObject({ tool_call_id: 'tool_exclusive' });
|
|
2736
|
-
expect(messages.at(-1)).toMatchObject({ tool_call_id: 'tool_parallel' });
|
|
2737
|
-
});
|
|
2738
|
-
|
|
2739
|
-
it('executeTool maps success without output to summary content', async () => {
|
|
2740
|
-
const provider = createProvider();
|
|
2741
|
-
const manager = createToolManager();
|
|
2742
|
-
manager.execute = vi.fn().mockResolvedValue({ success: true });
|
|
2743
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
2744
|
-
|
|
2745
|
-
const events = await collectEvents(
|
|
2746
|
-
(agent as unknown as AgentPrivate).executeTool(
|
|
2747
|
-
{
|
|
2748
|
-
id: 'call_no_output',
|
|
2749
|
-
type: 'function',
|
|
2750
|
-
index: 0,
|
|
2751
|
-
function: { name: 'bash', arguments: '{}' },
|
|
2752
|
-
},
|
|
2753
|
-
1
|
|
2754
|
-
)
|
|
2755
|
-
);
|
|
2756
|
-
|
|
2757
|
-
expect(events[0]).toMatchObject({
|
|
2758
|
-
type: 'tool_result',
|
|
2759
|
-
data: {
|
|
2760
|
-
content: 'Command completed successfully with no output.',
|
|
2761
|
-
tool_call_id: 'call_no_output',
|
|
2762
|
-
metadata: {
|
|
2763
|
-
toolResult: {
|
|
2764
|
-
summary: 'Command completed successfully with no output.',
|
|
2765
|
-
success: true,
|
|
2766
|
-
},
|
|
2767
|
-
},
|
|
2768
|
-
},
|
|
2769
|
-
});
|
|
2770
|
-
});
|
|
2771
|
-
|
|
2772
|
-
it('executeTool uses explicit tool error message when provided', async () => {
|
|
2773
|
-
const provider = createProvider();
|
|
2774
|
-
const manager = createToolManager();
|
|
2775
|
-
manager.execute = vi.fn().mockResolvedValue({
|
|
2776
|
-
success: false,
|
|
2777
|
-
error: { message: 'tool failed explicitly' } as { message: string },
|
|
2778
|
-
});
|
|
2779
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
2780
|
-
|
|
2781
|
-
const events = await collectEvents(
|
|
2782
|
-
(agent as unknown as AgentPrivate).executeTool(
|
|
2783
|
-
{
|
|
2784
|
-
id: 'call_err_message',
|
|
2785
|
-
type: 'function',
|
|
2786
|
-
index: 0,
|
|
2787
|
-
function: { name: 'bash', arguments: '{}' },
|
|
2788
|
-
},
|
|
2789
|
-
1
|
|
2790
|
-
)
|
|
2791
|
-
);
|
|
2792
|
-
|
|
2793
|
-
expect(events[0]).toMatchObject({
|
|
2794
|
-
type: 'tool_result',
|
|
2795
|
-
data: { content: 'tool failed explicitly', tool_call_id: 'call_err_message' },
|
|
2796
|
-
});
|
|
2797
|
-
});
|
|
2798
|
-
|
|
2799
|
-
it('marks tool metric success=false when tool returns failed result without errorCode', async () => {
|
|
2800
|
-
const provider = createProvider();
|
|
2801
|
-
const manager = createToolManager();
|
|
2802
|
-
manager.execute = vi.fn().mockResolvedValue({
|
|
2803
|
-
success: false,
|
|
2804
|
-
error: { message: 'tool failed without code' },
|
|
2805
|
-
});
|
|
2806
|
-
const onMetric = vi.fn(async (_metric: AgentMetric) => undefined);
|
|
2807
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
2808
|
-
|
|
2809
|
-
const events = await collectEvents(
|
|
2810
|
-
(agent as unknown as AgentPrivate).executeTool(
|
|
2811
|
-
{
|
|
2812
|
-
id: 'call_err_no_code',
|
|
2813
|
-
type: 'function',
|
|
2814
|
-
index: 0,
|
|
2815
|
-
function: { name: 'bash', arguments: '{}' },
|
|
2816
|
-
},
|
|
2817
|
-
1,
|
|
2818
|
-
{ onMessage: vi.fn(), onMetric }
|
|
2819
|
-
)
|
|
2820
|
-
);
|
|
2821
|
-
|
|
2822
|
-
expect(events[0]).toMatchObject({
|
|
2823
|
-
type: 'tool_result',
|
|
2824
|
-
data: { content: 'tool failed without code', tool_call_id: 'call_err_no_code' },
|
|
2825
|
-
});
|
|
2826
|
-
|
|
2827
|
-
const toolMetric = onMetric.mock.calls
|
|
2828
|
-
.map((call) => call[0] as AgentMetric)
|
|
2829
|
-
.find((metric) => metric.name === 'agent.tool.duration_ms');
|
|
2830
|
-
expect(toolMetric).toBeDefined();
|
|
2831
|
-
expect(toolMetric?.tags?.success).toBe('false');
|
|
2832
|
-
});
|
|
2833
|
-
|
|
2834
|
-
it('marks tool metric success=false when tool execution throws', async () => {
|
|
2835
|
-
const provider = createProvider();
|
|
2836
|
-
const manager = createToolManager();
|
|
2837
|
-
manager.execute = vi.fn().mockRejectedValue(new Error('chaos tool crash'));
|
|
2838
|
-
const onMetric = vi.fn(async (_metric: AgentMetric) => undefined);
|
|
2839
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
2840
|
-
|
|
2841
|
-
await expect(
|
|
2842
|
-
collectEvents(
|
|
2843
|
-
(agent as unknown as AgentPrivate).executeTool(
|
|
2844
|
-
{
|
|
2845
|
-
id: 'call_throw',
|
|
2846
|
-
type: 'function',
|
|
2847
|
-
index: 0,
|
|
2848
|
-
function: { name: 'bash', arguments: '{}' },
|
|
2849
|
-
},
|
|
2850
|
-
1,
|
|
2851
|
-
{ onMessage: vi.fn(), onMetric }
|
|
2852
|
-
)
|
|
2853
|
-
)
|
|
2854
|
-
).rejects.toThrow('chaos tool crash');
|
|
2855
|
-
|
|
2856
|
-
const toolMetric = onMetric.mock.calls
|
|
2857
|
-
.map((call) => call[0] as AgentMetric)
|
|
2858
|
-
.find((metric) => metric.name === 'agent.tool.duration_ms');
|
|
2859
|
-
expect(toolMetric).toBeDefined();
|
|
2860
|
-
expect(toolMetric?.tags?.success).toBe('false');
|
|
2861
|
-
});
|
|
2862
|
-
|
|
2863
|
-
it('yields error and stops when retry decision is false', async () => {
|
|
2864
|
-
const provider = createProvider();
|
|
2865
|
-
const manager = createToolManager();
|
|
2866
|
-
provider.generateStream = vi.fn().mockImplementation(() =>
|
|
2867
|
-
(async function* () {
|
|
2868
|
-
for (const chunk of [] as Chunk[]) {
|
|
2869
|
-
yield chunk;
|
|
2870
|
-
}
|
|
2871
|
-
throw new Error('llm failed');
|
|
2872
|
-
})()
|
|
2873
|
-
);
|
|
2874
|
-
|
|
2875
|
-
const onError = vi.fn().mockResolvedValue({ retry: false });
|
|
2876
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
2877
|
-
const events = await collectEvents(
|
|
2878
|
-
agent.runStream(createInput(), { onMessage: vi.fn(), onCheckpoint: vi.fn(), onError })
|
|
2879
|
-
);
|
|
2880
|
-
|
|
2881
|
-
expect(onError).toHaveBeenCalledOnce();
|
|
2882
|
-
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(AgentError);
|
|
2883
|
-
expect(events.map((e) => e.type)).toEqual(['progress', 'error']);
|
|
2884
|
-
expect(events[1]).toMatchObject({
|
|
2885
|
-
type: 'error',
|
|
2886
|
-
data: {
|
|
2887
|
-
name: 'UnknownError',
|
|
2888
|
-
code: 1005,
|
|
2889
|
-
errorCode: 'AGENT_UNKNOWN_ERROR',
|
|
2890
|
-
category: 'internal',
|
|
2891
|
-
retryable: false,
|
|
2892
|
-
httpStatus: 500,
|
|
2893
|
-
message: 'llm failed',
|
|
2894
|
-
},
|
|
2895
|
-
});
|
|
2896
|
-
});
|
|
2897
|
-
|
|
2898
|
-
it('retries after onError decision and succeeds on later attempt', async () => {
|
|
2899
|
-
const provider = createProvider();
|
|
2900
|
-
const manager = createToolManager();
|
|
2901
|
-
provider.generateStream = vi
|
|
2902
|
-
.fn()
|
|
2903
|
-
.mockImplementationOnce(() =>
|
|
2904
|
-
(async function* () {
|
|
2905
|
-
for (const chunk of [] as Chunk[]) {
|
|
2906
|
-
yield chunk;
|
|
2907
|
-
}
|
|
2908
|
-
throw new Error('temporary');
|
|
2909
|
-
})()
|
|
2910
|
-
)
|
|
2911
|
-
.mockReturnValueOnce(
|
|
2912
|
-
toStream([
|
|
2913
|
-
{
|
|
2914
|
-
index: 0,
|
|
2915
|
-
choices: [{ index: 0, delta: { content: 'ok' } }],
|
|
2916
|
-
},
|
|
2917
|
-
{
|
|
2918
|
-
index: 0,
|
|
2919
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
2920
|
-
},
|
|
2921
|
-
])
|
|
2922
|
-
);
|
|
2923
|
-
|
|
2924
|
-
const onError = vi.fn().mockResolvedValue({ retry: true });
|
|
2925
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
2926
|
-
maxRetryCount: 3,
|
|
2927
|
-
backoffConfig: { initialDelayMs: 1, maxDelayMs: 1, base: 2, jitter: false },
|
|
2928
|
-
});
|
|
2929
|
-
const events = await collectEvents(
|
|
2930
|
-
agent.runStream(createInput(), { onMessage: vi.fn(), onCheckpoint: vi.fn(), onError })
|
|
2931
|
-
);
|
|
2932
|
-
|
|
2933
|
-
expect(onError).toHaveBeenCalledOnce();
|
|
2934
|
-
expect(events.map((e) => e.type)).toEqual(['progress', 'error', 'progress', 'chunk', 'done']);
|
|
2935
|
-
});
|
|
2936
|
-
|
|
2937
|
-
it('retries retryable upstream errors by default when onError does not provide a decision', async () => {
|
|
2938
|
-
const provider = createProvider();
|
|
2939
|
-
const manager = createToolManager();
|
|
2940
|
-
provider.generateStream = vi
|
|
2941
|
-
.fn()
|
|
2942
|
-
.mockImplementationOnce(() =>
|
|
2943
|
-
(async function* () {
|
|
2944
|
-
for (const chunk of [] as Chunk[]) {
|
|
2945
|
-
yield chunk;
|
|
2946
|
-
}
|
|
2947
|
-
throw new LLMRetryableError(
|
|
2948
|
-
'500 Internal Server Error - 操作失败',
|
|
2949
|
-
undefined,
|
|
2950
|
-
'SERVER_500'
|
|
2951
|
-
);
|
|
2952
|
-
})()
|
|
2953
|
-
)
|
|
2954
|
-
.mockReturnValueOnce(
|
|
2955
|
-
toStream([
|
|
2956
|
-
{
|
|
2957
|
-
index: 0,
|
|
2958
|
-
choices: [{ index: 0, delta: { content: 'ok-after-retry' } }],
|
|
2959
|
-
},
|
|
2960
|
-
{
|
|
2961
|
-
index: 0,
|
|
2962
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
2963
|
-
},
|
|
2964
|
-
])
|
|
2965
|
-
);
|
|
2966
|
-
|
|
2967
|
-
const onError = vi.fn().mockResolvedValue(undefined);
|
|
2968
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
2969
|
-
maxRetryCount: 3,
|
|
2970
|
-
backoffConfig: { initialDelayMs: 1, maxDelayMs: 1, base: 2, jitter: false },
|
|
2971
|
-
});
|
|
2972
|
-
const events = await collectEvents(
|
|
2973
|
-
agent.runStream(createInput(), { onMessage: vi.fn(), onCheckpoint: vi.fn(), onError })
|
|
2974
|
-
);
|
|
2975
|
-
|
|
2976
|
-
expect(onError).toHaveBeenCalledOnce();
|
|
2977
|
-
expect(events.map((e) => e.type)).toEqual(['progress', 'error', 'progress', 'chunk', 'done']);
|
|
2978
|
-
expect(events[1]).toMatchObject({
|
|
2979
|
-
type: 'error',
|
|
2980
|
-
data: {
|
|
2981
|
-
name: 'AgentUpstreamServerError',
|
|
2982
|
-
errorCode: 'AGENT_UPSTREAM_SERVER',
|
|
2983
|
-
retryable: true,
|
|
2984
|
-
},
|
|
2985
|
-
});
|
|
2986
|
-
});
|
|
2987
|
-
|
|
2988
|
-
it('stops with max-retries when retryable upstream errors keep failing', async () => {
|
|
2989
|
-
const provider = createProvider();
|
|
2990
|
-
const manager = createToolManager();
|
|
2991
|
-
provider.generateStream = vi.fn().mockImplementation(() =>
|
|
2992
|
-
(async function* () {
|
|
2993
|
-
for (const chunk of [] as Chunk[]) {
|
|
2994
|
-
yield chunk;
|
|
2995
|
-
}
|
|
2996
|
-
throw new LLMRetryableError('upstream 500', undefined, 'SERVER_500');
|
|
2997
|
-
})()
|
|
2998
|
-
);
|
|
2999
|
-
|
|
3000
|
-
const onError = vi.fn().mockResolvedValue(undefined);
|
|
3001
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
3002
|
-
maxRetryCount: 2,
|
|
3003
|
-
backoffConfig: { initialDelayMs: 1, maxDelayMs: 1, base: 2, jitter: false },
|
|
3004
|
-
});
|
|
3005
|
-
const events = await collectEvents(
|
|
3006
|
-
agent.runStream(createInput(), { onMessage: vi.fn(), onCheckpoint: vi.fn(), onError })
|
|
3007
|
-
);
|
|
3008
|
-
|
|
3009
|
-
expect(onError).toHaveBeenCalledTimes(2);
|
|
3010
|
-
expect(provider.generateStream).toHaveBeenCalledTimes(2);
|
|
3011
|
-
expect(events.map((event) => event.type)).toEqual([
|
|
3012
|
-
'progress',
|
|
3013
|
-
'error',
|
|
3014
|
-
'progress',
|
|
3015
|
-
'error',
|
|
3016
|
-
'error',
|
|
3017
|
-
]);
|
|
3018
|
-
expect(events.at(-1)).toMatchObject({
|
|
3019
|
-
type: 'error',
|
|
3020
|
-
data: {
|
|
3021
|
-
name: 'MaxRetriesError',
|
|
3022
|
-
errorCode: 'AGENT_MAX_RETRIES_REACHED',
|
|
3023
|
-
},
|
|
3024
|
-
});
|
|
3025
|
-
});
|
|
3026
|
-
|
|
3027
|
-
it('stops immediately for non-retryable upstream errors when onError has no decision', async () => {
|
|
3028
|
-
const provider = createProvider();
|
|
3029
|
-
const manager = createToolManager();
|
|
3030
|
-
provider.generateStream = vi.fn().mockImplementation(() =>
|
|
3031
|
-
(async function* () {
|
|
3032
|
-
for (const chunk of [] as Chunk[]) {
|
|
3033
|
-
yield chunk;
|
|
3034
|
-
}
|
|
3035
|
-
throw new LLMAuthError('Invalid API key');
|
|
3036
|
-
})()
|
|
3037
|
-
);
|
|
3038
|
-
|
|
3039
|
-
const onError = vi.fn().mockResolvedValue(undefined);
|
|
3040
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
3041
|
-
const events = await collectEvents(
|
|
3042
|
-
agent.runStream(createInput(), { onMessage: vi.fn(), onCheckpoint: vi.fn(), onError })
|
|
3043
|
-
);
|
|
3044
|
-
|
|
3045
|
-
expect(onError).toHaveBeenCalledOnce();
|
|
3046
|
-
expect(provider.generateStream).toHaveBeenCalledTimes(1);
|
|
3047
|
-
expect(events.map((event) => event.type)).toEqual(['progress', 'error']);
|
|
3048
|
-
expect(events[1]).toMatchObject({
|
|
3049
|
-
type: 'error',
|
|
3050
|
-
data: {
|
|
3051
|
-
name: 'AgentUpstreamAuthError',
|
|
3052
|
-
errorCode: 'AGENT_UPSTREAM_AUTH',
|
|
3053
|
-
retryable: false,
|
|
3054
|
-
},
|
|
3055
|
-
});
|
|
3056
|
-
});
|
|
3057
|
-
|
|
3058
|
-
it('does not leak retry state across separate executions on the same instance', async () => {
|
|
3059
|
-
const provider = createProvider();
|
|
3060
|
-
const manager = createToolManager();
|
|
3061
|
-
provider.generateStream = vi
|
|
3062
|
-
.fn()
|
|
3063
|
-
.mockImplementationOnce(() =>
|
|
3064
|
-
(async function* () {
|
|
3065
|
-
for (const chunk of [] as Chunk[]) {
|
|
3066
|
-
yield chunk;
|
|
3067
|
-
}
|
|
3068
|
-
throw new Error('first run fail');
|
|
3069
|
-
})()
|
|
3070
|
-
)
|
|
3071
|
-
.mockReturnValueOnce(
|
|
3072
|
-
toStream([
|
|
3073
|
-
{
|
|
3074
|
-
index: 0,
|
|
3075
|
-
choices: [{ index: 0, delta: { content: 'second run ok' } }],
|
|
3076
|
-
},
|
|
3077
|
-
{
|
|
3078
|
-
index: 0,
|
|
3079
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
3080
|
-
},
|
|
3081
|
-
])
|
|
3082
|
-
);
|
|
3083
|
-
|
|
3084
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
3085
|
-
maxRetryCount: 1,
|
|
3086
|
-
backoffConfig: { initialDelayMs: 1, maxDelayMs: 1, base: 2, jitter: false },
|
|
3087
|
-
});
|
|
3088
|
-
const run1Events = await collectEvents(
|
|
3089
|
-
agent.runStream(createInput(), {
|
|
3090
|
-
onMessage: vi.fn(),
|
|
3091
|
-
onCheckpoint: vi.fn(),
|
|
3092
|
-
onError: async () => ({ retry: true }),
|
|
3093
|
-
})
|
|
3094
|
-
);
|
|
3095
|
-
const run2Events = await collectEvents(
|
|
3096
|
-
agent.runStream(createInput(), {
|
|
3097
|
-
onMessage: vi.fn(),
|
|
3098
|
-
onCheckpoint: vi.fn(),
|
|
3099
|
-
})
|
|
3100
|
-
);
|
|
3101
|
-
|
|
3102
|
-
expect(run1Events.map((event) => event.type)).toEqual(['progress', 'error', 'error']);
|
|
3103
|
-
expect(run2Events.at(-1)).toMatchObject({
|
|
3104
|
-
type: 'done',
|
|
3105
|
-
data: { finishReason: 'stop', steps: 1 },
|
|
3106
|
-
});
|
|
3107
|
-
expect(provider.generateStream).toHaveBeenCalledTimes(2);
|
|
3108
|
-
});
|
|
3109
|
-
|
|
3110
|
-
it('stops when local retry attempts reach maxRetryCount', async () => {
|
|
3111
|
-
const provider = createProvider();
|
|
3112
|
-
const manager = createToolManager();
|
|
3113
|
-
provider.generateStream = vi.fn().mockImplementation(() =>
|
|
3114
|
-
(async function* () {
|
|
3115
|
-
for (const chunk of [] as Chunk[]) {
|
|
3116
|
-
yield chunk;
|
|
3117
|
-
}
|
|
3118
|
-
throw new Error('always fail');
|
|
3119
|
-
})()
|
|
3120
|
-
);
|
|
3121
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 1 });
|
|
3122
|
-
const events = await collectEvents(
|
|
3123
|
-
agent.runStream(createInput(), {
|
|
3124
|
-
onMessage: vi.fn(),
|
|
3125
|
-
onCheckpoint: vi.fn(),
|
|
3126
|
-
onError: async () => ({ retry: true }),
|
|
3127
|
-
})
|
|
3128
|
-
);
|
|
3129
|
-
|
|
3130
|
-
expect(events.map((event) => event.type)).toEqual(['progress', 'error', 'error']);
|
|
3131
|
-
expect(events.at(-1)).toMatchObject({
|
|
3132
|
-
type: 'error',
|
|
3133
|
-
data: { message: 'Max retries reached' },
|
|
3134
|
-
});
|
|
3135
|
-
expect(provider.generateStream).toHaveBeenCalledTimes(1);
|
|
3136
|
-
});
|
|
3137
|
-
|
|
3138
|
-
it('waits for backoff delay before retrying llm call', async () => {
|
|
3139
|
-
vi.useFakeTimers();
|
|
3140
|
-
const provider = createProvider();
|
|
3141
|
-
const manager = createToolManager();
|
|
3142
|
-
provider.generateStream = vi
|
|
3143
|
-
.fn()
|
|
3144
|
-
.mockImplementationOnce(() =>
|
|
3145
|
-
(async function* () {
|
|
3146
|
-
for (const chunk of [] as Chunk[]) {
|
|
3147
|
-
yield chunk;
|
|
3148
|
-
}
|
|
3149
|
-
throw new Error('temporary');
|
|
3150
|
-
})()
|
|
3151
|
-
)
|
|
3152
|
-
.mockReturnValueOnce(
|
|
3153
|
-
toStream([
|
|
3154
|
-
{
|
|
3155
|
-
index: 0,
|
|
3156
|
-
choices: [{ index: 0, delta: { content: 'ok' } }],
|
|
3157
|
-
},
|
|
3158
|
-
{
|
|
3159
|
-
index: 0,
|
|
3160
|
-
choices: [{ index: 0, delta: { finish_reason: 'stop' } as unknown as ChunkDelta }],
|
|
3161
|
-
},
|
|
3162
|
-
])
|
|
3163
|
-
);
|
|
3164
|
-
|
|
3165
|
-
const onError = vi.fn().mockResolvedValue({ retry: true });
|
|
3166
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
3167
|
-
maxRetryCount: 3,
|
|
3168
|
-
backoffConfig: { initialDelayMs: 20, maxDelayMs: 20, base: 2, jitter: false },
|
|
3169
|
-
});
|
|
3170
|
-
|
|
3171
|
-
const eventsPromise = collectEvents(
|
|
3172
|
-
agent.runStream(createInput(), { onMessage: vi.fn(), onCheckpoint: vi.fn(), onError })
|
|
3173
|
-
);
|
|
3174
|
-
|
|
3175
|
-
await vi.advanceTimersByTimeAsync(19);
|
|
3176
|
-
expect(provider.generateStream).toHaveBeenCalledTimes(1);
|
|
3177
|
-
|
|
3178
|
-
await vi.advanceTimersByTimeAsync(1);
|
|
3179
|
-
const events = await eventsPromise;
|
|
3180
|
-
|
|
3181
|
-
expect(provider.generateStream).toHaveBeenCalledTimes(2);
|
|
3182
|
-
expect(events.map((e) => e.type)).toEqual(['progress', 'error', 'progress', 'chunk', 'done']);
|
|
3183
|
-
});
|
|
3184
|
-
|
|
3185
|
-
it('yields aborted error when llm stream throws AbortError', async () => {
|
|
3186
|
-
const provider = createProvider();
|
|
3187
|
-
const manager = createToolManager();
|
|
3188
|
-
provider.generateStream = vi.fn().mockImplementation(() =>
|
|
3189
|
-
(async function* () {
|
|
3190
|
-
for (const chunk of [] as Chunk[]) {
|
|
3191
|
-
yield chunk;
|
|
3192
|
-
}
|
|
3193
|
-
throw Object.assign(new Error('Operation aborted'), { name: 'AbortError' });
|
|
3194
|
-
})()
|
|
3195
|
-
);
|
|
3196
|
-
|
|
3197
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
3198
|
-
const events = await collectEvents(
|
|
3199
|
-
agent.runStream(createInput(), {
|
|
3200
|
-
onMessage: vi.fn(),
|
|
3201
|
-
onCheckpoint: vi.fn(),
|
|
3202
|
-
onError: vi.fn(),
|
|
3203
|
-
})
|
|
3204
|
-
);
|
|
3205
|
-
|
|
3206
|
-
expect(events.map((event) => event.type)).toEqual(['progress', 'error']);
|
|
3207
|
-
expect(events[1]).toMatchObject({
|
|
3208
|
-
type: 'error',
|
|
3209
|
-
data: { name: 'AgentAbortedError', message: 'Operation aborted' },
|
|
3210
|
-
});
|
|
3211
|
-
});
|
|
3212
|
-
|
|
3213
|
-
it('stops retry sleep with aborted error when signal aborts during backoff', async () => {
|
|
3214
|
-
vi.useFakeTimers();
|
|
3215
|
-
const provider = createProvider();
|
|
3216
|
-
const manager = createToolManager();
|
|
3217
|
-
provider.generateStream = vi.fn().mockImplementation(() =>
|
|
3218
|
-
(async function* () {
|
|
3219
|
-
for (const chunk of [] as Chunk[]) {
|
|
3220
|
-
yield chunk;
|
|
3221
|
-
}
|
|
3222
|
-
throw new Error('temporary');
|
|
3223
|
-
})()
|
|
3224
|
-
);
|
|
3225
|
-
|
|
3226
|
-
const controller = new AbortController();
|
|
3227
|
-
const agent = new StatelessAgent(provider, manager, {
|
|
3228
|
-
maxRetryCount: 3,
|
|
3229
|
-
backoffConfig: { initialDelayMs: 100, maxDelayMs: 100, base: 2, jitter: false },
|
|
3230
|
-
});
|
|
3231
|
-
|
|
3232
|
-
const eventsPromise = collectEvents(
|
|
3233
|
-
agent.runStream(
|
|
3234
|
-
{ ...createInput(), abortSignal: controller.signal },
|
|
3235
|
-
{ onMessage: vi.fn(), onCheckpoint: vi.fn(), onError: async () => ({ retry: true }) }
|
|
3236
|
-
)
|
|
3237
|
-
);
|
|
3238
|
-
|
|
3239
|
-
await vi.advanceTimersByTimeAsync(1);
|
|
3240
|
-
controller.abort();
|
|
3241
|
-
const events = await eventsPromise;
|
|
3242
|
-
|
|
3243
|
-
expect(events.at(-1)).toMatchObject({
|
|
3244
|
-
type: 'error',
|
|
3245
|
-
data: { name: 'AgentAbortedError', message: 'Operation aborted' },
|
|
3246
|
-
});
|
|
3247
|
-
});
|
|
3248
|
-
|
|
3249
|
-
it('rethrows non-abort sleep errors during retry delay', async () => {
|
|
3250
|
-
const provider = createProvider();
|
|
3251
|
-
const manager = createToolManager();
|
|
3252
|
-
provider.generateStream = vi.fn().mockImplementation(() =>
|
|
3253
|
-
(async function* () {
|
|
3254
|
-
for (const chunk of [] as Chunk[]) {
|
|
3255
|
-
yield chunk;
|
|
3256
|
-
}
|
|
3257
|
-
throw new Error('temporary');
|
|
3258
|
-
})()
|
|
3259
|
-
);
|
|
3260
|
-
|
|
3261
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 3 });
|
|
3262
|
-
(agent as unknown as { sleep: (ms: number, signal?: AbortSignal) => Promise<void> }).sleep = vi
|
|
3263
|
-
.fn()
|
|
3264
|
-
.mockRejectedValue(new Error('sleep crash'));
|
|
3265
|
-
|
|
3266
|
-
await expect(
|
|
3267
|
-
collectEvents(
|
|
3268
|
-
agent.runStream(createInput(), {
|
|
3269
|
-
onMessage: vi.fn(),
|
|
3270
|
-
onCheckpoint: vi.fn(),
|
|
3271
|
-
onError: async () => ({ retry: true }),
|
|
3272
|
-
})
|
|
3273
|
-
)
|
|
3274
|
-
).rejects.toThrow('sleep crash');
|
|
3275
|
-
});
|
|
3276
|
-
|
|
3277
|
-
it('private helpers handle callback errors and message conversion', async () => {
|
|
3278
|
-
const provider = createProvider();
|
|
3279
|
-
const manager = createToolManager();
|
|
3280
|
-
const logger = { error: vi.fn() };
|
|
3281
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 2, logger });
|
|
3282
|
-
const defaultConfigAgent = new StatelessAgent(provider, manager, {});
|
|
3283
|
-
const agentPrivate = agent as unknown as AgentPrivate;
|
|
3284
|
-
|
|
3285
|
-
const message: Message = {
|
|
3286
|
-
messageId: 'm1',
|
|
3287
|
-
type: 'assistant-text',
|
|
3288
|
-
id: 'legacy-id',
|
|
3289
|
-
role: 'assistant',
|
|
3290
|
-
content: 'text',
|
|
3291
|
-
reasoning_content: 'reason',
|
|
3292
|
-
tool_call_id: 'tool-1',
|
|
3293
|
-
tool_calls: [
|
|
3294
|
-
{
|
|
3295
|
-
id: 'tool-1',
|
|
3296
|
-
type: 'function',
|
|
3297
|
-
index: 0,
|
|
3298
|
-
function: { name: 'bash', arguments: '{}' },
|
|
3299
|
-
},
|
|
3300
|
-
],
|
|
3301
|
-
timestamp: 1,
|
|
3302
|
-
};
|
|
3303
|
-
|
|
3304
|
-
const llmMessage = agentPrivate.convertMessageToLLMMessage(message);
|
|
3305
|
-
expect(llmMessage).toMatchObject({
|
|
3306
|
-
role: 'assistant',
|
|
3307
|
-
content: 'text',
|
|
3308
|
-
id: 'legacy-id',
|
|
3309
|
-
reasoning_content: 'reason',
|
|
3310
|
-
tool_call_id: 'tool-1',
|
|
3311
|
-
tool_calls: [
|
|
3312
|
-
{
|
|
3313
|
-
id: 'tool-1',
|
|
3314
|
-
function: {
|
|
3315
|
-
arguments: '{}',
|
|
3316
|
-
},
|
|
3317
|
-
},
|
|
3318
|
-
],
|
|
3319
|
-
});
|
|
3320
|
-
|
|
3321
|
-
const invalidToolArgsMessage: Message = {
|
|
3322
|
-
...message,
|
|
3323
|
-
tool_calls: [
|
|
3324
|
-
{
|
|
3325
|
-
id: 'tool-invalid',
|
|
3326
|
-
type: 'function',
|
|
3327
|
-
index: 0,
|
|
3328
|
-
function: { name: 'glob', arguments: '' },
|
|
3329
|
-
},
|
|
3330
|
-
],
|
|
3331
|
-
};
|
|
3332
|
-
const sanitizedLlmMessage = agentPrivate.convertMessageToLLMMessage(invalidToolArgsMessage) as {
|
|
3333
|
-
tool_calls?: ToolCall[];
|
|
3334
|
-
};
|
|
3335
|
-
expect(sanitizedLlmMessage.tool_calls?.[0]?.function.arguments).toBe('{}');
|
|
3336
|
-
expect(invalidToolArgsMessage.tool_calls?.[0]?.function.arguments).toBe('');
|
|
3337
|
-
|
|
3338
|
-
await agentPrivate.safeCallback(async () => {
|
|
3339
|
-
throw new Error('callback failed');
|
|
3340
|
-
}, 'x');
|
|
3341
|
-
await agentPrivate.safeCallback(undefined, 'x');
|
|
3342
|
-
|
|
3343
|
-
const okDecision = await agentPrivate.safeErrorCallback(
|
|
3344
|
-
(err) => ({ retry: err.message === 'e1' }),
|
|
3345
|
-
new Error('e1')
|
|
3346
|
-
);
|
|
3347
|
-
expect(okDecision).toEqual({ retry: true });
|
|
3348
|
-
|
|
3349
|
-
const undefinedDecision = await agentPrivate.safeErrorCallback(
|
|
3350
|
-
undefined,
|
|
3351
|
-
new Error('no callback')
|
|
3352
|
-
);
|
|
3353
|
-
expect(undefinedDecision).toBeUndefined();
|
|
3354
|
-
|
|
3355
|
-
const errorDecision = await agentPrivate.safeErrorCallback(() => {
|
|
3356
|
-
throw new Error('error callback failed');
|
|
3357
|
-
}, new Error('e2'));
|
|
3358
|
-
expect(errorDecision).toBeUndefined();
|
|
3359
|
-
|
|
3360
|
-
const merged = await agentPrivate.mergeToolCalls(
|
|
3361
|
-
[{ id: 'x', function: { arguments: '{"a":' } }],
|
|
3362
|
-
[
|
|
3363
|
-
{ id: 'x', function: { arguments: '1}' } },
|
|
3364
|
-
{ id: 'y', function: { arguments: '{}' } },
|
|
3365
|
-
],
|
|
3366
|
-
'msg_test'
|
|
3367
|
-
);
|
|
3368
|
-
expect(merged).toEqual([
|
|
3369
|
-
{ id: 'x', function: { arguments: '{"a":1}' } },
|
|
3370
|
-
{ id: 'y', function: { arguments: '{}' } },
|
|
3371
|
-
]);
|
|
3372
|
-
|
|
3373
|
-
const checkpointEvents = await collectEvents(
|
|
3374
|
-
agentPrivate.yieldCheckpoint(undefined, 3, undefined, {
|
|
3375
|
-
onCheckpoint: () => {
|
|
3376
|
-
throw new Error('checkpoint failed');
|
|
3377
|
-
},
|
|
3378
|
-
} as { onCheckpoint: (cp: unknown) => void })
|
|
3379
|
-
);
|
|
3380
|
-
expect(checkpointEvents[0]).toMatchObject({
|
|
3381
|
-
type: 'checkpoint',
|
|
3382
|
-
data: {
|
|
3383
|
-
executionId: '',
|
|
3384
|
-
stepIndex: 3,
|
|
3385
|
-
lastMessageId: '',
|
|
3386
|
-
canResume: true,
|
|
3387
|
-
},
|
|
3388
|
-
});
|
|
3389
|
-
expect(defaultConfigAgent).toBeDefined();
|
|
3390
|
-
|
|
3391
|
-
expect(logger.error).toHaveBeenCalled();
|
|
3392
|
-
});
|
|
3393
|
-
|
|
3394
|
-
it('sleep rejects when signal is already aborted', async () => {
|
|
3395
|
-
const provider = createProvider();
|
|
3396
|
-
const manager = createToolManager();
|
|
3397
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 2 });
|
|
3398
|
-
const agentPrivate = agent as unknown as AgentPrivate;
|
|
3399
|
-
const controller = new AbortController();
|
|
3400
|
-
controller.abort();
|
|
3401
|
-
|
|
3402
|
-
await expect(agentPrivate.sleep(20, controller.signal)).rejects.toMatchObject({
|
|
3403
|
-
name: 'AbortError',
|
|
3404
|
-
message: 'Operation aborted',
|
|
3405
|
-
});
|
|
3406
|
-
});
|
|
3407
|
-
|
|
3408
|
-
it('sleep rejects when signal aborts during waiting', async () => {
|
|
3409
|
-
vi.useFakeTimers();
|
|
3410
|
-
const provider = createProvider();
|
|
3411
|
-
const manager = createToolManager();
|
|
3412
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 2 });
|
|
3413
|
-
const agentPrivate = agent as unknown as AgentPrivate;
|
|
3414
|
-
const controller = new AbortController();
|
|
3415
|
-
|
|
3416
|
-
const sleepPromise = agentPrivate.sleep(100, controller.signal);
|
|
3417
|
-
await vi.advanceTimersByTimeAsync(10);
|
|
3418
|
-
controller.abort();
|
|
3419
|
-
|
|
3420
|
-
await expect(sleepPromise).rejects.toMatchObject({
|
|
3421
|
-
name: 'AbortError',
|
|
3422
|
-
message: 'Operation aborted',
|
|
3423
|
-
});
|
|
3424
|
-
});
|
|
3425
|
-
|
|
3426
|
-
it('sleep resolves immediately when delay is non-positive', async () => {
|
|
3427
|
-
const provider = createProvider();
|
|
3428
|
-
const manager = createToolManager();
|
|
3429
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 2 });
|
|
3430
|
-
const agentPrivate = agent as unknown as AgentPrivate;
|
|
3431
|
-
|
|
3432
|
-
await expect(agentPrivate.sleep(0)).resolves.toBeUndefined();
|
|
3433
|
-
await expect(agentPrivate.sleep(-1)).resolves.toBeUndefined();
|
|
3434
|
-
});
|
|
3435
|
-
|
|
3436
|
-
it('normalizeError maps non-Error values to UnknownError default', () => {
|
|
3437
|
-
const provider = createProvider();
|
|
3438
|
-
const manager = createToolManager();
|
|
3439
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 2 });
|
|
3440
|
-
const agentPrivate = agent as unknown as AgentPrivate;
|
|
3441
|
-
|
|
3442
|
-
const normalized = agentPrivate.normalizeError('plain string error');
|
|
3443
|
-
expect(normalized).toBeInstanceOf(AgentError);
|
|
3444
|
-
expect(normalized).toMatchObject({
|
|
3445
|
-
name: 'UnknownError',
|
|
3446
|
-
message: 'Unknown error',
|
|
3447
|
-
});
|
|
3448
|
-
});
|
|
3449
|
-
|
|
3450
|
-
it('normalizeError maps abort-like and confirmation-timeout errors', () => {
|
|
3451
|
-
const provider = createProvider();
|
|
3452
|
-
const manager = createToolManager();
|
|
3453
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 2 });
|
|
3454
|
-
const agentPrivate = agent as unknown as AgentPrivate;
|
|
3455
|
-
|
|
3456
|
-
const abortNormalized = agentPrivate.normalizeError({
|
|
3457
|
-
name: 'AbortError',
|
|
3458
|
-
message: 'Operation aborted',
|
|
3459
|
-
});
|
|
3460
|
-
expect(abortNormalized).toMatchObject({
|
|
3461
|
-
name: 'AgentAbortedError',
|
|
3462
|
-
message: 'Operation aborted',
|
|
3463
|
-
});
|
|
3464
|
-
|
|
3465
|
-
const timeoutNormalized = agentPrivate.normalizeError(
|
|
3466
|
-
Object.assign(new Error('Confirmation timeout'), { name: 'ConfirmationTimeoutError' })
|
|
3467
|
-
);
|
|
3468
|
-
expect(timeoutNormalized).toMatchObject({
|
|
3469
|
-
name: 'ConfirmationTimeoutError',
|
|
3470
|
-
message: 'Confirmation timeout',
|
|
3471
|
-
});
|
|
3472
|
-
});
|
|
3473
|
-
|
|
3474
|
-
it('normalizeError maps provider error types from providers/errors.ts', () => {
|
|
3475
|
-
const provider = createProvider();
|
|
3476
|
-
const manager = createToolManager();
|
|
3477
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 2 });
|
|
3478
|
-
const agentPrivate = agent as unknown as AgentPrivate;
|
|
3479
|
-
|
|
3480
|
-
expect(agentPrivate.normalizeError(new LLMRateLimitError('rate limited'))).toMatchObject({
|
|
3481
|
-
name: 'AgentUpstreamRateLimitError',
|
|
3482
|
-
errorCode: 'AGENT_UPSTREAM_RATE_LIMIT',
|
|
3483
|
-
retryable: true,
|
|
3484
|
-
});
|
|
3485
|
-
expect(
|
|
3486
|
-
agentPrivate.normalizeError(new LLMRetryableError('timeout', undefined, 'TIMEOUT'))
|
|
3487
|
-
).toMatchObject({
|
|
3488
|
-
name: 'AgentUpstreamTimeoutError',
|
|
3489
|
-
errorCode: 'AGENT_UPSTREAM_TIMEOUT',
|
|
3490
|
-
retryable: true,
|
|
3491
|
-
});
|
|
3492
|
-
expect(
|
|
3493
|
-
agentPrivate.normalizeError(new LLMRetryableError('network', undefined, 'NETWORK_ERROR'))
|
|
3494
|
-
).toMatchObject({
|
|
3495
|
-
name: 'AgentUpstreamNetworkError',
|
|
3496
|
-
errorCode: 'AGENT_UPSTREAM_NETWORK',
|
|
3497
|
-
retryable: true,
|
|
3498
|
-
});
|
|
3499
|
-
expect(
|
|
3500
|
-
agentPrivate.normalizeError(new LLMRetryableError('server error', undefined, 'SERVER_503'))
|
|
3501
|
-
).toMatchObject({
|
|
3502
|
-
name: 'AgentUpstreamServerError',
|
|
3503
|
-
errorCode: 'AGENT_UPSTREAM_SERVER',
|
|
3504
|
-
retryable: true,
|
|
3505
|
-
});
|
|
3506
|
-
expect(agentPrivate.normalizeError(new LLMAuthError('bad key'))).toMatchObject({
|
|
3507
|
-
name: 'AgentUpstreamAuthError',
|
|
3508
|
-
errorCode: 'AGENT_UPSTREAM_AUTH',
|
|
3509
|
-
retryable: false,
|
|
3510
|
-
});
|
|
3511
|
-
expect(agentPrivate.normalizeError(new LLMNotFoundError('missing'))).toMatchObject({
|
|
3512
|
-
name: 'AgentUpstreamNotFoundError',
|
|
3513
|
-
errorCode: 'AGENT_UPSTREAM_NOT_FOUND',
|
|
3514
|
-
retryable: false,
|
|
3515
|
-
});
|
|
3516
|
-
expect(agentPrivate.normalizeError(new LLMBadRequestError('invalid'))).toMatchObject({
|
|
3517
|
-
name: 'AgentUpstreamBadRequestError',
|
|
3518
|
-
errorCode: 'AGENT_UPSTREAM_BAD_REQUEST',
|
|
3519
|
-
retryable: false,
|
|
3520
|
-
});
|
|
3521
|
-
expect(agentPrivate.normalizeError(new LLMPermanentError('blocked', 501))).toMatchObject({
|
|
3522
|
-
name: 'AgentUpstreamPermanentError',
|
|
3523
|
-
errorCode: 'AGENT_UPSTREAM_PERMANENT',
|
|
3524
|
-
retryable: false,
|
|
3525
|
-
});
|
|
3526
|
-
expect(agentPrivate.normalizeError(new LLMError('provider boom', 'HTTP_418'))).toMatchObject({
|
|
3527
|
-
name: 'AgentUpstreamError',
|
|
3528
|
-
errorCode: 'AGENT_UPSTREAM_ERROR',
|
|
3529
|
-
retryable: false,
|
|
3530
|
-
});
|
|
3531
|
-
expect(
|
|
3532
|
-
agentPrivate.normalizeError(new LLMRetryableError('provider retry', undefined, 'TRANSIENT_X'))
|
|
3533
|
-
).toMatchObject({
|
|
3534
|
-
name: 'AgentUpstreamRetryableError',
|
|
3535
|
-
errorCode: 'AGENT_UPSTREAM_RETRYABLE',
|
|
3536
|
-
retryable: true,
|
|
3537
|
-
});
|
|
3538
|
-
});
|
|
3539
|
-
|
|
3540
|
-
it('throwIfAborted throws AbortError and normalizeError returns AgentError as-is', () => {
|
|
3541
|
-
const provider = createProvider();
|
|
3542
|
-
const manager = createToolManager();
|
|
3543
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 2 });
|
|
3544
|
-
const agentPrivate = agent as unknown as AgentPrivate;
|
|
3545
|
-
|
|
3546
|
-
const controller = new AbortController();
|
|
3547
|
-
controller.abort();
|
|
3548
|
-
expect(() => agentPrivate.throwIfAborted(controller.signal)).toThrowError('Operation aborted');
|
|
3549
|
-
|
|
3550
|
-
const existing = new AgentError('custom', 1999);
|
|
3551
|
-
const normalized = agentPrivate.normalizeError(existing);
|
|
3552
|
-
expect(normalized).toBe(existing);
|
|
3553
|
-
});
|
|
3554
|
-
|
|
3555
|
-
it('runWithConcurrencyAndLock rejects on task failure and keeps settled guard path safe', async () => {
|
|
3556
|
-
vi.useFakeTimers();
|
|
3557
|
-
const provider = createProvider();
|
|
3558
|
-
const manager = createToolManager();
|
|
3559
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 2 });
|
|
3560
|
-
const agentPrivate = agent as unknown as AgentPrivate;
|
|
3561
|
-
|
|
3562
|
-
const tasks = [
|
|
3563
|
-
{
|
|
3564
|
-
run: async () => {
|
|
3565
|
-
throw new Error('parallel boom');
|
|
3566
|
-
},
|
|
3567
|
-
},
|
|
3568
|
-
{
|
|
3569
|
-
run: async () => {
|
|
3570
|
-
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
3571
|
-
return 'late';
|
|
3572
|
-
},
|
|
3573
|
-
},
|
|
3574
|
-
];
|
|
3575
|
-
|
|
3576
|
-
const promise = agentPrivate.runWithConcurrencyAndLock(tasks, 2);
|
|
3577
|
-
await expect(promise).rejects.toThrow('parallel boom');
|
|
3578
|
-
await vi.advanceTimersByTimeAsync(10);
|
|
3579
|
-
});
|
|
3580
|
-
|
|
3581
|
-
it('runWithConcurrencyAndLock returns empty result for empty tasks', async () => {
|
|
3582
|
-
const provider = createProvider();
|
|
3583
|
-
const manager = createToolManager();
|
|
3584
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 2 });
|
|
3585
|
-
const agentPrivate = agent as unknown as AgentPrivate;
|
|
3586
|
-
|
|
3587
|
-
const result = await agentPrivate.runWithConcurrencyAndLock([], 2);
|
|
3588
|
-
expect(result).toEqual([]);
|
|
3589
|
-
});
|
|
3590
|
-
|
|
3591
|
-
it('resolveToolConcurrencyPolicy falls back to exclusive when manager has no policy method', () => {
|
|
3592
|
-
const provider = createProvider();
|
|
3593
|
-
const manager = createToolManager();
|
|
3594
|
-
(manager as unknown as { getConcurrencyPolicy?: unknown }).getConcurrencyPolicy = undefined;
|
|
3595
|
-
const agent = new StatelessAgent(provider, manager, { maxRetryCount: 2 });
|
|
3596
|
-
const agentPrivate = agent as unknown as AgentPrivate;
|
|
3597
|
-
|
|
3598
|
-
const policy = agentPrivate.resolveToolConcurrencyPolicy({
|
|
3599
|
-
id: 'no_policy_tool',
|
|
3600
|
-
type: 'function',
|
|
3601
|
-
index: 0,
|
|
3602
|
-
function: { name: 'bash', arguments: '{}' },
|
|
3603
|
-
});
|
|
3604
|
-
|
|
3605
|
-
expect(policy).toEqual({ mode: 'exclusive' });
|
|
3606
|
-
});
|
|
3607
|
-
});
|