@swarmclawai/swarmclaw 1.6.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/package.json +3 -3
- package/src/app/.well-known/agent-card.json/route.ts +15 -0
- package/src/app/api/.well-known/agent-card/route.ts +6 -37
- package/src/components/home/home-launchpad.tsx +77 -5
- package/src/lib/a2a/agent-card.test.ts +94 -0
- package/src/lib/a2a/agent-card.ts +41 -1
- package/src/lib/providers/opencode-cli.test.ts +9 -0
- package/src/lib/providers/opencode-cli.ts +5 -1
- package/src/lib/server/missions/mission-templates.test.ts +17 -0
- package/src/lib/server/missions/mission-templates.ts +69 -0
- package/src/lib/server/protocols/protocol-service.test.ts +25 -0
- package/src/lib/server/protocols/protocol-templates.ts +48 -0
- package/src/lib/strip-internal-metadata.test.ts +23 -0
- package/src/lib/strip-internal-metadata.ts +136 -7
package/README.md
CHANGED
|
@@ -399,6 +399,17 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
399
399
|
|
|
400
400
|
## Releases
|
|
401
401
|
|
|
402
|
+
### v1.6.1 Highlights
|
|
403
|
+
|
|
404
|
+
Follow-up release for v1.6 with workflow starts, safer metadata handling, A2A discovery polish, and [#61](https://github.com/swarmclawai/swarmclaw/pull/61) by [@latentwill](https://github.com/latentwill). Thanks latentwill!
|
|
405
|
+
|
|
406
|
+
- **Mission and protocol templates for real work.** New starter paths cover codebase review sprints, research bureau scans, content studio cycles, release readiness panels, synthesis panels, and builder review loops.
|
|
407
|
+
- **Home launchpad paths.** First-run users can choose a self-hosted assistant, visual workflow, or autonomous mission path, with quality actions still one click away.
|
|
408
|
+
- **A2A discovery is easier to integrate.** The canonical `/.well-known/agent-card.json` endpoint now works alongside the legacy API route and hides disabled or trashed agents from public discovery.
|
|
409
|
+
- **Internal metadata stripping is safer.** Side-channel JSON is removed with balanced-object parsing and zod validation so nested payloads are scrubbed without deleting ordinary user JSON.
|
|
410
|
+
- **Browser smoke gate restored.** `npm run test:e2e` now runs a Playwright smoke against health, A2A discovery, `/home`, and `/quality`, either against a live URL or a temporary local dev server.
|
|
411
|
+
- **OpenCode CLI hang fixed.** OpenCode CLI delegation no longer keeps an inherited stdin pipe open, preventing hangs in non-interactive runs.
|
|
412
|
+
|
|
402
413
|
### v1.6.0 Highlights
|
|
403
414
|
|
|
404
415
|
Operator Quality Center release for builders running autonomous agents in production-like workflows.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
|
|
5
5
|
"main": "electron-dist/main.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -87,9 +87,9 @@
|
|
|
87
87
|
"test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
|
|
88
88
|
"test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
|
|
89
89
|
"test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
|
|
90
|
-
"test:runtime": "tsx --test src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
|
|
90
|
+
"test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/providers/opencode-cli.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
|
|
91
91
|
"test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
|
|
92
|
-
"test:e2e": "tsx
|
|
92
|
+
"test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
|
|
93
93
|
"test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
|
|
94
94
|
"electron:compile": "tsc -p electron/tsconfig.json",
|
|
95
95
|
"electron:dev": "npm run electron:compile && electron electron-dist/main.js",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { buildAgentCardDiscoveryPayload } from '@/lib/a2a/agent-card'
|
|
3
|
+
|
|
4
|
+
export const dynamic = 'force-dynamic'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* GET /.well-known/agent-card.json?agentId=xxx
|
|
8
|
+
*
|
|
9
|
+
* Canonical public A2A Agent Card discovery endpoint. If agentId is omitted,
|
|
10
|
+
* returns a directory of discoverable local SwarmClaw agents.
|
|
11
|
+
*/
|
|
12
|
+
export async function GET(req: Request) {
|
|
13
|
+
const { body, status } = buildAgentCardDiscoveryPayload(req)
|
|
14
|
+
return NextResponse.json(body, { status })
|
|
15
|
+
}
|
|
@@ -1,46 +1,15 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import {
|
|
3
|
-
import { generateAgentCard } from '@/lib/a2a/agent-card'
|
|
2
|
+
import { buildAgentCardDiscoveryPayload } from '@/lib/a2a/agent-card'
|
|
4
3
|
|
|
5
4
|
export const dynamic = 'force-dynamic'
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
|
-
* GET /.well-known/agent-card
|
|
7
|
+
* GET /api/.well-known/agent-card?agentId=xxx
|
|
9
8
|
*
|
|
10
|
-
* A2A Agent Card discovery endpoint.
|
|
11
|
-
*
|
|
12
|
-
* Otherwise, returns a directory of all non-disabled agents.
|
|
13
|
-
*
|
|
14
|
-
* Publicly accessible per A2A spec — no auth required for discovery.
|
|
9
|
+
* Back-compatible A2A Agent Card discovery endpoint. The canonical public
|
|
10
|
+
* well-known URL is implemented at /.well-known/agent-card.json.
|
|
15
11
|
*/
|
|
16
12
|
export async function GET(req: Request) {
|
|
17
|
-
const {
|
|
18
|
-
|
|
19
|
-
const baseUrl = `${new URL(req.url).origin}`
|
|
20
|
-
|
|
21
|
-
if (agentId) {
|
|
22
|
-
const agent = getAgent(agentId)
|
|
23
|
-
if (!agent) {
|
|
24
|
-
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
|
25
|
-
}
|
|
26
|
-
if (agent.disabled) {
|
|
27
|
-
return NextResponse.json({ error: 'Agent is disabled' }, { status: 404 })
|
|
28
|
-
}
|
|
29
|
-
const card = generateAgentCard(agent, baseUrl)
|
|
30
|
-
return NextResponse.json(card)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Return directory of all active agents
|
|
34
|
-
const agents = listAgents()
|
|
35
|
-
const directory = Object.values(agents)
|
|
36
|
-
.filter(a => !a.disabled)
|
|
37
|
-
.map(a => ({
|
|
38
|
-
name: a.name,
|
|
39
|
-
description: a.description || `SwarmClaw agent: ${a.name}`,
|
|
40
|
-
agentId: a.id,
|
|
41
|
-
apiEndpoint: `${baseUrl}/api/a2a`,
|
|
42
|
-
cardUrl: `${baseUrl}/api/.well-known/agent-card?agentId=${a.id}`,
|
|
43
|
-
}))
|
|
44
|
-
|
|
45
|
-
return NextResponse.json({ agents: directory, protocolVersion: '0.3.0' })
|
|
13
|
+
const { body, status } = buildAgentCardDiscoveryPayload(req)
|
|
14
|
+
return NextResponse.json(body, { status })
|
|
46
15
|
}
|
|
@@ -14,6 +14,48 @@ function SnapshotItem({ label, value, hint }: { label: string; value: string; hi
|
|
|
14
14
|
)
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function PathCard({
|
|
18
|
+
kicker,
|
|
19
|
+
title,
|
|
20
|
+
description,
|
|
21
|
+
primaryLabel,
|
|
22
|
+
secondaryLabel,
|
|
23
|
+
onPrimary,
|
|
24
|
+
onSecondary,
|
|
25
|
+
}: {
|
|
26
|
+
kicker: string
|
|
27
|
+
title: string
|
|
28
|
+
description: string
|
|
29
|
+
primaryLabel: string
|
|
30
|
+
secondaryLabel: string
|
|
31
|
+
onPrimary: () => void
|
|
32
|
+
onSecondary: () => void
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="flex min-h-[220px] flex-col rounded-[18px] border border-white/[0.07] bg-white/[0.03] p-5">
|
|
36
|
+
<div className="text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/55">{kicker}</div>
|
|
37
|
+
<div className="mt-3 text-[18px] font-display font-700 tracking-normal text-text">{title}</div>
|
|
38
|
+
<p className="mt-2 flex-1 text-[13px] leading-relaxed text-text-3/72">{description}</p>
|
|
39
|
+
<div className="mt-5 flex flex-wrap gap-2">
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
onClick={onPrimary}
|
|
43
|
+
className="rounded-[10px] bg-accent-bright px-3.5 py-2 text-[12px] font-display font-700 text-black transition-opacity hover:opacity-90"
|
|
44
|
+
>
|
|
45
|
+
{primaryLabel}
|
|
46
|
+
</button>
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
onClick={onSecondary}
|
|
50
|
+
className="rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-3.5 py-2 text-[12px] font-display font-700 text-text-2 transition-colors hover:bg-white/[0.08]"
|
|
51
|
+
>
|
|
52
|
+
{secondaryLabel}
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
17
59
|
type Props = {
|
|
18
60
|
firstAgent: Agent | null
|
|
19
61
|
agentCount: number
|
|
@@ -53,17 +95,17 @@ export function HomeLaunchpad({
|
|
|
53
95
|
}: Props) {
|
|
54
96
|
return (
|
|
55
97
|
<div className="max-w-[980px] mx-auto px-6 py-10">
|
|
56
|
-
<div className="rounded-[
|
|
98
|
+
<div className="rounded-[20px] border border-white/[0.06] bg-white/[0.025] p-6">
|
|
57
99
|
<div className="inline-flex rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-[11px] font-700 uppercase tracking-[0.16em] text-text-3/70">
|
|
58
|
-
Launchpad
|
|
100
|
+
v1.6 Launchpad
|
|
59
101
|
</div>
|
|
60
102
|
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
61
103
|
<div className="max-w-[620px]">
|
|
62
|
-
<h1 className="font-display text-[34px] font-700 tracking-
|
|
63
|
-
|
|
104
|
+
<h1 className="font-display text-[34px] font-700 tracking-normal text-text">
|
|
105
|
+
Pick a path and watch the workspace move.
|
|
64
106
|
</h1>
|
|
65
107
|
<p className="mt-3 text-[15px] leading-relaxed text-text-3/72">
|
|
66
|
-
|
|
108
|
+
Start with a local assistant, a reusable workflow, or a budgeted autonomous mission. The rest of the control plane stays one click away.
|
|
67
109
|
</p>
|
|
68
110
|
</div>
|
|
69
111
|
<div className="rounded-[18px] border border-white/[0.06] bg-white/[0.03] p-4 min-w-[240px]">
|
|
@@ -94,6 +136,36 @@ export function HomeLaunchpad({
|
|
|
94
136
|
</div>
|
|
95
137
|
</div>
|
|
96
138
|
|
|
139
|
+
<div className="mt-6 grid gap-3 lg:grid-cols-3">
|
|
140
|
+
<PathCard
|
|
141
|
+
kicker="Self-hosted assistant"
|
|
142
|
+
title={firstAgent ? `Work with ${firstAgent.name}` : 'Create the first agent'}
|
|
143
|
+
description="Open a live agent chat, then add memory, local tools, provider routing, or connector access as the work demands."
|
|
144
|
+
primaryLabel={firstAgent ? 'Open Chat' : 'Open Agents'}
|
|
145
|
+
secondaryLabel="Connect Platform"
|
|
146
|
+
onPrimary={onOpenFirstAgent}
|
|
147
|
+
onSecondary={onOpenConnectors}
|
|
148
|
+
/>
|
|
149
|
+
<PathCard
|
|
150
|
+
kicker="Visual workflow"
|
|
151
|
+
title="Shape a reusable run"
|
|
152
|
+
description="Use protocol templates and the builder to turn review, research, planning, or release checks into durable workflows."
|
|
153
|
+
primaryLabel="Open Builder"
|
|
154
|
+
secondaryLabel="Use Templates"
|
|
155
|
+
onPrimary={onOpenBuilder}
|
|
156
|
+
onSecondary={onOpenProtocols}
|
|
157
|
+
/>
|
|
158
|
+
<PathCard
|
|
159
|
+
kicker="Autonomous mission"
|
|
160
|
+
title="Run with budgets"
|
|
161
|
+
description="Start a mission template for release QA, research, support triage, cost audit, or failed-run review with reports and caps."
|
|
162
|
+
primaryLabel="Open Missions"
|
|
163
|
+
secondaryLabel="Quality Center"
|
|
164
|
+
onPrimary={onStartReleaseQaMission}
|
|
165
|
+
onSecondary={onRunEvalSuite}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
97
169
|
<div className="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
98
170
|
<LaunchActionCard
|
|
99
171
|
title={firstAgent ? 'Open First Agent Chat' : 'Open Agents'}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { after, before, describe, it } from 'node:test'
|
|
6
|
+
|
|
7
|
+
const originalEnv = {
|
|
8
|
+
DATA_DIR: process.env.DATA_DIR,
|
|
9
|
+
WORKSPACE_DIR: process.env.WORKSPACE_DIR,
|
|
10
|
+
SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let tempDir = ''
|
|
14
|
+
let storage: typeof import('@/lib/server/storage')
|
|
15
|
+
let canonicalRoute: typeof import('@/app/.well-known/agent-card.json/route')
|
|
16
|
+
let legacyRoute: typeof import('@/app/api/.well-known/agent-card/route')
|
|
17
|
+
|
|
18
|
+
function testAgent(id: string, overrides: Record<string, unknown> = {}) {
|
|
19
|
+
const now = Date.now()
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
name: id === 'agent-active' ? 'Active Agent' : 'Hidden Agent',
|
|
23
|
+
description: 'A2A route test agent',
|
|
24
|
+
systemPrompt: '',
|
|
25
|
+
provider: 'ollama',
|
|
26
|
+
model: 'qwen3.5',
|
|
27
|
+
credentialId: null,
|
|
28
|
+
fallbackCredentialIds: [],
|
|
29
|
+
apiEndpoint: null,
|
|
30
|
+
gatewayProfileId: null,
|
|
31
|
+
extensions: [],
|
|
32
|
+
capabilities: ['research'],
|
|
33
|
+
createdAt: now,
|
|
34
|
+
updatedAt: now,
|
|
35
|
+
...overrides,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
before(async () => {
|
|
40
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-a2a-card-'))
|
|
41
|
+
process.env.DATA_DIR = path.join(tempDir, 'data')
|
|
42
|
+
process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
|
|
43
|
+
process.env.SWARMCLAW_BUILD_MODE = '1'
|
|
44
|
+
storage = await import('@/lib/server/storage')
|
|
45
|
+
canonicalRoute = await import('@/app/.well-known/agent-card.json/route')
|
|
46
|
+
legacyRoute = await import('@/app/api/.well-known/agent-card/route')
|
|
47
|
+
storage.saveAgents({
|
|
48
|
+
'agent-active': testAgent('agent-active'),
|
|
49
|
+
'agent-disabled': testAgent('agent-disabled', { disabled: true }),
|
|
50
|
+
'agent-trashed': testAgent('agent-trashed', { trashedAt: Date.now() }),
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
after(() => {
|
|
55
|
+
if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
|
|
56
|
+
else process.env.DATA_DIR = originalEnv.DATA_DIR
|
|
57
|
+
if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
|
|
58
|
+
else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
|
|
59
|
+
if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
|
|
60
|
+
else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
|
|
61
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('A2A agent card discovery', () => {
|
|
65
|
+
it('serves the canonical well-known directory and hides disabled agents', async () => {
|
|
66
|
+
const response = await canonicalRoute.GET(new Request('http://local.test/.well-known/agent-card.json'))
|
|
67
|
+
assert.equal(response.status, 200)
|
|
68
|
+
const body = await response.json()
|
|
69
|
+
|
|
70
|
+
assert.equal(body.protocolVersion, '0.3.0')
|
|
71
|
+
assert.equal(body.kind, 'directory')
|
|
72
|
+
assert.deepEqual(body.agents.map((agent: { agentId: string }) => agent.agentId), ['agent-active'])
|
|
73
|
+
assert.equal(body.agents[0].apiEndpoint, 'http://local.test/api/a2a')
|
|
74
|
+
assert.equal(body.agents[0].cardUrl, 'http://local.test/.well-known/agent-card.json?agentId=agent-active')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('returns a full card from both canonical and legacy routes', async () => {
|
|
78
|
+
const canonical = await canonicalRoute.GET(new Request('http://local.test/.well-known/agent-card.json?agentId=agent-active'))
|
|
79
|
+
const legacy = await legacyRoute.GET(new Request('http://local.test/api/.well-known/agent-card?agentId=agent-active'))
|
|
80
|
+
|
|
81
|
+
assert.equal(canonical.status, 200)
|
|
82
|
+
assert.equal(legacy.status, 200)
|
|
83
|
+
assert.equal((await canonical.json()).name, 'Active Agent')
|
|
84
|
+
assert.equal((await legacy.json()).apiEndpoint, 'http://local.test/api/a2a')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('returns 404 for disabled or missing agent cards', async () => {
|
|
88
|
+
const disabled = await canonicalRoute.GET(new Request('http://local.test/.well-known/agent-card.json?agentId=agent-disabled'))
|
|
89
|
+
const missing = await canonicalRoute.GET(new Request('http://local.test/.well-known/agent-card.json?agentId=nope'))
|
|
90
|
+
|
|
91
|
+
assert.equal(disabled.status, 404)
|
|
92
|
+
assert.equal(missing.status, 404)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Agent } from '@/types/agent'
|
|
2
2
|
import type { AgentCard } from './types'
|
|
3
|
+
import { getAgent, listAgents } from '@/lib/server/agents/agent-repository'
|
|
3
4
|
|
|
4
|
-
const A2A_PROTOCOL_VERSION = '0.3.0'
|
|
5
|
+
export const A2A_PROTOCOL_VERSION = '0.3.0'
|
|
5
6
|
const SWARMCLAW_VERSION = '1.0.0'
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -59,3 +60,42 @@ export function generateAgentCard(agent: Agent, baseUrl: string): AgentCard {
|
|
|
59
60
|
],
|
|
60
61
|
}
|
|
61
62
|
}
|
|
63
|
+
|
|
64
|
+
function isPubliclyDiscoverableAgent(agent: Agent): boolean {
|
|
65
|
+
return agent.disabled !== true && !agent.trashedAt
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildAgentCardDiscoveryPayload(req: Request): {
|
|
69
|
+
body: unknown
|
|
70
|
+
status?: number
|
|
71
|
+
} {
|
|
72
|
+
const url = new URL(req.url)
|
|
73
|
+
const agentId = url.searchParams.get('agentId')
|
|
74
|
+
const baseUrl = url.origin
|
|
75
|
+
|
|
76
|
+
if (agentId) {
|
|
77
|
+
const agent = getAgent(agentId)
|
|
78
|
+
if (!agent || !isPubliclyDiscoverableAgent(agent)) {
|
|
79
|
+
return { body: { error: 'Agent not found' }, status: 404 }
|
|
80
|
+
}
|
|
81
|
+
return { body: generateAgentCard(agent, baseUrl) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const directory = Object.values(listAgents())
|
|
85
|
+
.filter(isPubliclyDiscoverableAgent)
|
|
86
|
+
.map((agent) => ({
|
|
87
|
+
name: agent.name,
|
|
88
|
+
description: agent.description || `SwarmClaw agent: ${agent.name}`,
|
|
89
|
+
agentId: agent.id,
|
|
90
|
+
apiEndpoint: `${baseUrl}/api/a2a`,
|
|
91
|
+
cardUrl: `${baseUrl}/.well-known/agent-card.json?agentId=${encodeURIComponent(agent.id)}`,
|
|
92
|
+
}))
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
body: {
|
|
96
|
+
agents: directory,
|
|
97
|
+
kind: 'directory',
|
|
98
|
+
protocolVersion: A2A_PROTOCOL_VERSION,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
import { OPENCODE_CLI_STDIO } from './opencode-cli'
|
|
4
|
+
|
|
5
|
+
describe('opencode-cli provider', () => {
|
|
6
|
+
it('closes child stdin so argv-prompt runs do not hang waiting for input', () => {
|
|
7
|
+
assert.deepEqual(OPENCODE_CLI_STDIO, ['ignore', 'pipe', 'pipe'])
|
|
8
|
+
})
|
|
9
|
+
})
|
|
@@ -4,6 +4,8 @@ import { log } from '../server/logger'
|
|
|
4
4
|
import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
|
|
5
5
|
import { resolveCliBinary, buildCliEnv, probeCliAuth, attachAbortHandler, isStderrNoise } from './cli-utils'
|
|
6
6
|
|
|
7
|
+
export const OPENCODE_CLI_STDIO: ['ignore', 'pipe', 'pipe'] = ['ignore', 'pipe', 'pipe']
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* OpenCode CLI provider — spawns `opencode run <message> --format json` for non-interactive usage.
|
|
9
11
|
* Tracks `session.opencodeSessionId` from streamed JSON events to support multi-turn continuity.
|
|
@@ -60,7 +62,9 @@ export function streamOpenCodeCliChat({ session, message, imagePath, systemPromp
|
|
|
60
62
|
const proc = spawn(binary, args, {
|
|
61
63
|
cwd,
|
|
62
64
|
env,
|
|
63
|
-
|
|
65
|
+
// stdin must be closed: OpenCode CLI can wait forever on a connected pipe
|
|
66
|
+
// even when the prompt is passed via argv.
|
|
67
|
+
stdio: OPENCODE_CLI_STDIO,
|
|
64
68
|
timeout: processTimeoutMs,
|
|
65
69
|
})
|
|
66
70
|
|
|
@@ -96,6 +96,23 @@ describe('mission-templates: registry', () => {
|
|
|
96
96
|
assert.equal(templates.getMissionTemplate('weekly-agent-quality-report')?.category, 'monitoring')
|
|
97
97
|
})
|
|
98
98
|
|
|
99
|
+
it('includes v1.6 love-path templates for review, research, and content', () => {
|
|
100
|
+
const expected = [
|
|
101
|
+
['codebase-review-sprint', 'productivity'],
|
|
102
|
+
['research-bureau-scan', 'research'],
|
|
103
|
+
['content-studio-cycle', 'communication'],
|
|
104
|
+
] as const
|
|
105
|
+
|
|
106
|
+
for (const [id, category] of expected) {
|
|
107
|
+
const template = templates.getMissionTemplate(id)
|
|
108
|
+
assert.ok(template, `expected ${id} template`)
|
|
109
|
+
assert.equal(template.category, category)
|
|
110
|
+
assert.ok(template.defaults.goal.length > 120, `${id} should have a concrete goal`)
|
|
111
|
+
assert.ok(template.defaults.successCriteria.length >= 3, `${id} should have acceptance criteria`)
|
|
112
|
+
assert.ok(template.defaults.budget.maxTurns, `${id} should have a turn budget`)
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
99
116
|
it('getMissionTemplate resolves known ids', () => {
|
|
100
117
|
const list = templates.listMissionTemplates()
|
|
101
118
|
const first = list[0]
|
|
@@ -303,6 +303,75 @@ export const BUILT_IN_MISSION_TEMPLATES: MissionTemplate[] = [
|
|
|
303
303
|
reportSchedule: report(DAY),
|
|
304
304
|
},
|
|
305
305
|
},
|
|
306
|
+
{
|
|
307
|
+
id: 'codebase-review-sprint',
|
|
308
|
+
name: 'Codebase Review Sprint',
|
|
309
|
+
description:
|
|
310
|
+
'Inspect a repository for user-facing bugs, fragile flows, missing tests, and release-readiness risks.',
|
|
311
|
+
icon: '🧪',
|
|
312
|
+
category: 'productivity',
|
|
313
|
+
tags: ['codebase', 'review', 'release', 'quality'],
|
|
314
|
+
setupNote:
|
|
315
|
+
'Set the repository path and risk areas in the goal. Keep code edits disabled unless the mission is explicitly converted into implementation work.',
|
|
316
|
+
defaults: {
|
|
317
|
+
title: 'Codebase Review Sprint',
|
|
318
|
+
goal:
|
|
319
|
+
'Review the current codebase for release-readiness. Inspect tests, build scripts, recent failure-prone flows, user-facing onboarding, desktop/package notes, and high-risk runtime paths. Produce a prioritized markdown report with bugs, missing tests, quick wins, and deferred risks. Do not edit files unless explicitly approved.',
|
|
320
|
+
successCriteria: [
|
|
321
|
+
'At least 5 concrete risks or no-finding checks are documented with file or workflow evidence',
|
|
322
|
+
'Recommended fixes are prioritized by user impact and implementation effort',
|
|
323
|
+
'The report separates release blockers from follow-up improvements',
|
|
324
|
+
],
|
|
325
|
+
budget: budget({ maxUsd: 2, maxTokens: 140_000, maxTurns: 140, maxWallclockSec: DAY }),
|
|
326
|
+
reportSchedule: report(6 * HOUR),
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
id: 'research-bureau-scan',
|
|
331
|
+
name: 'Research Bureau Scan',
|
|
332
|
+
description:
|
|
333
|
+
'Fan out a topic across multiple research angles, then synthesize evidence into a concise decision brief.',
|
|
334
|
+
icon: '🔎',
|
|
335
|
+
category: 'research',
|
|
336
|
+
tags: ['research', 'synthesis', 'competitive', 'decision'],
|
|
337
|
+
setupNote:
|
|
338
|
+
'Name the topic, sources, and decision the research should support before starting.',
|
|
339
|
+
defaults: {
|
|
340
|
+
title: 'Research Bureau Scan',
|
|
341
|
+
goal:
|
|
342
|
+
'Research the target topic from at least three angles: current market signals, technical feasibility, and user impact. Gather source-backed notes, compare conflicting evidence, and produce a concise decision brief with recommendation, confidence, and open questions.',
|
|
343
|
+
successCriteria: [
|
|
344
|
+
'At least 6 source-backed findings are captured',
|
|
345
|
+
'The final brief compares evidence across at least 3 research angles',
|
|
346
|
+
'Recommendation includes confidence level and open questions',
|
|
347
|
+
],
|
|
348
|
+
budget: budget({ maxUsd: 3, maxTokens: 180_000, maxTurns: 180, maxWallclockSec: 2 * DAY }),
|
|
349
|
+
reportSchedule: report(12 * HOUR),
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
id: 'content-studio-cycle',
|
|
354
|
+
name: 'Content Studio Cycle',
|
|
355
|
+
description:
|
|
356
|
+
'Turn a brief into draft, edit pass, publish checklist, and repurposed snippets for multiple channels.',
|
|
357
|
+
icon: '✍️',
|
|
358
|
+
category: 'communication',
|
|
359
|
+
tags: ['content', 'writing', 'editorial', 'launch'],
|
|
360
|
+
setupNote:
|
|
361
|
+
'Provide the audience, voice, channels, and any approval boundary. Public posting stays manual by default.',
|
|
362
|
+
defaults: {
|
|
363
|
+
title: 'Content Studio Cycle',
|
|
364
|
+
goal:
|
|
365
|
+
'Convert the supplied brief into a polished content package. Produce an outline, long-form draft, editor notes, publish checklist, and short repurposed snippets for the requested channels. Do not publish or post externally without approval.',
|
|
366
|
+
successCriteria: [
|
|
367
|
+
'Package includes outline, draft, editor notes, checklist, and channel snippets',
|
|
368
|
+
'Copy follows the requested audience and voice constraints',
|
|
369
|
+
'Any claims that need evidence are marked before publication',
|
|
370
|
+
],
|
|
371
|
+
budget: budget({ maxUsd: 2, maxTokens: 120_000, maxTurns: 120, maxWallclockSec: DAY }),
|
|
372
|
+
reportSchedule: report(6 * HOUR),
|
|
373
|
+
},
|
|
374
|
+
},
|
|
306
375
|
{
|
|
307
376
|
id: 'hello-world-demo',
|
|
308
377
|
name: 'Hello World Demo',
|
|
@@ -2,6 +2,31 @@ import assert from 'node:assert/strict'
|
|
|
2
2
|
import test from 'node:test'
|
|
3
3
|
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
|
|
4
4
|
|
|
5
|
+
test('protocol-service exposes v1.6 built-in templates for release, research, and builder flows', () => {
|
|
6
|
+
const output = runWithTempDataDir<{
|
|
7
|
+
ids: string[]
|
|
8
|
+
releaseOutputs: string[]
|
|
9
|
+
builderKinds: string[]
|
|
10
|
+
}>(`
|
|
11
|
+
const protocolsMod = await import('./src/lib/server/protocols/protocol-templates')
|
|
12
|
+
const protocols = protocolsMod.default || protocolsMod
|
|
13
|
+
const templates = protocols.listAllTemplates()
|
|
14
|
+
const release = templates.find((template) => template.id === 'release_readiness_panel')
|
|
15
|
+
const builder = templates.find((template) => template.id === 'builder_review_loop')
|
|
16
|
+
console.log(JSON.stringify({
|
|
17
|
+
ids: templates.map((template) => template.id),
|
|
18
|
+
releaseOutputs: release?.recommendedOutputs || [],
|
|
19
|
+
builderKinds: builder?.defaultPhases?.map((phase) => phase.kind) || [],
|
|
20
|
+
}))
|
|
21
|
+
`, { prefix: 'swarmclaw-protocol-templates-' })
|
|
22
|
+
|
|
23
|
+
assert.ok(output.ids.includes('release_readiness_panel'))
|
|
24
|
+
assert.ok(output.ids.includes('research_bureau_synthesis'))
|
|
25
|
+
assert.ok(output.ids.includes('builder_review_loop'))
|
|
26
|
+
assert.ok(output.releaseOutputs.includes('go/no-go decision'))
|
|
27
|
+
assert.deepEqual(output.builderKinds, ['present', 'collect_independent_inputs', 'compare', 'emit_tasks', 'summarize'])
|
|
28
|
+
})
|
|
29
|
+
|
|
5
30
|
test('protocol-service creates a hidden transcript run and completes a structured session', () => {
|
|
6
31
|
const output = runWithTempDataDir<{
|
|
7
32
|
status: string | null
|
|
@@ -77,6 +77,54 @@ export const BUILT_IN_PROTOCOL_TEMPLATES: ProtocolTemplate[] = [
|
|
|
77
77
|
{ id: 'summarize', kind: 'summarize', label: 'Summarize the outcome' },
|
|
78
78
|
],
|
|
79
79
|
},
|
|
80
|
+
{
|
|
81
|
+
id: 'release_readiness_panel',
|
|
82
|
+
name: 'Release Readiness Panel',
|
|
83
|
+
description: 'Review a candidate release, compare risks, and produce a go/no-go summary.',
|
|
84
|
+
builtIn: true,
|
|
85
|
+
singleAgentAllowed: true,
|
|
86
|
+
tags: ['release', 'quality', 'review'],
|
|
87
|
+
recommendedOutputs: ['blockers', 'risk summary', 'go/no-go decision'],
|
|
88
|
+
defaultPhases: [
|
|
89
|
+
{ id: 'present', kind: 'present', label: 'Set the release context' },
|
|
90
|
+
{ id: 'collect', kind: 'collect_independent_inputs', label: 'Collect release risks' },
|
|
91
|
+
{ id: 'compare', kind: 'compare', label: 'Compare blockers and evidence' },
|
|
92
|
+
{ id: 'decide', kind: 'decide', label: 'Make a go/no-go recommendation' },
|
|
93
|
+
{ id: 'summarize', kind: 'summarize', label: 'Summarize release readiness' },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'research_bureau_synthesis',
|
|
98
|
+
name: 'Research Bureau Synthesis',
|
|
99
|
+
description: 'Collect multiple research angles and turn them into one source-backed brief.',
|
|
100
|
+
builtIn: true,
|
|
101
|
+
singleAgentAllowed: true,
|
|
102
|
+
tags: ['research', 'synthesis', 'decision'],
|
|
103
|
+
recommendedOutputs: ['findings', 'recommendation', 'open questions'],
|
|
104
|
+
defaultPhases: [
|
|
105
|
+
{ id: 'present', kind: 'present', label: 'Frame the research question' },
|
|
106
|
+
{ id: 'collect', kind: 'collect_independent_inputs', label: 'Collect research angles' },
|
|
107
|
+
{ id: 'compare', kind: 'compare', label: 'Compare source-backed findings' },
|
|
108
|
+
{ id: 'decide', kind: 'decide', label: 'Recommend the current direction' },
|
|
109
|
+
{ id: 'summarize', kind: 'summarize', label: 'Write the decision brief' },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'builder_review_loop',
|
|
114
|
+
name: 'Builder Review Loop',
|
|
115
|
+
description: 'Move an implementation plan through builder, reviewer, decision, and follow-up summary.',
|
|
116
|
+
builtIn: true,
|
|
117
|
+
singleAgentAllowed: true,
|
|
118
|
+
tags: ['builder', 'review', 'implementation'],
|
|
119
|
+
recommendedOutputs: ['implementation notes', 'review risks', 'follow-up tasks'],
|
|
120
|
+
defaultPhases: [
|
|
121
|
+
{ id: 'present', kind: 'present', label: 'Present the work item' },
|
|
122
|
+
{ id: 'collect', kind: 'collect_independent_inputs', label: 'Collect builder and reviewer notes' },
|
|
123
|
+
{ id: 'compare', kind: 'compare', label: 'Compare implementation and review signals' },
|
|
124
|
+
{ id: 'emit_tasks', kind: 'emit_tasks', label: 'Emit follow-up tasks' },
|
|
125
|
+
{ id: 'summarize', kind: 'summarize', label: 'Summarize the delivery path' },
|
|
126
|
+
],
|
|
127
|
+
},
|
|
80
128
|
{
|
|
81
129
|
id: 'decision_round',
|
|
82
130
|
name: 'Decision Round',
|
|
@@ -35,6 +35,29 @@ describe('stripInternalJson', () => {
|
|
|
35
35
|
assert.equal(stripInternalJson(input).trim(), '')
|
|
36
36
|
})
|
|
37
37
|
|
|
38
|
+
it('removes multi-line working-state JSON with nested strings and arrays', () => {
|
|
39
|
+
const input = [
|
|
40
|
+
'Answer first.',
|
|
41
|
+
'{',
|
|
42
|
+
' "factsUpsert": [{ "title": "Nested", "value": "brace } inside a string" }],',
|
|
43
|
+
' "questionsUpsert": []',
|
|
44
|
+
'}',
|
|
45
|
+
'Answer second.',
|
|
46
|
+
].join('\n')
|
|
47
|
+
const result = stripInternalJson(input)
|
|
48
|
+
assert.equal(result, 'Answer first. Answer second.')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('preserves objects with internal-looking keys when schema validation fails', () => {
|
|
52
|
+
const input = 'The score object is { "quality_score": "high", "quality_reasoning": 42 }'
|
|
53
|
+
assert.equal(stripInternalJson(input), input)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('preserves taskIntent-only user JSON without classifier fields', () => {
|
|
57
|
+
const input = 'Example payload: { "taskIntent": "book a flight" }'
|
|
58
|
+
assert.equal(stripInternalJson(input), input)
|
|
59
|
+
})
|
|
60
|
+
|
|
38
61
|
it('handles multiple JSON blocks, only removing internal ones', () => {
|
|
39
62
|
const input = '{ "isDeliverableTask": true } some text { "foo": "bar" }'
|
|
40
63
|
const result = stripInternalJson(input)
|
|
@@ -9,27 +9,156 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Importable from both client and server code.
|
|
11
11
|
*/
|
|
12
|
+
import { z } from 'zod'
|
|
12
13
|
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// Classification JSON
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
16
17
|
|
|
17
18
|
const INTERNAL_JSON_KEYS = [
|
|
18
|
-
'
|
|
19
|
-
'
|
|
19
|
+
'factsUpsert', 'artifactsUpsert', 'planSteps', 'decisionsAppend',
|
|
20
|
+
'blockersUpsert', 'questionsUpsert', 'hypothesesUpsert', 'supersedeIds',
|
|
21
|
+
'taskIntent', 'isLightweightDirectChat', 'isDeliverableTask', 'quality_score',
|
|
22
|
+
'isBroadGoal', 'hasHumanSignals', 'explicitToolRequests', 'isResearchSynthesis',
|
|
23
|
+
'confidence', 'isIncomplete',
|
|
20
24
|
]
|
|
21
25
|
|
|
22
26
|
export const INTERNAL_KEY_RE = new RegExp(`"(?:${INTERNAL_JSON_KEYS.join('|')})"`)
|
|
23
27
|
|
|
28
|
+
const WorkingStatePatchLikeSchema = z.object({
|
|
29
|
+
factsUpsert: z.array(z.unknown()).optional(),
|
|
30
|
+
artifactsUpsert: z.array(z.unknown()).optional(),
|
|
31
|
+
planSteps: z.array(z.unknown()).optional(),
|
|
32
|
+
decisionsAppend: z.array(z.unknown()).optional(),
|
|
33
|
+
blockersUpsert: z.array(z.unknown()).optional(),
|
|
34
|
+
questionsUpsert: z.array(z.unknown()).optional(),
|
|
35
|
+
hypothesesUpsert: z.array(z.unknown()).optional(),
|
|
36
|
+
supersedeIds: z.array(z.unknown()).optional(),
|
|
37
|
+
}).passthrough()
|
|
38
|
+
|
|
39
|
+
const MessageClassificationLikeSchema = z.object({
|
|
40
|
+
taskIntent: z.string().optional(),
|
|
41
|
+
isLightweightDirectChat: z.boolean().optional(),
|
|
42
|
+
isDeliverableTask: z.boolean().optional(),
|
|
43
|
+
isBroadGoal: z.boolean().optional(),
|
|
44
|
+
hasHumanSignals: z.boolean().optional(),
|
|
45
|
+
explicitToolRequests: z.array(z.unknown()).optional(),
|
|
46
|
+
isResearchSynthesis: z.boolean().optional(),
|
|
47
|
+
confidence: z.number().optional(),
|
|
48
|
+
}).passthrough()
|
|
49
|
+
|
|
50
|
+
const ResponseCompletenessLikeSchema = z.object({
|
|
51
|
+
isIncomplete: z.boolean(),
|
|
52
|
+
}).passthrough()
|
|
53
|
+
|
|
54
|
+
const QualityScoreLikeSchema = z.object({
|
|
55
|
+
quality_score: z.number(),
|
|
56
|
+
quality_reasoning: z.string().optional(),
|
|
57
|
+
}).passthrough()
|
|
58
|
+
|
|
59
|
+
interface InternalPayloadRule {
|
|
60
|
+
schema: z.ZodType<unknown>
|
|
61
|
+
distinctiveKeys: string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const INTERNAL_PAYLOAD_RULES: InternalPayloadRule[] = [
|
|
65
|
+
{
|
|
66
|
+
schema: WorkingStatePatchLikeSchema,
|
|
67
|
+
distinctiveKeys: [
|
|
68
|
+
'factsUpsert',
|
|
69
|
+
'artifactsUpsert',
|
|
70
|
+
'planSteps',
|
|
71
|
+
'decisionsAppend',
|
|
72
|
+
'blockersUpsert',
|
|
73
|
+
'questionsUpsert',
|
|
74
|
+
'hypothesesUpsert',
|
|
75
|
+
'supersedeIds',
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
schema: MessageClassificationLikeSchema,
|
|
80
|
+
distinctiveKeys: [
|
|
81
|
+
'isLightweightDirectChat',
|
|
82
|
+
'isDeliverableTask',
|
|
83
|
+
'isBroadGoal',
|
|
84
|
+
'hasHumanSignals',
|
|
85
|
+
'explicitToolRequests',
|
|
86
|
+
'isResearchSynthesis',
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
schema: ResponseCompletenessLikeSchema,
|
|
91
|
+
distinctiveKeys: ['isIncomplete'],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
schema: QualityScoreLikeSchema,
|
|
95
|
+
distinctiveKeys: ['quality_score'],
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
function objectIsInternalMetadata(obj: Record<string, unknown>): boolean {
|
|
100
|
+
for (const { schema, distinctiveKeys } of INTERNAL_PAYLOAD_RULES) {
|
|
101
|
+
if (!distinctiveKeys.some((key) => key in obj)) continue
|
|
102
|
+
if (schema.safeParse(obj).success) return true
|
|
103
|
+
}
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function findBalancedJsonObjectEnd(text: string, start: number): number {
|
|
108
|
+
if (text.charAt(start) !== '{') return -1
|
|
109
|
+
let depth = 0
|
|
110
|
+
let inString = false
|
|
111
|
+
let escaped = false
|
|
112
|
+
for (let i = start; i < text.length; i += 1) {
|
|
113
|
+
const c = text.charAt(i)
|
|
114
|
+
if (inString) {
|
|
115
|
+
if (escaped) escaped = false
|
|
116
|
+
else if (c === '\\') escaped = true
|
|
117
|
+
else if (c === '"') inString = false
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
if (c === '"') {
|
|
121
|
+
inString = true
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (c === '{') depth += 1
|
|
125
|
+
else if (c === '}') {
|
|
126
|
+
depth -= 1
|
|
127
|
+
if (depth === 0) return i + 1
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return -1
|
|
131
|
+
}
|
|
132
|
+
|
|
24
133
|
/**
|
|
25
134
|
* Remove top-level `{ ... }` blocks that contain known internal classification keys.
|
|
26
|
-
* Handles multi-line JSON. Only strips blocks where at least one
|
|
135
|
+
* Handles nested and multi-line JSON. Only strips blocks where at least one
|
|
136
|
+
* distinctive internal key is present and the payload passes schema validation.
|
|
27
137
|
*/
|
|
28
138
|
export function stripInternalJson(text: string): string {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
139
|
+
let out = text || ''
|
|
140
|
+
for (let guard = 0; guard < 32; guard += 1) {
|
|
141
|
+
let removed = false
|
|
142
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
143
|
+
if (out.charAt(i) !== '{') continue
|
|
144
|
+
const end = findBalancedJsonObjectEnd(out, i)
|
|
145
|
+
if (end <= i) continue
|
|
146
|
+
const candidate = out.slice(i, end)
|
|
147
|
+
let parsed: unknown
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(candidate)
|
|
150
|
+
} catch {
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue
|
|
154
|
+
if (!objectIsInternalMetadata(parsed as Record<string, unknown>)) continue
|
|
155
|
+
out = (out.slice(0, i).replace(/\s+$/, '') + ' ' + out.slice(end).replace(/^\s+/, '')).trim()
|
|
156
|
+
removed = true
|
|
157
|
+
break
|
|
158
|
+
}
|
|
159
|
+
if (!removed) break
|
|
160
|
+
}
|
|
161
|
+
return out
|
|
33
162
|
}
|
|
34
163
|
|
|
35
164
|
// ---------------------------------------------------------------------------
|