@swarmclawai/swarmclaw 0.7.1 → 0.7.3

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.
Files changed (237) hide show
  1. package/README.md +155 -150
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +26 -0
  4. package/src/app/api/agents/[id]/thread/route.ts +37 -9
  5. package/src/app/api/agents/route.ts +13 -2
  6. package/src/app/api/auth/route.ts +76 -7
  7. package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
  8. package/src/app/api/{sessions → chats}/[id]/browser/route.ts +5 -1
  9. package/src/app/api/{sessions → chats}/[id]/chat/route.ts +7 -3
  10. package/src/app/api/{sessions → chats}/[id]/checkpoints/route.ts +1 -1
  11. package/src/app/api/chats/[id]/main-loop/route.ts +13 -0
  12. package/src/app/api/{sessions → chats}/[id]/messages/route.ts +19 -13
  13. package/src/app/api/{sessions → chats}/[id]/restore/route.ts +1 -1
  14. package/src/app/api/{sessions → chats}/[id]/route.ts +22 -52
  15. package/src/app/api/{sessions → chats}/[id]/stop/route.ts +6 -1
  16. package/src/app/api/{sessions → chats}/route.ts +21 -7
  17. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  18. package/src/app/api/connectors/doctor/route.ts +13 -0
  19. package/src/app/api/files/open/route.ts +16 -14
  20. package/src/app/api/memory/maintenance/route.ts +11 -1
  21. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  22. package/src/app/api/openclaw/skills/route.ts +11 -3
  23. package/src/app/api/plugins/dependencies/route.ts +24 -0
  24. package/src/app/api/plugins/install/route.ts +15 -92
  25. package/src/app/api/plugins/route.ts +6 -26
  26. package/src/app/api/plugins/settings/route.ts +40 -0
  27. package/src/app/api/plugins/ui/route.ts +1 -0
  28. package/src/app/api/settings/route.ts +49 -7
  29. package/src/app/api/tasks/[id]/route.ts +15 -6
  30. package/src/app/api/tasks/bulk/route.ts +2 -2
  31. package/src/app/api/tasks/route.ts +9 -4
  32. package/src/app/api/usage/route.ts +30 -0
  33. package/src/app/api/webhooks/[id]/route.ts +8 -1
  34. package/src/app/page.tsx +9 -2
  35. package/src/cli/index.js +39 -33
  36. package/src/cli/index.ts +43 -49
  37. package/src/cli/spec.js +29 -27
  38. package/src/components/agents/agent-card.tsx +16 -13
  39. package/src/components/agents/agent-chat-list.tsx +104 -4
  40. package/src/components/agents/agent-list.tsx +54 -22
  41. package/src/components/agents/agent-sheet.tsx +209 -18
  42. package/src/components/agents/cron-job-form.tsx +3 -3
  43. package/src/components/agents/inspector-panel.tsx +110 -50
  44. package/src/components/auth/access-key-gate.tsx +36 -97
  45. package/src/components/auth/setup-wizard.tsx +5 -38
  46. package/src/components/chat/chat-area.tsx +39 -27
  47. package/src/components/{sessions/session-card.tsx → chat/chat-card.tsx} +7 -23
  48. package/src/components/chat/chat-header.tsx +299 -314
  49. package/src/components/{sessions/session-list.tsx → chat/chat-list.tsx} +11 -14
  50. package/src/components/chat/chat-tool-toggles.tsx +26 -17
  51. package/src/components/chat/checkpoint-timeline.tsx +4 -4
  52. package/src/components/chat/message-bubble.tsx +4 -1
  53. package/src/components/chat/message-list.tsx +5 -3
  54. package/src/components/chat/session-debug-panel.tsx +1 -1
  55. package/src/components/chat/tool-request-banner.tsx +3 -3
  56. package/src/components/chatrooms/agent-hover-card.tsx +3 -3
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +2 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +347 -205
  59. package/src/components/connectors/connector-list.tsx +265 -127
  60. package/src/components/connectors/connector-sheet.tsx +218 -1
  61. package/src/components/home/home-view.tsx +129 -5
  62. package/src/components/layout/app-layout.tsx +392 -182
  63. package/src/components/layout/mobile-header.tsx +26 -8
  64. package/src/components/plugins/plugin-list.tsx +487 -254
  65. package/src/components/plugins/plugin-sheet.tsx +236 -13
  66. package/src/components/projects/project-detail.tsx +183 -0
  67. package/src/components/settings/gateway-connection-panel.tsx +1 -1
  68. package/src/components/shared/agent-picker-list.tsx +2 -2
  69. package/src/components/shared/command-palette.tsx +111 -25
  70. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  71. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  72. package/src/components/shared/settings/section-heartbeat.tsx +78 -1
  73. package/src/components/shared/settings/section-orchestrator.tsx +3 -3
  74. package/src/components/shared/settings/section-providers.tsx +1 -1
  75. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  76. package/src/components/shared/settings/section-secrets.tsx +6 -6
  77. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  78. package/src/components/shared/settings/section-voice.tsx +5 -1
  79. package/src/components/shared/settings/section-web-search.tsx +10 -2
  80. package/src/components/shared/settings/settings-page.tsx +244 -56
  81. package/src/components/tasks/approvals-panel.tsx +205 -18
  82. package/src/components/tasks/task-board.tsx +242 -46
  83. package/src/components/usage/metrics-dashboard.tsx +147 -1
  84. package/src/components/wallets/wallet-panel.tsx +17 -5
  85. package/src/components/webhooks/webhook-sheet.tsx +8 -8
  86. package/src/lib/auth.ts +17 -0
  87. package/src/lib/chat-streaming-state.test.ts +108 -0
  88. package/src/lib/chat-streaming-state.ts +108 -0
  89. package/src/lib/chat.ts +1 -1
  90. package/src/lib/{sessions.ts → chats.ts} +28 -18
  91. package/src/lib/openclaw-agent-id.test.ts +14 -0
  92. package/src/lib/openclaw-agent-id.ts +31 -0
  93. package/src/lib/providers/claude-cli.ts +1 -1
  94. package/src/lib/server/agent-assignment.test.ts +112 -0
  95. package/src/lib/server/agent-assignment.ts +169 -0
  96. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  97. package/src/lib/server/approvals-auto-approve.test.ts +205 -0
  98. package/src/lib/server/approvals.ts +483 -75
  99. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  100. package/src/lib/server/browser-state.test.ts +118 -0
  101. package/src/lib/server/browser-state.ts +123 -0
  102. package/src/lib/server/build-llm.test.ts +36 -0
  103. package/src/lib/server/build-llm.ts +11 -4
  104. package/src/lib/server/builtin-plugins.ts +34 -0
  105. package/src/lib/server/capability-router.ts +10 -8
  106. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  107. package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
  108. package/src/lib/server/chat-execution.ts +285 -165
  109. package/src/lib/server/chatroom-health.test.ts +26 -0
  110. package/src/lib/server/chatroom-health.ts +2 -3
  111. package/src/lib/server/chatroom-helpers.test.ts +67 -2
  112. package/src/lib/server/chatroom-helpers.ts +48 -8
  113. package/src/lib/server/connectors/discord.ts +175 -11
  114. package/src/lib/server/connectors/doctor.test.ts +80 -0
  115. package/src/lib/server/connectors/doctor.ts +116 -0
  116. package/src/lib/server/connectors/manager.ts +948 -112
  117. package/src/lib/server/connectors/policy.test.ts +222 -0
  118. package/src/lib/server/connectors/policy.ts +452 -0
  119. package/src/lib/server/connectors/slack.ts +188 -9
  120. package/src/lib/server/connectors/telegram.ts +65 -15
  121. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  122. package/src/lib/server/connectors/thread-context.ts +72 -0
  123. package/src/lib/server/connectors/types.ts +41 -11
  124. package/src/lib/server/cost.ts +34 -1
  125. package/src/lib/server/daemon-state.ts +61 -3
  126. package/src/lib/server/data-dir.ts +13 -0
  127. package/src/lib/server/delegation-jobs.test.ts +140 -0
  128. package/src/lib/server/delegation-jobs.ts +248 -0
  129. package/src/lib/server/document-utils.test.ts +47 -0
  130. package/src/lib/server/document-utils.ts +397 -0
  131. package/src/lib/server/heartbeat-service.ts +14 -40
  132. package/src/lib/server/heartbeat-source.test.ts +22 -0
  133. package/src/lib/server/heartbeat-source.ts +7 -0
  134. package/src/lib/server/identity-continuity.test.ts +77 -0
  135. package/src/lib/server/identity-continuity.ts +127 -0
  136. package/src/lib/server/mailbox-utils.ts +347 -0
  137. package/src/lib/server/main-agent-loop.ts +28 -1103
  138. package/src/lib/server/memory-db.ts +4 -6
  139. package/src/lib/server/memory-tiers.ts +40 -0
  140. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  141. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  142. package/src/lib/server/openclaw-exec-config.ts +5 -6
  143. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  144. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  145. package/src/lib/server/openclaw-sync.ts +3 -2
  146. package/src/lib/server/orchestrator-lg.ts +20 -9
  147. package/src/lib/server/orchestrator.ts +7 -7
  148. package/src/lib/server/playwright-proxy.mjs +27 -3
  149. package/src/lib/server/plugins.test.ts +207 -0
  150. package/src/lib/server/plugins.ts +927 -66
  151. package/src/lib/server/provider-health.ts +38 -6
  152. package/src/lib/server/queue.ts +13 -28
  153. package/src/lib/server/scheduler.ts +2 -0
  154. package/src/lib/server/session-archive-memory.test.ts +85 -0
  155. package/src/lib/server/session-archive-memory.ts +230 -0
  156. package/src/lib/server/session-mailbox.ts +8 -18
  157. package/src/lib/server/session-reset-policy.test.ts +99 -0
  158. package/src/lib/server/session-reset-policy.ts +311 -0
  159. package/src/lib/server/session-run-manager.ts +33 -82
  160. package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
  161. package/src/lib/server/session-tools/calendar.ts +366 -0
  162. package/src/lib/server/session-tools/canvas.ts +1 -1
  163. package/src/lib/server/session-tools/chatroom.ts +4 -2
  164. package/src/lib/server/session-tools/connector.ts +114 -10
  165. package/src/lib/server/session-tools/context.ts +21 -5
  166. package/src/lib/server/session-tools/crawl.ts +447 -0
  167. package/src/lib/server/session-tools/crud.ts +74 -28
  168. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  169. package/src/lib/server/session-tools/delegate.ts +497 -24
  170. package/src/lib/server/session-tools/discovery.ts +24 -6
  171. package/src/lib/server/session-tools/document.ts +283 -0
  172. package/src/lib/server/session-tools/edit_file.ts +4 -2
  173. package/src/lib/server/session-tools/email.ts +320 -0
  174. package/src/lib/server/session-tools/extract.ts +137 -0
  175. package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
  176. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  177. package/src/lib/server/session-tools/file.ts +241 -25
  178. package/src/lib/server/session-tools/git.ts +1 -1
  179. package/src/lib/server/session-tools/http.ts +1 -1
  180. package/src/lib/server/session-tools/human-loop.ts +227 -0
  181. package/src/lib/server/session-tools/image-gen.ts +380 -0
  182. package/src/lib/server/session-tools/index.ts +130 -50
  183. package/src/lib/server/session-tools/mailbox.ts +276 -0
  184. package/src/lib/server/session-tools/memory.ts +172 -3
  185. package/src/lib/server/session-tools/monitor.ts +151 -8
  186. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  187. package/src/lib/server/session-tools/openclaw-nodes.ts +1 -1
  188. package/src/lib/server/session-tools/openclaw-workspace.ts +1 -1
  189. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  190. package/src/lib/server/session-tools/platform.ts +148 -7
  191. package/src/lib/server/session-tools/plugin-creator.ts +89 -26
  192. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  193. package/src/lib/server/session-tools/replicate.ts +301 -0
  194. package/src/lib/server/session-tools/sample-ui.ts +1 -1
  195. package/src/lib/server/session-tools/sandbox.ts +4 -2
  196. package/src/lib/server/session-tools/schedule.ts +24 -12
  197. package/src/lib/server/session-tools/session-info.ts +43 -7
  198. package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
  199. package/src/lib/server/session-tools/shell.ts +5 -2
  200. package/src/lib/server/session-tools/subagent.ts +194 -28
  201. package/src/lib/server/session-tools/table.ts +587 -0
  202. package/src/lib/server/session-tools/wallet.ts +42 -12
  203. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  204. package/src/lib/server/session-tools/web.ts +926 -91
  205. package/src/lib/server/storage.ts +255 -16
  206. package/src/lib/server/stream-agent-chat.ts +116 -268
  207. package/src/lib/server/structured-extract.test.ts +72 -0
  208. package/src/lib/server/structured-extract.ts +373 -0
  209. package/src/lib/server/task-mention.test.ts +16 -2
  210. package/src/lib/server/task-mention.ts +61 -10
  211. package/src/lib/server/tool-aliases.ts +66 -18
  212. package/src/lib/server/tool-capability-policy.test.ts +9 -9
  213. package/src/lib/server/tool-capability-policy.ts +38 -27
  214. package/src/lib/server/tool-retry.ts +2 -0
  215. package/src/lib/server/watch-jobs.test.ts +173 -0
  216. package/src/lib/server/watch-jobs.ts +532 -0
  217. package/src/lib/server/ws-hub.ts +5 -3
  218. package/src/lib/tool-definitions.ts +4 -0
  219. package/src/lib/validation/schemas.test.ts +26 -0
  220. package/src/lib/validation/schemas.ts +10 -1
  221. package/src/lib/ws-client.ts +14 -12
  222. package/src/proxy.ts +5 -5
  223. package/src/stores/use-app-store.ts +5 -11
  224. package/src/stores/use-chat-store.ts +38 -9
  225. package/src/types/index.ts +352 -47
  226. package/src/app/api/sessions/[id]/main-loop/route.ts +0 -94
  227. package/src/components/sessions/new-session-sheet.tsx +0 -253
  228. package/src/lib/server/main-session.ts +0 -24
  229. package/src/lib/server/session-run-manager.test.ts +0 -23
  230. /package/src/app/api/{sessions → chats}/[id]/clear/route.ts +0 -0
  231. /package/src/app/api/{sessions → chats}/[id]/deploy/route.ts +0 -0
  232. /package/src/app/api/{sessions → chats}/[id]/devserver/route.ts +0 -0
  233. /package/src/app/api/{sessions → chats}/[id]/edit-resend/route.ts +0 -0
  234. /package/src/app/api/{sessions → chats}/[id]/fork/route.ts +0 -0
  235. /package/src/app/api/{sessions → chats}/[id]/mailbox/route.ts +0 -0
  236. /package/src/app/api/{sessions → chats}/[id]/retry/route.ts +0 -0
  237. /package/src/app/api/{sessions → chats}/heartbeat/route.ts +0 -0
