@swarmclawai/swarmclaw 0.9.3 → 0.9.5
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 +12 -10
- package/bundled-skills/google-workspace/SKILL.md +2 -0
- package/package.json +1 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +1 -1
- package/src/app/api/clawhub/install/route.ts +2 -0
- package/src/app/api/skills/[id]/route.ts +4 -0
- package/src/app/api/skills/route.ts +4 -0
- package/src/components/agents/agent-sheet.tsx +5 -5
- package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
- package/src/lib/server/agents/agent-thread-session.ts +1 -1
- package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
- package/src/lib/server/agents/main-agent-loop.ts +259 -0
- package/src/lib/server/agents/orchestrator-lg.ts +12 -8
- package/src/lib/server/agents/orchestrator.ts +11 -7
- package/src/lib/server/agents/subagent-runtime.test.ts +99 -16
- package/src/lib/server/agents/subagent-runtime.ts +115 -19
- package/src/lib/server/agents/subagent-swarm.ts +3 -3
- package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
- package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +228 -3
- package/src/lib/server/chat-execution/chat-execution.ts +402 -149
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +74 -30
- package/src/lib/server/chat-execution/stream-agent-chat.ts +240 -62
- package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
- package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
- package/src/lib/server/connectors/contact-boundaries.ts +163 -0
- package/src/lib/server/connectors/manager.test.ts +626 -73
- package/src/lib/server/connectors/manager.ts +40 -9
- package/src/lib/server/connectors/session-consolidation.ts +2 -0
- package/src/lib/server/connectors/session-kind.ts +7 -0
- package/src/lib/server/connectors/session.test.ts +104 -0
- package/src/lib/server/connectors/session.ts +5 -2
- package/src/lib/server/identity-continuity.test.ts +4 -3
- package/src/lib/server/identity-continuity.ts +8 -4
- package/src/lib/server/memory/session-archive-memory.ts +2 -1
- package/src/lib/server/plugins.test.ts +263 -0
- package/src/lib/server/plugins.ts +406 -10
- package/src/lib/server/session-reset-policy.test.ts +17 -3
- package/src/lib/server/session-reset-policy.ts +4 -2
- package/src/lib/server/session-tools/connector.ts +11 -10
- package/src/lib/server/session-tools/context.ts +15 -1
- package/src/lib/server/session-tools/crud.ts +41 -7
- package/src/lib/server/session-tools/index.ts +44 -6
- package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
- package/src/lib/server/session-tools/memory.ts +12 -23
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +50 -0
- package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
- package/src/lib/server/session-tools/skill-runtime.ts +382 -0
- package/src/lib/server/session-tools/skills.ts +575 -0
- package/src/lib/server/session-tools/subagent.ts +3 -3
- package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
- package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
- package/src/lib/server/skills/skill-discovery.ts +4 -0
- package/src/lib/server/skills/skills-normalize.test.ts +28 -0
- package/src/lib/server/skills/skills-normalize.ts +93 -1
- package/src/lib/server/storage.ts +1 -1
- package/src/lib/server/tasks/task-followups.test.ts +124 -0
- package/src/lib/server/tasks/task-followups.ts +88 -13
- package/src/lib/server/tool-loop-detection.test.ts +21 -0
- package/src/lib/server/tool-loop-detection.ts +79 -0
- package/src/types/index.ts +158 -2
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ The OpenClaw Control Plane in SwarmClaw adds:
|
|
|
64
64
|
- Gateway import/export JSON, clone flows, and richer external runtime fleet visibility
|
|
65
65
|
- Agent and route-target preferences for steering work toward OpenClaw gateways by tags or use case (`local-dev`, `single-vps`, `private-tailnet`, `browser-heavy`, `team-control`)
|
|
66
66
|
|
|
67
|
-
The Agent Inspector Panel lets you edit OpenClaw files (`SOUL.md`, `IDENTITY.md`, `USER.md`), tune personality/system behavior, and manage OpenClaw-compatible skills. SwarmClaw also supports importing OpenClaw `SKILL.md` files from URL.
|
|
67
|
+
The Agent Inspector Panel lets you edit OpenClaw files (`SOUL.md`, `IDENTITY.md`, `USER.md`), tune personality/system behavior, and manage OpenClaw-compatible skills. Skills are now discoverable by default, pinned skills stay always-on for an agent, and executable `SKILL.md` metadata can drive on-demand runtime skill selection. SwarmClaw also supports importing OpenClaw `SKILL.md` files from URL.
|
|
68
68
|
|
|
69
69
|
To connect an agent to an OpenClaw gateway:
|
|
70
70
|
|
|
@@ -155,7 +155,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
|
|
|
155
155
|
The installer resolves the latest stable release tag and installs that version by default.
|
|
156
156
|
It also builds the production bundle so `npm run start` is ready immediately after install.
|
|
157
157
|
No Deno install is required; local sandbox execution is Docker-first with automatic host Node fallback.
|
|
158
|
-
To pin a version: `SWARMCLAW_VERSION=v0.9.
|
|
158
|
+
To pin a version: `SWARMCLAW_VERSION=v0.9.4 curl ... | bash`
|
|
159
159
|
|
|
160
160
|
Or run locally from the repo (friendly for non-technical users):
|
|
161
161
|
|
|
@@ -248,7 +248,7 @@ Notes:
|
|
|
248
248
|
- **Connector bridge** - Discord, Slack, Telegram, WhatsApp, Teams, Matrix, OpenClaw, and others
|
|
249
249
|
- **Memory + knowledge** - hybrid search, memory graph, shared knowledge store, and auto-journaling
|
|
250
250
|
- **Operational guardrails** - capability policy, cost tracking, provider health, and credential failover
|
|
251
|
-
- **Extensibility** - plugin hooks/tools/UI extensions plus reusable skills
|
|
251
|
+
- **Extensibility** - plugin hooks/tools/UI extensions plus reusable skills with discovery-by-default runtime selection
|
|
252
252
|
|
|
253
253
|
For the full feature matrix and per-capability details, see:
|
|
254
254
|
- https://swarmclaw.ai/docs
|
|
@@ -364,6 +364,8 @@ Connector ingress now also supports optional pairing/allowlist policy:
|
|
|
364
364
|
- `/think` command can set connector thread thinking level (`low`, `medium`, `high`)
|
|
365
365
|
- Session overrides also support per-thread `/reply`, `/scope`, `/thread`, `/provider`, `/model`, `/idle`, `/maxage`, and `/reset` controls
|
|
366
366
|
|
|
367
|
+
Direct connector sessions are now the only source of routable connector state. Main agent threads no longer inherit outbound connector targets from mirrored history, and tool-only heartbeats stay out of visible main-thread history.
|
|
368
|
+
|
|
367
369
|
## Agent Tools
|
|
368
370
|
|
|
369
371
|
Agents can use the following tools when enabled:
|
|
@@ -727,15 +729,15 @@ On `v*` tags, GitHub Actions will:
|
|
|
727
729
|
2. Create a GitHub Release
|
|
728
730
|
3. Build and publish Docker images to `ghcr.io/swarmclawai/swarmclaw` (`:vX.Y.Z`, `:latest`, `:sha-*`)
|
|
729
731
|
|
|
730
|
-
#### v0.9.
|
|
732
|
+
#### v0.9.5 Release Readiness Notes
|
|
731
733
|
|
|
732
|
-
Before shipping `v0.9.
|
|
734
|
+
Before shipping `v0.9.5`, confirm the following user-facing changes are reflected in docs:
|
|
733
735
|
|
|
734
|
-
1.
|
|
735
|
-
2.
|
|
736
|
-
3.
|
|
737
|
-
4. Site and README install/version strings are updated to `v0.9.
|
|
738
|
-
5. The release tag, npm package version, and generated GitHub release install snippet all agree on the non-prefixed npm version (`0.9.
|
|
736
|
+
1. Plugin/runtime docs mention the new typed lifecycle hooks: `beforePromptBuild`, `beforeToolCall`, `beforeModelResolve`, `llmInput`, `llmOutput`, `toolResultPersist`, `beforeMessageWrite`, plus session and subagent lifecycle hooks.
|
|
737
|
+
2. Connector/memory docs explain that quiet-boundary memories are matched by identifiers and agent aliases, with explicit boundary metadata support, rather than relying on hardcoded person-name fallbacks.
|
|
738
|
+
3. Skills docs still explain that local skills are discoverable by default, pinned skills stay always-on, and `use_skill` is the runtime path for selection/loading/dispatch.
|
|
739
|
+
4. Site and README install/version strings are updated to `v0.9.5`, including release notes index text and any pinned install snippets.
|
|
740
|
+
5. The release tag, npm package version, and generated GitHub release install snippet all agree on the non-prefixed npm version (`0.9.5`) versus the git tag (`v0.9.5`).
|
|
739
741
|
|
|
740
742
|
## CLI
|
|
741
743
|
|
|
@@ -4,6 +4,8 @@ description: Use Google Workspace CLI (`gws`) for Drive, Docs, Sheets, Gmail, Ca
|
|
|
4
4
|
homepage: https://github.com/googleworkspace/cli
|
|
5
5
|
metadata:
|
|
6
6
|
openclaw:
|
|
7
|
+
toolNames: [google_workspace, gws]
|
|
8
|
+
capabilities: [google-workspace, google-docs, google-drive, google-sheets, gmail, google-calendar, google-chat]
|
|
7
9
|
requires:
|
|
8
10
|
bins: [gws]
|
|
9
11
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -189,7 +189,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|
|
189
189
|
syntheticSession.fallbackCredentialIds = route?.fallbackCredentialIds || syntheticSession.fallbackCredentialIds || []
|
|
190
190
|
syntheticSession.gatewayProfileId = route?.gatewayProfileId ?? syntheticSession.gatewayProfileId ?? null
|
|
191
191
|
syntheticSession.apiEndpoint = resolvedEndpoint
|
|
192
|
-
const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent)
|
|
192
|
+
const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent, syntheticSession.cwd)
|
|
193
193
|
const chatroomContext = buildChatroomSystemPrompt(freshChatroom, agents, agent.id)
|
|
194
194
|
const fullSystemPrompt = [agentSystemPrompt, chatroomContext].filter(Boolean).join('\n\n')
|
|
195
195
|
const history = buildHistoryForAgent(freshChatroom, agent.id, imagePath, attachedFiles)
|
|
@@ -45,6 +45,8 @@ export async function POST(req: Request) {
|
|
|
45
45
|
homepage: normalized.homepage,
|
|
46
46
|
primaryEnv: normalized.primaryEnv,
|
|
47
47
|
skillKey: normalized.skillKey,
|
|
48
|
+
toolNames: normalized.toolNames,
|
|
49
|
+
capabilities: normalized.capabilities,
|
|
48
50
|
always: normalized.always,
|
|
49
51
|
installOptions: normalized.installOptions,
|
|
50
52
|
skillRequirements: normalized.skillRequirements,
|
|
@@ -37,11 +37,15 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
37
37
|
homepage: normalized.homepage ?? null,
|
|
38
38
|
primaryEnv: normalized.primaryEnv ?? null,
|
|
39
39
|
skillKey: normalized.skillKey ?? null,
|
|
40
|
+
toolNames: normalized.toolNames,
|
|
41
|
+
capabilities: normalized.capabilities,
|
|
40
42
|
always: typeof normalized.always === 'boolean' ? normalized.always : false,
|
|
41
43
|
installOptions: normalized.installOptions,
|
|
42
44
|
skillRequirements: normalized.skillRequirements,
|
|
43
45
|
detectedEnvVars: normalized.detectedEnvVars,
|
|
44
46
|
security: normalized.security,
|
|
47
|
+
invocation: normalized.invocation,
|
|
48
|
+
commandDispatch: normalized.commandDispatch,
|
|
45
49
|
frontmatter: normalized.frontmatter,
|
|
46
50
|
scope: updatedScope,
|
|
47
51
|
agentIds: updatedAgentIds,
|
|
@@ -32,11 +32,15 @@ export async function POST(req: Request) {
|
|
|
32
32
|
homepage: normalized.homepage,
|
|
33
33
|
primaryEnv: normalized.primaryEnv,
|
|
34
34
|
skillKey: normalized.skillKey,
|
|
35
|
+
toolNames: normalized.toolNames,
|
|
36
|
+
capabilities: normalized.capabilities,
|
|
35
37
|
always: normalized.always,
|
|
36
38
|
installOptions: normalized.installOptions,
|
|
37
39
|
skillRequirements: normalized.skillRequirements,
|
|
38
40
|
detectedEnvVars: normalized.detectedEnvVars,
|
|
39
41
|
security: normalized.security,
|
|
42
|
+
invocation: normalized.invocation,
|
|
43
|
+
commandDispatch: normalized.commandDispatch,
|
|
40
44
|
frontmatter: normalized.frontmatter,
|
|
41
45
|
scope,
|
|
42
46
|
agentIds,
|
|
@@ -2213,7 +2213,7 @@ export function AgentSheet() {
|
|
|
2213
2213
|
<div ref={(node) => { sectionRefs.current.tools = node }}>
|
|
2214
2214
|
<SectionCard
|
|
2215
2215
|
title="Tools & Delegation"
|
|
2216
|
-
description="Enable plugins, skills, MCP tools, and delegation behavior for this agent."
|
|
2216
|
+
description="Enable plugins, pin preferred skills, connect MCP tools, and configure delegation behavior for this agent."
|
|
2217
2217
|
>
|
|
2218
2218
|
{/* Plugins — hidden for providers that manage capabilities outside LangGraph */}
|
|
2219
2219
|
{!hasNativeCapabilities && (
|
|
@@ -2281,7 +2281,7 @@ export function AgentSheet() {
|
|
|
2281
2281
|
<div className="mb-8">
|
|
2282
2282
|
<div className="flex items-center justify-between mb-2">
|
|
2283
2283
|
<label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em]">
|
|
2284
|
-
Skills <span className="normal-case tracking-normal font-normal text-text-3">(from ~/.claude/skills/)</span>
|
|
2284
|
+
Pinned Claude Skills <span className="normal-case tracking-normal font-normal text-text-3">(from ~/.claude/skills/)</span>
|
|
2285
2285
|
</label>
|
|
2286
2286
|
<button
|
|
2287
2287
|
onClick={loadClaudeSkills}
|
|
@@ -2297,7 +2297,7 @@ export function AgentSheet() {
|
|
|
2297
2297
|
Refresh
|
|
2298
2298
|
</button>
|
|
2299
2299
|
</div>
|
|
2300
|
-
<p className="text-[12px] text-text-3/60 mb-3">
|
|
2300
|
+
<p className="text-[12px] text-text-3/60 mb-3">Optional preference list. Pinned Claude skills are called out explicitly when this agent is delegated work.</p>
|
|
2301
2301
|
{claudeSkills.length > 0 ? (
|
|
2302
2302
|
<div className="flex flex-wrap gap-2">
|
|
2303
2303
|
{claudeSkills.map((s) => {
|
|
@@ -2328,9 +2328,9 @@ export function AgentSheet() {
|
|
|
2328
2328
|
{Object.keys(dynamicSkills).length > 0 && (
|
|
2329
2329
|
<div className="mb-8">
|
|
2330
2330
|
<label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
|
|
2331
|
-
|
|
2331
|
+
Pinned Skills <span className="normal-case tracking-normal font-normal text-text-3">(from Skills manager)</span>
|
|
2332
2332
|
</label>
|
|
2333
|
-
<p className="text-[12px] text-text-3/60 mb-3">
|
|
2333
|
+
<p className="text-[12px] text-text-3/60 mb-3">All ready local skills are discoverable by default. Pin skills here only when they should stay in this agent's prompt as always-on guidance.</p>
|
|
2334
2334
|
<div className="flex flex-wrap gap-2">
|
|
2335
2335
|
{Object.values(dynamicSkills).map((s) => {
|
|
2336
2336
|
const active = skillIds.includes(s.id)
|
|
@@ -174,4 +174,68 @@ describe('ensureAgentThreadSession', () => {
|
|
|
174
174
|
|
|
175
175
|
assert.equal(output.session.openclawAgentId, 'main')
|
|
176
176
|
})
|
|
177
|
+
|
|
178
|
+
it('clears stale connector routing state from an existing agent shortcut session', () => {
|
|
179
|
+
const output = runWithTempDataDir(`
|
|
180
|
+
const storageMod = await import('@/lib/server/storage')
|
|
181
|
+
const storage = storageMod.default || storageMod['module.exports'] || storageMod
|
|
182
|
+
const helperMod = await import('@/lib/server/agents/agent-thread-session')
|
|
183
|
+
const ensureAgentThreadSession = helperMod.ensureAgentThreadSession
|
|
184
|
+
|| helperMod.default?.ensureAgentThreadSession
|
|
185
|
+
|| helperMod['module.exports']?.ensureAgentThreadSession
|
|
186
|
+
|
|
187
|
+
const now = Date.now()
|
|
188
|
+
storage.saveAgents({
|
|
189
|
+
molly: {
|
|
190
|
+
id: 'molly',
|
|
191
|
+
name: 'Molly',
|
|
192
|
+
provider: 'openai',
|
|
193
|
+
model: 'gpt-test',
|
|
194
|
+
credentialId: null,
|
|
195
|
+
apiEndpoint: null,
|
|
196
|
+
fallbackCredentialIds: [],
|
|
197
|
+
heartbeatEnabled: true,
|
|
198
|
+
heartbeatIntervalSec: 600,
|
|
199
|
+
threadSessionId: 'agent-chat-molly-existing',
|
|
200
|
+
createdAt: now,
|
|
201
|
+
updatedAt: now,
|
|
202
|
+
plugins: ['memory'],
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
storage.saveSessions({
|
|
206
|
+
'agent-chat-molly-existing': {
|
|
207
|
+
id: 'agent-chat-molly-existing',
|
|
208
|
+
name: 'Molly',
|
|
209
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
210
|
+
user: 'default',
|
|
211
|
+
provider: 'openai',
|
|
212
|
+
model: 'gpt-old',
|
|
213
|
+
claudeSessionId: null,
|
|
214
|
+
codexThreadId: null,
|
|
215
|
+
opencodeSessionId: null,
|
|
216
|
+
delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
|
|
217
|
+
messages: [],
|
|
218
|
+
createdAt: now,
|
|
219
|
+
lastActiveAt: now,
|
|
220
|
+
sessionType: 'human',
|
|
221
|
+
agentId: 'molly',
|
|
222
|
+
plugins: ['memory'],
|
|
223
|
+
connectorContext: {
|
|
224
|
+
connectorId: 'conn-1',
|
|
225
|
+
channelId: 'wrong-chat',
|
|
226
|
+
senderId: 'wrong-user',
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const session = ensureAgentThreadSession('molly')
|
|
232
|
+
const persisted = storage.loadSessions()[session.id]
|
|
233
|
+
|
|
234
|
+
console.log(JSON.stringify({
|
|
235
|
+
connectorContext: persisted.connectorContext || null,
|
|
236
|
+
}))
|
|
237
|
+
`)
|
|
238
|
+
|
|
239
|
+
assert.equal(output.connectorContext, null)
|
|
240
|
+
})
|
|
177
241
|
})
|
|
@@ -64,7 +64,7 @@ function buildThreadSession(agent: Agent, sessionId: string, user: string, creat
|
|
|
64
64
|
connectorIdleTimeoutSec: existing?.connectorIdleTimeoutSec || null,
|
|
65
65
|
connectorMaxAgeSec: existing?.connectorMaxAgeSec || null,
|
|
66
66
|
mailbox: existing?.mailbox || null,
|
|
67
|
-
connectorContext:
|
|
67
|
+
connectorContext: undefined,
|
|
68
68
|
lastAutoMemoryAt: existing?.lastAutoMemoryAt || null,
|
|
69
69
|
lastHeartbeatText: existing?.lastHeartbeatText || null,
|
|
70
70
|
lastHeartbeatSentAt: existing?.lastHeartbeatSentAt || null,
|
|
@@ -195,6 +195,83 @@ describe('main-agent-loop advanced', () => {
|
|
|
195
195
|
assert.equal(output.followupOk, null, 'no followup on terminal ack')
|
|
196
196
|
})
|
|
197
197
|
|
|
198
|
+
it('persists and upgrades a skill blocker across recommend/install steps', () => {
|
|
199
|
+
const output = runWithTempDataDir(`
|
|
200
|
+
${sessionSetupScript()}
|
|
201
|
+
|
|
202
|
+
mainLoop.handleMainLoopRunResult({
|
|
203
|
+
sessionId: 'main',
|
|
204
|
+
message: 'Continue the Google Workspace automation.',
|
|
205
|
+
internal: true,
|
|
206
|
+
source: 'heartbeat',
|
|
207
|
+
resultText: 'Blocked: missing capability for Google Workspace CLI in this environment.',
|
|
208
|
+
})
|
|
209
|
+
const state1 = mainLoop.getMainLoopStateForSession('main')
|
|
210
|
+
|
|
211
|
+
mainLoop.handleMainLoopRunResult({
|
|
212
|
+
sessionId: 'main',
|
|
213
|
+
message: 'Continue the Google Workspace automation.',
|
|
214
|
+
internal: true,
|
|
215
|
+
source: 'heartbeat',
|
|
216
|
+
resultText: 'Checked local skills.',
|
|
217
|
+
toolEvents: [{
|
|
218
|
+
name: 'manage_skills',
|
|
219
|
+
input: JSON.stringify({ action: 'recommend_for_task', task: 'Google Workspace automation' }),
|
|
220
|
+
output: JSON.stringify({ local: [{ name: 'google-workspace', status: 'needs_install' }] }),
|
|
221
|
+
}],
|
|
222
|
+
})
|
|
223
|
+
const state2 = mainLoop.getMainLoopStateForSession('main')
|
|
224
|
+
|
|
225
|
+
mainLoop.handleMainLoopRunResult({
|
|
226
|
+
sessionId: 'main',
|
|
227
|
+
message: 'Continue the Google Workspace automation.',
|
|
228
|
+
internal: true,
|
|
229
|
+
source: 'heartbeat',
|
|
230
|
+
resultText: 'Install approval requested.',
|
|
231
|
+
toolEvents: [{
|
|
232
|
+
name: 'manage_skills',
|
|
233
|
+
input: JSON.stringify({ action: 'install', name: 'google-workspace' }),
|
|
234
|
+
output: JSON.stringify({
|
|
235
|
+
requiresApproval: true,
|
|
236
|
+
approval: { id: 'appr-123' },
|
|
237
|
+
skill: { name: 'google-workspace' },
|
|
238
|
+
}),
|
|
239
|
+
}],
|
|
240
|
+
})
|
|
241
|
+
const state3 = mainLoop.getMainLoopStateForSession('main')
|
|
242
|
+
|
|
243
|
+
const heartbeatPrompt = mainLoop.buildMainLoopHeartbeatPrompt({
|
|
244
|
+
id: 'main',
|
|
245
|
+
shortcutForAgentId: 'agent-a',
|
|
246
|
+
agentId: 'agent-a',
|
|
247
|
+
heartbeatEnabled: true,
|
|
248
|
+
messages: [{ role: 'user', text: 'Deploy the system.', time: 1 }],
|
|
249
|
+
}, 'Base prompt')
|
|
250
|
+
|
|
251
|
+
console.log(JSON.stringify({
|
|
252
|
+
firstStatus: state1?.skillBlocker?.status ?? null,
|
|
253
|
+
firstSummary: state1?.skillBlocker?.summary ?? null,
|
|
254
|
+
secondStatus: state2?.skillBlocker?.status ?? null,
|
|
255
|
+
secondCandidates: state2?.skillBlocker?.candidateSkills ?? [],
|
|
256
|
+
secondAttempts: state2?.skillBlocker?.attempts ?? -1,
|
|
257
|
+
thirdStatus: state3?.skillBlocker?.status ?? null,
|
|
258
|
+
thirdApprovalId: state3?.skillBlocker?.approvalId ?? null,
|
|
259
|
+
promptHasSkillBlocker: heartbeatPrompt.includes('Active skill blocker:'),
|
|
260
|
+
promptHasApproval: heartbeatPrompt.includes('Pending approval: appr-123'),
|
|
261
|
+
}))
|
|
262
|
+
`)
|
|
263
|
+
|
|
264
|
+
assert.equal(output.firstStatus, 'new')
|
|
265
|
+
assert.match(String(output.firstSummary), /missing capability/i)
|
|
266
|
+
assert.equal(output.secondStatus, 'recommended')
|
|
267
|
+
assert.deepEqual(output.secondCandidates, ['google-workspace'])
|
|
268
|
+
assert.equal(output.secondAttempts, 1)
|
|
269
|
+
assert.equal(output.thirdStatus, 'approval_requested')
|
|
270
|
+
assert.equal(output.thirdApprovalId, 'appr-123')
|
|
271
|
+
assert.equal(output.promptHasSkillBlocker, true)
|
|
272
|
+
assert.equal(output.promptHasApproval, true)
|
|
273
|
+
})
|
|
274
|
+
|
|
198
275
|
it('resets metadata miss count when structured metadata returns and keeps terminal acks at zero', () => {
|
|
199
276
|
const meta = heartbeatMetaLine('progress', 'deploy', 'continue')
|
|
200
277
|
const output = runWithTempDataDir(`
|
|
@@ -44,6 +44,15 @@ export interface MainLoopState {
|
|
|
44
44
|
followupChainCount: number
|
|
45
45
|
metaMissCount: number
|
|
46
46
|
workingMemoryNotes: string[]
|
|
47
|
+
skillBlocker: {
|
|
48
|
+
summary: string
|
|
49
|
+
query: string | null
|
|
50
|
+
status: 'new' | 'searched' | 'recommended' | 'approval_requested' | 'installed'
|
|
51
|
+
attempts: number
|
|
52
|
+
candidateSkills: string[]
|
|
53
|
+
approvalId: string | null
|
|
54
|
+
updatedAt: number
|
|
55
|
+
} | null
|
|
47
56
|
lastMemoryNoteAt: number | null
|
|
48
57
|
lastPlannedAt: number | null
|
|
49
58
|
lastReviewedAt: number | null
|
|
@@ -139,6 +148,7 @@ function defaultState(): MainLoopState {
|
|
|
139
148
|
followupChainCount: 0,
|
|
140
149
|
metaMissCount: 0,
|
|
141
150
|
workingMemoryNotes: [],
|
|
151
|
+
skillBlocker: null,
|
|
142
152
|
lastMemoryNoteAt: null,
|
|
143
153
|
lastPlannedAt: null,
|
|
144
154
|
lastReviewedAt: null,
|
|
@@ -220,6 +230,42 @@ function normalizeTimeline(value: unknown): MainLoopState['timeline'] {
|
|
|
220
230
|
return out
|
|
221
231
|
}
|
|
222
232
|
|
|
233
|
+
function normalizeSkillBlocker(value: unknown): MainLoopState['skillBlocker'] {
|
|
234
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
235
|
+
const record = value as Record<string, unknown>
|
|
236
|
+
const summary = cleanText(record.summary, 240)
|
|
237
|
+
if (!summary) return null
|
|
238
|
+
const status = record.status === 'new'
|
|
239
|
+
|| record.status === 'searched'
|
|
240
|
+
|| record.status === 'recommended'
|
|
241
|
+
|| record.status === 'approval_requested'
|
|
242
|
+
|| record.status === 'installed'
|
|
243
|
+
? record.status
|
|
244
|
+
: 'new'
|
|
245
|
+
const query = cleanText(record.query, 240)
|
|
246
|
+
const candidateSkills = Array.isArray(record.candidateSkills)
|
|
247
|
+
? uniqueStrings(record.candidateSkills.filter((entry): entry is string => typeof entry === 'string'), 6)
|
|
248
|
+
: []
|
|
249
|
+
const approvalId = typeof record.approvalId === 'string' && record.approvalId.trim()
|
|
250
|
+
? record.approvalId.trim()
|
|
251
|
+
: null
|
|
252
|
+
const updatedAt = typeof record.updatedAt === 'number' && Number.isFinite(record.updatedAt)
|
|
253
|
+
? Math.trunc(record.updatedAt)
|
|
254
|
+
: now()
|
|
255
|
+
const attempts = typeof record.attempts === 'number' && Number.isFinite(record.attempts)
|
|
256
|
+
? Math.max(0, Math.min(6, Math.trunc(record.attempts)))
|
|
257
|
+
: 0
|
|
258
|
+
return {
|
|
259
|
+
summary,
|
|
260
|
+
query,
|
|
261
|
+
status,
|
|
262
|
+
attempts,
|
|
263
|
+
candidateSkills,
|
|
264
|
+
approvalId,
|
|
265
|
+
updatedAt,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
223
269
|
function parseHeartbeatMeta(text: string): { goal?: string; status?: MainLoopState['status']; summary?: string; nextAction?: string } | null {
|
|
224
270
|
const match = (text || '').match(HEARTBEAT_META_RE)
|
|
225
271
|
if (!match) return null
|
|
@@ -257,6 +303,7 @@ function clampState(state: MainLoopState): MainLoopState {
|
|
|
257
303
|
state.metaMissCount = Math.max(0, Math.min(100, Math.trunc(state.metaMissCount || 0)))
|
|
258
304
|
state.missionTokens = Math.max(0, Math.trunc(state.missionTokens || 0))
|
|
259
305
|
state.missionCostUsd = Math.max(0, Number.isFinite(state.missionCostUsd) ? Number(state.missionCostUsd) : 0)
|
|
306
|
+
state.skillBlocker = normalizeSkillBlocker(state.skillBlocker)
|
|
260
307
|
state.updatedAt = typeof state.updatedAt === 'number' && Number.isFinite(state.updatedAt) ? Math.trunc(state.updatedAt) : now()
|
|
261
308
|
return state
|
|
262
309
|
}
|
|
@@ -286,6 +333,7 @@ function normalizeState(input?: Partial<MainLoopState> | null): MainLoopState {
|
|
|
286
333
|
if (typeof input.followupChainCount === 'number') next.followupChainCount = input.followupChainCount
|
|
287
334
|
if (typeof input.metaMissCount === 'number') next.metaMissCount = input.metaMissCount
|
|
288
335
|
if (Array.isArray(input.workingMemoryNotes)) next.workingMemoryNotes = [...input.workingMemoryNotes]
|
|
336
|
+
if (input.skillBlocker === null || typeof input.skillBlocker === 'object') next.skillBlocker = input.skillBlocker
|
|
289
337
|
if (typeof input.lastMemoryNoteAt === 'number' || input.lastMemoryNoteAt === null) next.lastMemoryNoteAt = input.lastMemoryNoteAt ?? null
|
|
290
338
|
if (typeof input.lastPlannedAt === 'number' || input.lastPlannedAt === null) next.lastPlannedAt = input.lastPlannedAt ?? null
|
|
291
339
|
if (typeof input.lastReviewedAt === 'number' || input.lastReviewedAt === null) next.lastReviewedAt = input.lastReviewedAt ?? null
|
|
@@ -404,6 +452,198 @@ function formatGoalContract(goalContract: GoalContract | null): string {
|
|
|
404
452
|
return lines.join('\n')
|
|
405
453
|
}
|
|
406
454
|
|
|
455
|
+
function parseJsonRecord(value: string | undefined): Record<string, unknown> | null {
|
|
456
|
+
if (typeof value !== 'string' || !value.trim()) return null
|
|
457
|
+
try {
|
|
458
|
+
const parsed = JSON.parse(value)
|
|
459
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
460
|
+
return parsed as Record<string, unknown>
|
|
461
|
+
}
|
|
462
|
+
} catch {
|
|
463
|
+
// ignore non-JSON outputs
|
|
464
|
+
}
|
|
465
|
+
return null
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function summarizeSelectedSkillRuntime(session: MainSessionLike | null): string {
|
|
469
|
+
const runtimeState = session?.skillRuntimeState
|
|
470
|
+
if (!runtimeState || typeof runtimeState !== 'object') return ''
|
|
471
|
+
const state = runtimeState as Record<string, unknown>
|
|
472
|
+
const selectedSkillName = cleanText(state.selectedSkillName, 160)
|
|
473
|
+
if (!selectedSkillName) return ''
|
|
474
|
+
const lines = [`Selected skill: ${selectedSkillName}`]
|
|
475
|
+
const lastAction = typeof state.lastAction === 'string' ? state.lastAction.trim() : ''
|
|
476
|
+
const lastRunToolName = cleanText(state.lastRunToolName, 120)
|
|
477
|
+
if (lastAction) lines.push(`Last skill action: ${lastAction}`)
|
|
478
|
+
if (lastRunToolName) lines.push(`Last dispatched tool: ${lastRunToolName}`)
|
|
479
|
+
return lines.join('\n')
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function summarizeUseSkillToolEvent(toolEvents: MessageToolEvent[]): string | null {
|
|
483
|
+
const event = [...toolEvents].reverse().find((entry) => entry.name === 'use_skill')
|
|
484
|
+
if (!event?.output) return null
|
|
485
|
+
const output = parseJsonRecord(event.output)
|
|
486
|
+
if (!output) return null
|
|
487
|
+
const skill = output.skill && typeof output.skill === 'object'
|
|
488
|
+
? output.skill as Record<string, unknown>
|
|
489
|
+
: null
|
|
490
|
+
const skillName = typeof skill?.name === 'string' && skill.name.trim()
|
|
491
|
+
? skill.name.trim()
|
|
492
|
+
: typeof output.selectedSkillName === 'string' && output.selectedSkillName.trim()
|
|
493
|
+
? output.selectedSkillName.trim()
|
|
494
|
+
: ''
|
|
495
|
+
if (!skillName) return null
|
|
496
|
+
if (output.executed === true) {
|
|
497
|
+
const toolName = typeof output.dispatchedTool === 'string' ? output.dispatchedTool.trim() : ''
|
|
498
|
+
return toolName ? `Skill run: ${skillName} via ${toolName}` : `Skill run: ${skillName}`
|
|
499
|
+
}
|
|
500
|
+
if (output.loaded === true) return `Loaded skill guidance: ${skillName}`
|
|
501
|
+
if (output.selected === true) return `Selected skill: ${skillName}`
|
|
502
|
+
return `Skill context: ${skillName}`
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function firstMatchingLine(text: string, pattern: RegExp): string | null {
|
|
506
|
+
for (const line of (text || '').split('\n')) {
|
|
507
|
+
const trimmed = line.trim()
|
|
508
|
+
if (trimmed && pattern.test(trimmed)) return trimmed
|
|
509
|
+
}
|
|
510
|
+
return null
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function deriveSkillBlockerFromToolEvents(params: {
|
|
514
|
+
toolEvents: MessageToolEvent[]
|
|
515
|
+
current: MainLoopState['skillBlocker']
|
|
516
|
+
query: string | null
|
|
517
|
+
}): MainLoopState['skillBlocker'] {
|
|
518
|
+
const event = [...params.toolEvents].reverse().find((entry) => entry.name === 'manage_skills')
|
|
519
|
+
if (!event) return params.current
|
|
520
|
+
const input = parseJsonRecord(event.input)
|
|
521
|
+
const output = parseJsonRecord(event.output)
|
|
522
|
+
const action = typeof input?.action === 'string' ? input.action.trim().toLowerCase() : ''
|
|
523
|
+
const nowTs = now()
|
|
524
|
+
|
|
525
|
+
const candidateNames = (() => {
|
|
526
|
+
const local = Array.isArray(output?.local)
|
|
527
|
+
? output?.local
|
|
528
|
+
: Array.isArray(output)
|
|
529
|
+
? output
|
|
530
|
+
: []
|
|
531
|
+
return uniqueStrings(local.flatMap((entry) => {
|
|
532
|
+
if (!entry || typeof entry !== 'object') return []
|
|
533
|
+
const record = entry as Record<string, unknown>
|
|
534
|
+
const nestedSkill = record.skill && typeof record.skill === 'object' ? record.skill as Record<string, unknown> : null
|
|
535
|
+
const name = typeof record.skillName === 'string'
|
|
536
|
+
? record.skillName
|
|
537
|
+
: typeof record.name === 'string'
|
|
538
|
+
? record.name
|
|
539
|
+
: typeof nestedSkill?.name === 'string'
|
|
540
|
+
? nestedSkill.name
|
|
541
|
+
: ''
|
|
542
|
+
return name ? [name] : []
|
|
543
|
+
}), 4)
|
|
544
|
+
})()
|
|
545
|
+
|
|
546
|
+
const installSkillName = (() => {
|
|
547
|
+
if (typeof output?.skillName === 'string' && output.skillName.trim()) return output.skillName.trim()
|
|
548
|
+
if (output?.skill && typeof output.skill === 'object') {
|
|
549
|
+
const nested = output.skill as Record<string, unknown>
|
|
550
|
+
if (typeof nested.name === 'string' && nested.name.trim()) return nested.name.trim()
|
|
551
|
+
}
|
|
552
|
+
if (typeof input?.name === 'string' && input.name.trim()) return input.name.trim()
|
|
553
|
+
return candidateNames[0] || null
|
|
554
|
+
})()
|
|
555
|
+
|
|
556
|
+
if (action === 'install') {
|
|
557
|
+
if (output?.ok === true && output.installed === true) {
|
|
558
|
+
return normalizeSkillBlocker({
|
|
559
|
+
summary: installSkillName
|
|
560
|
+
? `Installed skill "${installSkillName}". Use it on the next step instead of re-discovering skills.`
|
|
561
|
+
: 'Installed a skill for this blocker. Use it before re-running discovery.',
|
|
562
|
+
query: params.query,
|
|
563
|
+
status: 'installed',
|
|
564
|
+
attempts: (params.current?.attempts || 0) + 1,
|
|
565
|
+
candidateSkills: installSkillName ? [installSkillName] : candidateNames,
|
|
566
|
+
approvalId: null,
|
|
567
|
+
updatedAt: nowTs,
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
const approval = output?.approval && typeof output.approval === 'object'
|
|
571
|
+
? output.approval as Record<string, unknown>
|
|
572
|
+
: null
|
|
573
|
+
const approvalId = typeof approval?.id === 'string' ? approval.id.trim() : ''
|
|
574
|
+
if (output?.requiresApproval === true || approvalId) {
|
|
575
|
+
return normalizeSkillBlocker({
|
|
576
|
+
summary: installSkillName
|
|
577
|
+
? `Install approval is pending for skill "${installSkillName}". Wait for the approval instead of retrying discovery.`
|
|
578
|
+
: 'A skill install approval is pending. Wait for the approval instead of retrying discovery.',
|
|
579
|
+
query: params.query,
|
|
580
|
+
status: 'approval_requested',
|
|
581
|
+
attempts: (params.current?.attempts || 0) + 1,
|
|
582
|
+
candidateSkills: installSkillName ? [installSkillName] : candidateNames,
|
|
583
|
+
approvalId: approvalId || params.current?.approvalId || null,
|
|
584
|
+
updatedAt: nowTs,
|
|
585
|
+
})
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (action === 'recommend_for_task' || action === 'status' || action === 'search_available') {
|
|
590
|
+
return normalizeSkillBlocker({
|
|
591
|
+
summary: candidateNames.length > 0
|
|
592
|
+
? `Skill candidates found: ${candidateNames.join(', ')}. Use one of them or request install approval once if needed.`
|
|
593
|
+
: 'Checked local skills for this blocker. Avoid repeating the same discovery loop without a materially different query.',
|
|
594
|
+
query: params.query,
|
|
595
|
+
status: candidateNames.length > 0 ? 'recommended' : 'searched',
|
|
596
|
+
attempts: (params.current?.attempts || 0) + 1,
|
|
597
|
+
candidateSkills: candidateNames,
|
|
598
|
+
approvalId: params.current?.approvalId || null,
|
|
599
|
+
updatedAt: nowTs,
|
|
600
|
+
})
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return params.current
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function deriveSkillBlockerFromText(params: {
|
|
607
|
+
text: string
|
|
608
|
+
current: MainLoopState['skillBlocker']
|
|
609
|
+
query: string | null
|
|
610
|
+
}): MainLoopState['skillBlocker'] {
|
|
611
|
+
const blockerLine = firstMatchingLine(
|
|
612
|
+
params.text,
|
|
613
|
+
/\b(missing capability|missing (?:binary|binaries|env|tool|command)|not installed|install required|requires .* cli|requires .* binary)\b/i,
|
|
614
|
+
)
|
|
615
|
+
if (!blockerLine) return params.current
|
|
616
|
+
return normalizeSkillBlocker({
|
|
617
|
+
summary: blockerLine,
|
|
618
|
+
query: params.query,
|
|
619
|
+
status: params.current?.status === 'approval_requested' ? 'approval_requested' : 'new',
|
|
620
|
+
attempts: params.current?.attempts || 0,
|
|
621
|
+
candidateSkills: params.current?.candidateSkills || [],
|
|
622
|
+
approvalId: params.current?.approvalId || null,
|
|
623
|
+
updatedAt: now(),
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function summarizeSkillBlocker(blocker: MainLoopState['skillBlocker']): string {
|
|
628
|
+
if (!blocker) return ''
|
|
629
|
+
const lines = [
|
|
630
|
+
`Summary: ${blocker.summary}`,
|
|
631
|
+
blocker.query ? `Current query: ${blocker.query}` : '',
|
|
632
|
+
blocker.candidateSkills.length > 0 ? `Candidate skills: ${blocker.candidateSkills.join(', ')}` : '',
|
|
633
|
+
blocker.approvalId ? `Pending approval: ${blocker.approvalId}` : '',
|
|
634
|
+
blocker.status === 'new'
|
|
635
|
+
? 'Next action: use manage_skills once this turn to recommend or inspect a fitting skill for the blocker.'
|
|
636
|
+
: blocker.status === 'searched'
|
|
637
|
+
? 'Next action: do not repeat the same discovery blindly. Either adjust the query materially or proceed with the explicit blocker.'
|
|
638
|
+
: blocker.status === 'recommended'
|
|
639
|
+
? 'Next action: use one recommended skill now, or request one explicit install approval if the best fit is not yet installed.'
|
|
640
|
+
: blocker.status === 'approval_requested'
|
|
641
|
+
? 'Next action: wait for the pending approval instead of repeating discovery or install requests.'
|
|
642
|
+
: 'Next action: use the installed skill before re-running generic exploration.',
|
|
643
|
+
]
|
|
644
|
+
return lines.filter(Boolean).join('\n')
|
|
645
|
+
}
|
|
646
|
+
|
|
407
647
|
function extractWaitSignal(text: string, toolEvents: MessageToolEvent[]): boolean {
|
|
408
648
|
const haystack = `${text}\n${toolEvents.map((event) => `${event.name} ${event.input || ''} ${event.output || ''}`).join('\n')}`
|
|
409
649
|
return /\b(wait for|waiting for|approval|human reply|mailbox|watch job|pending approval)\b/i.test(haystack)
|
|
@@ -473,6 +713,8 @@ export function buildMainLoopHeartbeatPrompt(session: unknown, fallbackPrompt: s
|
|
|
473
713
|
state.currentPlanStep ? `Current plan step: ${state.currentPlanStep}` : '',
|
|
474
714
|
planLines ? `Plan:\n${planLines}` : '',
|
|
475
715
|
state.pendingEvents.length > 0 ? `Pending external events:\n${summarizePendingEvents(state.pendingEvents)}` : '',
|
|
716
|
+
state.skillBlocker ? `Active skill blocker:\n${summarizeSkillBlocker(state.skillBlocker)}` : '',
|
|
717
|
+
summarizeSelectedSkillRuntime(candidate),
|
|
476
718
|
boundedSummary ? `Latest summary:\n${boundedSummary}` : '',
|
|
477
719
|
boundedFallbackPrompt ? `Base heartbeat instructions:\n${boundedFallbackPrompt}` : '',
|
|
478
720
|
'',
|
|
@@ -627,7 +869,24 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
|
|
|
627
869
|
const cleanedResult = persistedText.trim()
|
|
628
870
|
const waitingForExternal = extractWaitSignal(resultText, toolEvents)
|
|
629
871
|
const gotTerminalAck = /^HEARTBEAT_OK$/i.test(cleanedResult) || /^NO_MESSAGE$/i.test(cleanedResult)
|
|
872
|
+
const selectedSkillNote = summarizeUseSkillToolEvent(toolEvents)
|
|
873
|
+
if (selectedSkillNote) appendWorkingMemory(state, selectedSkillNote)
|
|
630
874
|
state.metaMissCount = heartbeat || plan || review || gotTerminalAck ? 0 : state.metaMissCount + 1
|
|
875
|
+
const skillQuery = cleanText(state.nextAction || input.message || state.goal, 240)
|
|
876
|
+
let skillBlocker = deriveSkillBlockerFromToolEvents({
|
|
877
|
+
toolEvents,
|
|
878
|
+
current: state.skillBlocker,
|
|
879
|
+
query: skillQuery,
|
|
880
|
+
})
|
|
881
|
+
skillBlocker = deriveSkillBlockerFromText({
|
|
882
|
+
text: `${resultText}\n${toolEvents.map((event) => event.output || '').join('\n')}`,
|
|
883
|
+
current: skillBlocker,
|
|
884
|
+
query: skillQuery,
|
|
885
|
+
})
|
|
886
|
+
if ((gotTerminalAck && state.status !== 'blocked') || (state.status === 'ok' && !waitingForExternal && !input.error)) {
|
|
887
|
+
skillBlocker = null
|
|
888
|
+
}
|
|
889
|
+
state.skillBlocker = skillBlocker
|
|
631
890
|
|
|
632
891
|
if (input.internal) {
|
|
633
892
|
state.pendingEvents = []
|