@@ -0,0 +1,72 @@
1
+ import assert from 'node:assert/strict'
2
+ import { afterEach, describe, it } from 'node:test'
3
+ import { PROVIDERS } from '../providers'
4
+ import { runStructuredExtraction } from './structured-extract'
5
+
6
+ const originalOllamaHandler = PROVIDERS.ollama.handler.streamChat
7
+
8
+ afterEach(() => {
9
+ PROVIDERS.ollama.handler.streamChat = originalOllamaHandler
10
+ })
11
+
12
+ describe('runStructuredExtraction', () => {
13
+ it('parses fenced JSON output from the current provider', async () => {
14
+ PROVIDERS.ollama.handler.streamChat = async () => '```json\n{"name":"Ada","score":10}\n```'
15
+
16
+ const result = await runStructuredExtraction({
17
+ session: {
18
+ id: 'session-1',
19
+ provider: 'ollama',
20
+ model: 'qwen3.5',
21
+ credentialId: null,
22
+ fallbackCredentialIds: [],
23
+ apiEndpoint: 'http://localhost:11434',
24
+ },
25
+ text: 'Ada scored 10.',
26
+ schema: {
27
+ type: 'object',
28
+ properties: {
29
+ name: { type: 'string' },
30
+ score: { type: 'number' },
31
+ },
32
+ required: ['name', 'score'],
33
+ },
34
+ instruction: 'Extract the person and score.',
35
+ })
36
+
37
+ assert.deepEqual(result.object, { name: 'Ada', score: 10 })
38
+ assert.deepEqual(result.validationErrors, [])
39
+ })
40
+
41
+ it('repairs invalid JSON with a second pass', async () => {
42
+ let callCount = 0
43
+ PROVIDERS.ollama.handler.streamChat = async () => {
44
+ callCount += 1
45
+ return callCount === 1 ? 'name: Ada' : '{"name":"Ada"}'
46
+ }
47
+
48
+ const result = await runStructuredExtraction({
49
+ session: {
50
+ id: 'session-2',
51
+ provider: 'ollama',
52
+ model: 'qwen3.5',
53
+ credentialId: null,
54
+ fallbackCredentialIds: [],
55
+ apiEndpoint: 'http://localhost:11434',
56
+ },
57
+ text: 'Ada',
58
+ schema: {
59
+ type: 'object',
60
+ properties: {
61
+ name: { type: 'string' },
62
+ },
63
+ required: ['name'],
64
+ },
65
+ instruction: 'Extract the name.',
66
+ })
67
+
68
+ assert.equal(callCount, 2)
69
+ assert.deepEqual(result.object, { name: 'Ada' })
70
+ assert.deepEqual(result.validationErrors, [])
71
+ })
72
+ })
@@ -0,0 +1,373 @@
1
+ import type { Session } from '@/types'
2
+ import { getProvider, streamChatWithFailover } from '@/lib/providers'
3
+ import { decryptKey, loadCredentials } from './storage'
4
+ import { extractDocumentArtifact, type DocumentArtifact } from './document-utils'
5
+
6
+ type JsonSchemaLike = Record<string, unknown>
7
+
8
+ interface ExtractionSession extends Pick<Session, 'id' | 'provider' | 'model' | 'credentialId' | 'fallbackCredentialIds' | 'apiEndpoint' | 'thinkingLevel'> {
9
+ name?: string
10
+ cwd?: string
11
+ }
12
+
13
+ export interface StructuredExtractionSource {
14
+ kind: 'text' | 'file' | 'mixed'
15
+ text: string
16
+ filePath?: string | null
17
+ artifact?: DocumentArtifact | null
18
+ }
19
+
20
+ export interface StructuredExtractionResult {
21
+ object: unknown
22
+ raw: string
23
+ validationErrors: string[]
24
+ provider: string
25
+ model: string
26
+ source: StructuredExtractionSource
27
+ }
28
+
29
+ function resolveApiKey(session: ExtractionSession): string | null {
30
+ const provider = getProvider(session.provider)
31
+ if (!provider) throw new Error(`Unknown provider: ${session.provider}`)
32
+ if (provider.requiresApiKey) {
33
+ if (!session.credentialId) throw new Error('No API key configured for this session')
34
+ const creds = loadCredentials()
35
+ const cred = creds[session.credentialId]
36
+ if (!cred?.encryptedKey) throw new Error('API key not found. Please add one in Settings.')
37
+ return decryptKey(cred.encryptedKey)
38
+ }
39
+ if (provider.optionalApiKey && session.credentialId) {
40
+ const creds = loadCredentials()
41
+ const cred = creds[session.credentialId]
42
+ if (cred?.encryptedKey) {
43
+ try {
44
+ return decryptKey(cred.encryptedKey)
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+ }
50
+ return null
51
+ }
52
+
53
+ function normalizeSchemaInput(schema: unknown): JsonSchemaLike {
54
+ if (schema && typeof schema === 'object' && !Array.isArray(schema)) return schema as JsonSchemaLike
55
+ if (typeof schema === 'string' && schema.trim()) {
56
+ const parsed = JSON.parse(schema)
57
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as JsonSchemaLike
58
+ }
59
+ throw new Error('schema must be a JSON object or a JSON string representing an object.')
60
+ }
61
+
62
+ function defaultSummarySchema(): JsonSchemaLike {
63
+ return {
64
+ type: 'object',
65
+ properties: {
66
+ summary: { type: 'string' },
67
+ keyPoints: { type: 'array', items: { type: 'string' } },
68
+ entities: {
69
+ type: 'array',
70
+ items: {
71
+ type: 'object',
72
+ properties: {
73
+ name: { type: 'string' },
74
+ type: { type: 'string' },
75
+ value: {},
76
+ },
77
+ required: ['name'],
78
+ },
79
+ },
80
+ },
81
+ required: ['summary', 'keyPoints'],
82
+ }
83
+ }
84
+
85
+ function normalizeText(value: string, maxChars = 120_000): string {
86
+ const cleaned = value.replace(/\r\n/g, '\n').replace(/\u0000/g, '').trim()
87
+ if (cleaned.length <= maxChars) return cleaned
88
+ return `${cleaned.slice(0, maxChars)}\n\n[... truncated ...]`
89
+ }
90
+
91
+ function extractJsonBlock(text: string): string | null {
92
+ const raw = (text || '').trim()
93
+ if (!raw) return null
94
+
95
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim()
96
+ if (fenced) return fenced
97
+
98
+ if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) {
99
+ return raw
100
+ }
101
+
102
+ let inString = false
103
+ let escaped = false
104
+ let start = -1
105
+ const stack: string[] = []
106
+ for (let index = 0; index < raw.length; index += 1) {
107
+ const char = raw[index]
108
+ if (inString) {
109
+ if (escaped) {
110
+ escaped = false
111
+ continue
112
+ }
113
+ if (char === '\\') {
114
+ escaped = true
115
+ continue
116
+ }
117
+ if (char === '"') inString = false
118
+ continue
119
+ }
120
+ if (char === '"') {
121
+ inString = true
122
+ continue
123
+ }
124
+ if (char === '{' || char === '[') {
125
+ if (stack.length === 0) start = index
126
+ stack.push(char)
127
+ continue
128
+ }
129
+ if (char === '}' || char === ']') {
130
+ const last = stack.at(-1)
131
+ if ((char === '}' && last === '{') || (char === ']' && last === '[')) {
132
+ stack.pop()
133
+ if (stack.length === 0 && start >= 0) {
134
+ return raw.slice(start, index + 1)
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ return null
141
+ }
142
+
143
+ function parseModelJson(text: string): unknown {
144
+ const candidate = extractJsonBlock(text)
145
+ if (!candidate) throw new Error('Model did not return JSON.')
146
+ return JSON.parse(candidate)
147
+ }
148
+
149
+ function typeMatches(value: unknown, expected: string): boolean {
150
+ if (expected === 'array') return Array.isArray(value)
151
+ if (expected === 'object') return !!value && typeof value === 'object' && !Array.isArray(value)
152
+ if (expected === 'string') return typeof value === 'string'
153
+ if (expected === 'number') return typeof value === 'number' && Number.isFinite(value)
154
+ if (expected === 'integer') return typeof value === 'number' && Number.isInteger(value)
155
+ if (expected === 'boolean') return typeof value === 'boolean'
156
+ if (expected === 'null') return value === null
157
+ return true
158
+ }
159
+
160
+ function validateJsonLikeSchema(
161
+ value: unknown,
162
+ schema: JsonSchemaLike,
163
+ path = '$',
164
+ errors: string[] = [],
165
+ ): string[] {
166
+ const expected = schema.type
167
+ if (typeof expected === 'string' && !typeMatches(value, expected)) {
168
+ errors.push(`${path} should be ${expected}`)
169
+ return errors
170
+ }
171
+
172
+ if (Array.isArray(schema.enum) && !schema.enum.some((entry) => JSON.stringify(entry) === JSON.stringify(value))) {
173
+ errors.push(`${path} must be one of the allowed enum values`)
174
+ }
175
+
176
+ if (expected === 'object' && value && typeof value === 'object' && !Array.isArray(value)) {
177
+ const asRecord = value as Record<string, unknown>
178
+ const properties = (schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties))
179
+ ? schema.properties as Record<string, JsonSchemaLike>
180
+ : {}
181
+ const required = Array.isArray(schema.required) ? schema.required.filter((entry): entry is string => typeof entry === 'string') : []
182
+ for (const key of required) {
183
+ if (!(key in asRecord)) errors.push(`${path}.${key} is required`)
184
+ }
185
+ for (const [key, childSchema] of Object.entries(properties)) {
186
+ if (!(key in asRecord)) continue
187
+ validateJsonLikeSchema(asRecord[key], childSchema, `${path}.${key}`, errors)
188
+ }
189
+ if (schema.additionalProperties === false) {
190
+ for (const key of Object.keys(asRecord)) {
191
+ if (!(key in properties)) errors.push(`${path}.${key} is not allowed`)
192
+ }
193
+ }
194
+ }
195
+
196
+ if (expected === 'array' && Array.isArray(value)) {
197
+ const itemSchema = (schema.items && typeof schema.items === 'object' && !Array.isArray(schema.items))
198
+ ? schema.items as JsonSchemaLike
199
+ : null
200
+ if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
201
+ errors.push(`${path} must contain at least ${schema.minItems} items`)
202
+ }
203
+ if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
204
+ errors.push(`${path} must contain at most ${schema.maxItems} items`)
205
+ }
206
+ if (itemSchema) {
207
+ value.slice(0, 100).forEach((entry, index) => validateJsonLikeSchema(entry, itemSchema, `${path}[${index}]`, errors))
208
+ }
209
+ }
210
+
211
+ return errors
212
+ }
213
+
214
+ async function callExtractionModel(params: {
215
+ session: ExtractionSession
216
+ prompt: string
217
+ }): Promise<string> {
218
+ const provider = getProvider(params.session.provider)
219
+ if (!provider) throw new Error(`Unknown provider: ${params.session.provider}`)
220
+
221
+ const apiKey = resolveApiKey(params.session)
222
+ const streamedText: string[] = []
223
+ const streamedErrors: string[] = []
224
+
225
+ const raw = await streamChatWithFailover({
226
+ session: {
227
+ id: `${params.session.id}:extract:${Date.now()}`,
228
+ provider: params.session.provider,
229
+ model: params.session.model,
230
+ credentialId: params.session.credentialId ?? null,
231
+ fallbackCredentialIds: params.session.fallbackCredentialIds || [],
232
+ apiEndpoint: params.session.apiEndpoint || undefined,
233
+ thinkingLevel: params.session.thinkingLevel,
234
+ },
235
+ message: params.prompt,
236
+ apiKey,
237
+ active: new Map(),
238
+ loadHistory: () => [],
239
+ write: (chunk) => {
240
+ for (const line of chunk.split('\n')) {
241
+ if (!line.startsWith('data: ')) continue
242
+ try {
243
+ const event = JSON.parse(line.slice(6).trim()) as Record<string, unknown>
244
+ if (event.t === 'd' && typeof event.text === 'string') streamedText.push(event.text)
245
+ if (event.t === 'err' && typeof event.text === 'string') streamedErrors.push(event.text)
246
+ } catch {
247
+ // ignore malformed SSE fragments
248
+ }
249
+ }
250
+ },
251
+ })
252
+
253
+ const text = (raw || streamedText.join('')).trim()
254
+ if (!text) {
255
+ throw new Error(streamedErrors[0] || `Provider "${provider.name}" returned no content.`)
256
+ }
257
+ return text
258
+ }
259
+
260
+ function buildExtractionPrompt(params: {
261
+ instruction?: string | null
262
+ schema: JsonSchemaLike
263
+ source: StructuredExtractionSource
264
+ }): string {
265
+ const parts = [
266
+ 'Extract structured data from the provided source.',
267
+ 'Return only valid JSON. Do not include markdown fences, commentary, or explanatory text.',
268
+ 'If a field cannot be determined, use null, an empty string, or an empty array based on the schema.',
269
+ ]
270
+ if (params.instruction?.trim()) {
271
+ parts.push(`Task:\n${params.instruction.trim()}`)
272
+ }
273
+ parts.push(`JSON Schema:\n${JSON.stringify(params.schema, null, 2)}`)
274
+ if (params.source.artifact) {
275
+ const artifact = params.source.artifact
276
+ parts.push(`Source metadata:\n${JSON.stringify({
277
+ filePath: artifact.filePath,
278
+ fileName: artifact.fileName,
279
+ ext: artifact.ext,
280
+ method: artifact.method,
281
+ metadata: artifact.metadata,
282
+ tableCount: artifact.tables.length,
283
+ }, null, 2)}`)
284
+ }
285
+ parts.push(`Source text:\n${params.source.text}`)
286
+ return parts.join('\n\n')
287
+ }
288
+
289
+ async function prepareSource(params: {
290
+ text?: string | null
291
+ filePath?: string | null
292
+ preferOcr?: boolean
293
+ maxChars?: number
294
+ }): Promise<StructuredExtractionSource> {
295
+ const chunks: string[] = []
296
+ let artifact: DocumentArtifact | null = null
297
+
298
+ if (params.filePath) {
299
+ artifact = await extractDocumentArtifact(params.filePath, {
300
+ preferOcr: params.preferOcr,
301
+ maxChars: params.maxChars,
302
+ })
303
+ if (artifact.text.trim()) chunks.push(artifact.text)
304
+ }
305
+
306
+ if (params.text?.trim()) chunks.push(params.text.trim())
307
+ if (chunks.length === 0) throw new Error('text or filePath is required.')
308
+
309
+ return {
310
+ kind: params.filePath && params.text ? 'mixed' : params.filePath ? 'file' : 'text',
311
+ filePath: params.filePath || null,
312
+ artifact,
313
+ text: normalizeText(chunks.join('\n\n'), params.maxChars || 120_000),
314
+ }
315
+ }
316
+
317
+ export async function runStructuredExtraction(params: {
318
+ session: ExtractionSession
319
+ text?: string | null
320
+ filePath?: string | null
321
+ instruction?: string | null
322
+ schema?: unknown
323
+ preferOcr?: boolean
324
+ maxChars?: number
325
+ }): Promise<StructuredExtractionResult> {
326
+ if (!params.session.provider || !params.session.model) {
327
+ throw new Error('Current session is missing provider/model configuration.')
328
+ }
329
+
330
+ const source = await prepareSource({
331
+ text: params.text,
332
+ filePath: params.filePath,
333
+ preferOcr: params.preferOcr,
334
+ maxChars: params.maxChars,
335
+ })
336
+ const schema = params.schema === undefined ? defaultSummarySchema() : normalizeSchemaInput(params.schema)
337
+ const prompt = buildExtractionPrompt({
338
+ instruction: params.instruction,
339
+ schema,
340
+ source,
341
+ })
342
+
343
+ let raw = await callExtractionModel({
344
+ session: params.session,
345
+ prompt,
346
+ })
347
+
348
+ let parsed: unknown
349
+ try {
350
+ parsed = parseModelJson(raw)
351
+ } catch (error) {
352
+ raw = await callExtractionModel({
353
+ session: params.session,
354
+ prompt: [
355
+ 'Repair the invalid JSON below so it becomes valid JSON that matches the provided schema.',
356
+ 'Return only JSON.',
357
+ `JSON Schema:\n${JSON.stringify(schema, null, 2)}`,
358
+ `Invalid output:\n${raw}`,
359
+ ].join('\n\n'),
360
+ })
361
+ parsed = parseModelJson(raw)
362
+ }
363
+
364
+ const validationErrors = validateJsonLikeSchema(parsed, schema).slice(0, 50)
365
+ return {
366
+ object: parsed,
367
+ raw,
368
+ validationErrors,
369
+ provider: params.session.provider,
370
+ model: params.session.model,
371
+ source,
372
+ }
373
+ }
@@ -1,7 +1,7 @@
1
1
  import { describe, it } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import type { Agent } from '@/types'
4
- import { parseMentionedAgentId, resolveTaskAgentFromDescription } from './task-mention'
4
+ import { parseAssignedAgentId, parseMentionedAgentId, resolveAgentReference, resolveTaskAgentFromDescription } from './task-mention'
5
5
 
6
6
  const now = Date.now()
7
7
  const agents: Record<string, Agent> = {
@@ -37,5 +37,19 @@ describe('task-mention', () => {
37
37
  const resolved = resolveTaskAgentFromDescription('No mention here', 'default', agents)
38
38
  assert.equal(resolved, 'default')
39
39
  })
40
- })
41
40
 
41
+ it('resolves agent ids directly', () => {
42
+ const resolved = resolveAgentReference('coder', agents)
43
+ assert.equal(resolved, 'coder')
44
+ })
45
+
46
+ it('parses plain-language assignment phrases', () => {
47
+ const assigned = parseAssignedAgentId('Create this task and assign it to agent "default".', agents)
48
+ assert.equal(assigned, 'default')
49
+ })
50
+
51
+ it('resolves task assignment without @mentions', () => {
52
+ const resolved = resolveTaskAgentFromDescription('Please delegate this to CodeBot.', 'default', agents)
53
+ assert.equal(resolved, 'coder')
54
+ })
55
+ })
@@ -1,5 +1,39 @@
1
1
  import type { Agent } from '@/types'
2
2
 
3
+ function normalizeReference(reference: string): string {
4
+ return reference
5
+ .trim()
6
+ .replace(/^@/, '')
7
+ .replace(/^agent\s+/i, '')
8
+ .replace(/^["'`]+|["'`]+$/g, '')
9
+ .replace(/[.,!?;:]+$/g, '')
10
+ .trim()
11
+ .toLowerCase()
12
+ }
13
+
14
+ export function resolveAgentReference(
15
+ reference: string,
16
+ agents: Record<string, Agent>,
17
+ ): string | null {
18
+ const normalized = normalizeReference(reference)
19
+ if (!normalized) return null
20
+
21
+ const agentList = Object.values(agents)
22
+ const exactId = agentList.find((agent) => agent.id.toLowerCase() === normalized)
23
+ if (exactId) return exactId.id
24
+
25
+ const exactName = agentList.find((agent) => agent.name.toLowerCase() === normalized)
26
+ if (exactName) return exactName.id
27
+
28
+ const startsWithId = agentList.find((agent) => agent.id.toLowerCase().startsWith(normalized))
29
+ if (startsWithId) return startsWithId.id
30
+
31
+ const startsWithName = agentList.find((agent) => agent.name.toLowerCase().startsWith(normalized))
32
+ if (startsWithName) return startsWithName.id
33
+
34
+ return null
35
+ }
36
+
3
37
  /**
4
38
  * Parse @AgentName mentions from text and resolve to an agent ID.
5
39
  * Uses case-insensitive exact match, then falls back to starts-with.
@@ -13,16 +47,31 @@ export function parseMentionedAgentId(
13
47
  let match: RegExpExecArray | null
14
48
 
15
49
  while ((match = mentionRegex.exec(description)) !== null) {
16
- const mention = (match[1] || '').toLowerCase().replace(/[.,!?;:]+$/g, '')
17
- if (!mention) continue
50
+ const mention = match[1] || ''
51
+ const resolved = resolveAgentReference(mention, agents)
52
+ if (resolved) return resolved
53
+ }
54
+
55
+ return null
56
+ }
18
57
 
19
- // Exact name match (case-insensitive)
20
- const exact = agentList.find((a) => a.name.toLowerCase() === mention)
21
- if (exact) return exact.id
58
+ export function parseAssignedAgentId(
59
+ description: string,
60
+ agents: Record<string, Agent>,
61
+ ): string | null {
62
+ const patterns = [
63
+ /(?:assign(?:ed)?|delegate(?:d)?|route(?:d)?|hand(?:ed)?)(?:\s+\w+){0,4}\s+to\s+(?:agent\s+)?["'`]?([^"'`\n]+?)["'`]?(?=$|[\s.,;:])/gi,
64
+ /(?:assignee|assigned[_\s-]?to|agent(?:\s+id)?)\s*[:=]\s*["'`]?([^"'`\n]+?)["'`]?(?=$|[\s.,;:])/gi,
65
+ /for\s+agent\s+["'`]?([^"'`\n]+?)["'`]?(?=$|[\s.,;:])/gi,
66
+ ]
22
67
 
23
- // Starts-with match (for partial names like @code matching "CodeBot")
24
- const startsWith = agentList.find((a) => a.name.toLowerCase().startsWith(mention))
25
- if (startsWith) return startsWith.id
68
+ for (const pattern of patterns) {
69
+ let match: RegExpExecArray | null
70
+ while ((match = pattern.exec(description)) !== null) {
71
+ const candidate = (match[1] || '').trim()
72
+ const resolved = resolveAgentReference(candidate, agents)
73
+ if (resolved) return resolved
74
+ }
26
75
  }
27
76
 
28
77
  return null
@@ -30,7 +79,7 @@ export function parseMentionedAgentId(
30
79
 
31
80
  /**
32
81
  * Resolve task agent: if description has an @mention, use that agent.
33
- * Otherwise fall back to currentAgentId.
82
+ * Otherwise fall back to an explicit assignment phrase, then currentAgentId.
34
83
  */
35
84
  export function resolveTaskAgentFromDescription(
36
85
  description: string,
@@ -38,5 +87,7 @@ export function resolveTaskAgentFromDescription(
38
87
  agents: Record<string, Agent>,
39
88
  ): string {
40
89
  const mentioned = parseMentionedAgentId(description, agents)
41
- return mentioned || currentAgentId
90
+ if (mentioned) return mentioned
91
+ const assigned = parseAssignedAgentId(description, agents)
92
+ return assigned || currentAgentId
42
93
  }