astrocode-workflow 0.1.55 → 0.1.56
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/dist/hooks/inject-provider.js +58 -3
- package/dist/tools/index.js +3 -1
- package/dist/tools/injects.d.ts +5 -0
- package/dist/tools/injects.js +79 -0
- package/dist/tools/workflow.d.ts +3 -0
- package/dist/tools/workflow.js +38 -6
- package/dist/workflow/context.d.ts +8 -0
- package/dist/workflow/context.js +48 -2
- package/package.json +1 -1
- package/src/hooks/inject-provider.ts +70 -3
- package/src/tools/index.ts +3 -1
- package/src/tools/injects.ts +88 -0
- package/src/tools/workflow.ts +39 -6
- package/src/workflow/context.ts +56 -2
- package/src/workflow/directives.ts +3 -2
|
@@ -16,24 +16,76 @@ export function createInjectProvider(opts) {
|
|
|
16
16
|
function markInjected(injectId, nowMs) {
|
|
17
17
|
injectedCache.set(injectId, nowMs);
|
|
18
18
|
}
|
|
19
|
+
function getInjectionDiagnostics(nowIso, scopeAllowlist, typeAllowlist) {
|
|
20
|
+
// Get ALL injects to analyze filtering
|
|
21
|
+
const allInjects = db.prepare("SELECT * FROM injects").all();
|
|
22
|
+
let total = allInjects.length;
|
|
23
|
+
let selected = 0;
|
|
24
|
+
let skippedExpired = 0;
|
|
25
|
+
let skippedScope = 0;
|
|
26
|
+
let skippedType = 0;
|
|
27
|
+
let eligibleIds = [];
|
|
28
|
+
for (const inject of allInjects) {
|
|
29
|
+
// Check expiration
|
|
30
|
+
if (inject.expires_at && inject.expires_at <= nowIso) {
|
|
31
|
+
skippedExpired++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// Check scope
|
|
35
|
+
if (!scopeAllowlist.includes(inject.scope)) {
|
|
36
|
+
skippedScope++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// Check type
|
|
40
|
+
if (!typeAllowlist.includes(inject.type)) {
|
|
41
|
+
skippedType++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
// This inject is eligible
|
|
45
|
+
selected++;
|
|
46
|
+
eligibleIds.push(inject.inject_id);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
now: nowIso,
|
|
50
|
+
scopes_considered: scopeAllowlist,
|
|
51
|
+
types_considered: typeAllowlist,
|
|
52
|
+
total_injects: total,
|
|
53
|
+
selected_eligible: selected,
|
|
54
|
+
skipped: {
|
|
55
|
+
expired: skippedExpired,
|
|
56
|
+
scope: skippedScope,
|
|
57
|
+
type: skippedType,
|
|
58
|
+
},
|
|
59
|
+
eligible_ids: eligibleIds,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
19
62
|
async function injectEligibleInjects(sessionId) {
|
|
20
63
|
const now = nowISO();
|
|
21
64
|
const nowMs = Date.now();
|
|
22
|
-
// Get
|
|
65
|
+
// Get allowlists from config or defaults
|
|
23
66
|
const scopeAllowlist = config.inject?.scope_allowlist ?? ["repo", "global"];
|
|
24
67
|
const typeAllowlist = config.inject?.type_allowlist ?? ["note", "policy"];
|
|
68
|
+
// Get diagnostic data
|
|
69
|
+
const diagnostics = getInjectionDiagnostics(now, scopeAllowlist, typeAllowlist);
|
|
25
70
|
const eligibleInjects = selectEligibleInjects(db, {
|
|
26
71
|
nowIso: now,
|
|
27
72
|
scopeAllowlist,
|
|
28
73
|
typeAllowlist,
|
|
29
74
|
limit: config.inject?.max_per_turn ?? 5,
|
|
30
75
|
});
|
|
31
|
-
|
|
76
|
+
let injected = 0;
|
|
77
|
+
let skippedDeduped = 0;
|
|
78
|
+
if (eligibleInjects.length === 0) {
|
|
79
|
+
// Log when no injects are eligible
|
|
80
|
+
console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=0 skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:0}`);
|
|
32
81
|
return;
|
|
82
|
+
}
|
|
33
83
|
// Inject each eligible inject, skipping recently injected ones
|
|
34
84
|
for (const inject of eligibleInjects) {
|
|
35
|
-
if (shouldSkipInject(inject.inject_id, nowMs))
|
|
85
|
+
if (shouldSkipInject(inject.inject_id, nowMs)) {
|
|
86
|
+
skippedDeduped++;
|
|
36
87
|
continue;
|
|
88
|
+
}
|
|
37
89
|
// Format as injection message
|
|
38
90
|
const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
|
|
39
91
|
await injectChatPrompt({
|
|
@@ -42,8 +94,11 @@ export function createInjectProvider(opts) {
|
|
|
42
94
|
text: formattedText,
|
|
43
95
|
agent: "Astrocode"
|
|
44
96
|
});
|
|
97
|
+
injected++;
|
|
45
98
|
markInjected(inject.inject_id, nowMs);
|
|
46
99
|
}
|
|
100
|
+
// Log diagnostic summary
|
|
101
|
+
console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=${injected} skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:${skippedDeduped}}`);
|
|
47
102
|
}
|
|
48
103
|
// Public hook handlers
|
|
49
104
|
return {
|
package/dist/tools/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { createAstroRunGetTool, createAstroRunAbortTool } from "./run";
|
|
|
6
6
|
import { createAstroWorkflowProceedTool } from "./workflow";
|
|
7
7
|
import { createAstroStageStartTool, createAstroStageCompleteTool, createAstroStageFailTool, createAstroStageResetTool } from "./stage";
|
|
8
8
|
import { createAstroArtifactPutTool, createAstroArtifactListTool, createAstroArtifactGetTool } from "./artifacts";
|
|
9
|
-
import { createAstroInjectPutTool, createAstroInjectListTool, createAstroInjectSearchTool, createAstroInjectGetTool } from "./injects";
|
|
9
|
+
import { createAstroInjectPutTool, createAstroInjectListTool, createAstroInjectSearchTool, createAstroInjectGetTool, createAstroInjectEligibleTool, createAstroInjectDebugDueTool } from "./injects";
|
|
10
10
|
import { createAstroRepairTool } from "./repair";
|
|
11
11
|
export function createAstroTools(opts) {
|
|
12
12
|
const { ctx, config, db, agents } = opts;
|
|
@@ -39,6 +39,8 @@ export function createAstroTools(opts) {
|
|
|
39
39
|
tools.astro_inject_list = createAstroInjectListTool({ ctx, config, db });
|
|
40
40
|
tools.astro_inject_search = createAstroInjectSearchTool({ ctx, config, db });
|
|
41
41
|
tools.astro_inject_get = createAstroInjectGetTool({ ctx, config, db });
|
|
42
|
+
tools.astro_inject_eligible = createAstroInjectEligibleTool({ ctx, config, db });
|
|
43
|
+
tools.astro_inject_debug_due = createAstroInjectDebugDueTool({ ctx, config, db });
|
|
42
44
|
tools.astro_repair = createAstroRepairTool({ ctx, config, db });
|
|
43
45
|
}
|
|
44
46
|
else {
|
package/dist/tools/injects.d.ts
CHANGED
|
@@ -46,3 +46,8 @@ export declare function createAstroInjectEligibleTool(opts: {
|
|
|
46
46
|
config: AstrocodeConfig;
|
|
47
47
|
db: SqliteDb;
|
|
48
48
|
}): ToolDefinition;
|
|
49
|
+
export declare function createAstroInjectDebugDueTool(opts: {
|
|
50
|
+
ctx: any;
|
|
51
|
+
config: AstrocodeConfig;
|
|
52
|
+
db: SqliteDb;
|
|
53
|
+
}): ToolDefinition;
|
package/dist/tools/injects.js
CHANGED
|
@@ -137,3 +137,82 @@ export function createAstroInjectEligibleTool(opts) {
|
|
|
137
137
|
},
|
|
138
138
|
});
|
|
139
139
|
}
|
|
140
|
+
export function createAstroInjectDebugDueTool(opts) {
|
|
141
|
+
const { db } = opts;
|
|
142
|
+
return tool({
|
|
143
|
+
description: "Debug: show comprehensive injection diagnostics - why injects were selected/skipped.",
|
|
144
|
+
args: {
|
|
145
|
+
scopes_json: tool.schema.string().default('["repo","global"]'),
|
|
146
|
+
types_json: tool.schema.string().default('["note","policy"]'),
|
|
147
|
+
},
|
|
148
|
+
execute: async ({ scopes_json, types_json }) => {
|
|
149
|
+
const now = nowISO();
|
|
150
|
+
const scopes = JSON.parse(scopes_json);
|
|
151
|
+
const types = JSON.parse(types_json);
|
|
152
|
+
// Get ALL injects to analyze filtering
|
|
153
|
+
const allInjects = db.prepare("SELECT * FROM injects").all();
|
|
154
|
+
let total = allInjects.length;
|
|
155
|
+
let selected = 0;
|
|
156
|
+
let skippedExpired = 0;
|
|
157
|
+
let skippedScope = 0;
|
|
158
|
+
let skippedType = 0;
|
|
159
|
+
const excludedReasons = [];
|
|
160
|
+
const selectedInjects = [];
|
|
161
|
+
for (const inject of allInjects) {
|
|
162
|
+
const reasons = [];
|
|
163
|
+
// Check expiration
|
|
164
|
+
if (inject.expires_at && inject.expires_at <= now) {
|
|
165
|
+
reasons.push("expired");
|
|
166
|
+
skippedExpired++;
|
|
167
|
+
}
|
|
168
|
+
// Check scope
|
|
169
|
+
if (!scopes.includes(inject.scope)) {
|
|
170
|
+
reasons.push("scope");
|
|
171
|
+
skippedScope++;
|
|
172
|
+
}
|
|
173
|
+
// Check type
|
|
174
|
+
if (!types.includes(inject.type)) {
|
|
175
|
+
reasons.push("type");
|
|
176
|
+
skippedType++;
|
|
177
|
+
}
|
|
178
|
+
if (reasons.length > 0) {
|
|
179
|
+
excludedReasons.push({
|
|
180
|
+
inject_id: inject.inject_id,
|
|
181
|
+
title: inject.title,
|
|
182
|
+
reasons: reasons,
|
|
183
|
+
scope: inject.scope,
|
|
184
|
+
type: inject.type,
|
|
185
|
+
expires_at: inject.expires_at,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
selected++;
|
|
190
|
+
selectedInjects.push({
|
|
191
|
+
inject_id: inject.inject_id,
|
|
192
|
+
title: inject.title,
|
|
193
|
+
scope: inject.scope,
|
|
194
|
+
type: inject.type,
|
|
195
|
+
expires_at: inject.expires_at,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return JSON.stringify({
|
|
200
|
+
now,
|
|
201
|
+
scopes_considered: scopes,
|
|
202
|
+
types_considered: types,
|
|
203
|
+
summary: {
|
|
204
|
+
total_injects: total,
|
|
205
|
+
selected_eligible: selected,
|
|
206
|
+
excluded_total: total - selected,
|
|
207
|
+
skipped_breakdown: {
|
|
208
|
+
expired: skippedExpired,
|
|
209
|
+
scope: skippedScope,
|
|
210
|
+
type: skippedType,
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
selected_injects: selectedInjects,
|
|
214
|
+
excluded_injects: excludedReasons,
|
|
215
|
+
}, null, 2);
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
package/dist/tools/workflow.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { type ToolDefinition } from "@opencode-ai/plugin/tool";
|
|
2
2
|
import type { AstrocodeConfig } from "../config/schema";
|
|
3
3
|
import type { SqliteDb } from "../state/db";
|
|
4
|
+
import type { StageKey } from "../state/types";
|
|
5
|
+
export declare const STAGE_TO_AGENT_MAP: Record<string, string>;
|
|
6
|
+
export declare function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig, agents?: any, warnings?: string[]): string;
|
|
4
7
|
import { AgentConfig } from "@opencode-ai/sdk";
|
|
5
8
|
export declare function createAstroWorkflowProceedTool(opts: {
|
|
6
9
|
ctx: any;
|
package/dist/tools/workflow.js
CHANGED
|
@@ -9,7 +9,7 @@ import { newEventId } from "../state/ids";
|
|
|
9
9
|
import { debug } from "../shared/log";
|
|
10
10
|
import { createToastManager } from "../ui/toasts";
|
|
11
11
|
// Agent name mapping for case-sensitive resolution
|
|
12
|
-
const STAGE_TO_AGENT_MAP = {
|
|
12
|
+
export const STAGE_TO_AGENT_MAP = {
|
|
13
13
|
frame: "Frame",
|
|
14
14
|
plan: "Plan",
|
|
15
15
|
spec: "Spec",
|
|
@@ -18,13 +18,39 @@ const STAGE_TO_AGENT_MAP = {
|
|
|
18
18
|
verify: "Verify",
|
|
19
19
|
close: "Close"
|
|
20
20
|
};
|
|
21
|
-
function resolveAgentName(stageKey, config) {
|
|
21
|
+
export function resolveAgentName(stageKey, config, agents, warnings) {
|
|
22
22
|
// Use configurable agent names from config, fallback to hardcoded map, then General
|
|
23
23
|
const agentNames = config.agents?.stage_agent_names;
|
|
24
|
+
let candidate;
|
|
24
25
|
if (agentNames && agentNames[stageKey]) {
|
|
25
|
-
|
|
26
|
+
candidate = agentNames[stageKey];
|
|
26
27
|
}
|
|
27
|
-
|
|
28
|
+
else {
|
|
29
|
+
candidate = STAGE_TO_AGENT_MAP[stageKey] || "General";
|
|
30
|
+
}
|
|
31
|
+
// Validate that the agent actually exists in the registry
|
|
32
|
+
if (agents && !agents[candidate]) {
|
|
33
|
+
const warning = `Agent "${candidate}" not found in registry for stage "${stageKey}". Falling back to General.`;
|
|
34
|
+
if (warnings) {
|
|
35
|
+
warnings.push(warning);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
console.warn(`[Astrocode] ${warning}`);
|
|
39
|
+
}
|
|
40
|
+
candidate = "General";
|
|
41
|
+
}
|
|
42
|
+
// Final guard: ensure General exists, fallback to built-in "general" if not
|
|
43
|
+
if (agents && !agents[candidate]) {
|
|
44
|
+
const finalWarning = `Critical: General agent not found in registry. Falling back to built-in "general".`;
|
|
45
|
+
if (warnings) {
|
|
46
|
+
warnings.push(finalWarning);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
console.warn(`[Astrocode] ${finalWarning}`);
|
|
50
|
+
}
|
|
51
|
+
return "general"; // built-in, guaranteed by OpenCode
|
|
52
|
+
}
|
|
53
|
+
return candidate;
|
|
28
54
|
}
|
|
29
55
|
function stageGoal(stage, cfg) {
|
|
30
56
|
switch (stage) {
|
|
@@ -99,6 +125,7 @@ export function createAstroWorkflowProceedTool(opts) {
|
|
|
99
125
|
const sessionId = ctx.sessionID;
|
|
100
126
|
const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
|
|
101
127
|
const actions = [];
|
|
128
|
+
const warnings = [];
|
|
102
129
|
const startedAt = nowISO();
|
|
103
130
|
for (let i = 0; i < steps; i++) {
|
|
104
131
|
const next = decideNextAction(db, config);
|
|
@@ -172,7 +199,7 @@ export function createAstroWorkflowProceedTool(opts) {
|
|
|
172
199
|
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id);
|
|
173
200
|
const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key);
|
|
174
201
|
// Mark stage started + set subagent_type to the stage agent.
|
|
175
|
-
let agentName = resolveAgentName(next.stage_key, config);
|
|
202
|
+
let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
|
|
176
203
|
// Validate agent availability with fallback chain
|
|
177
204
|
const systemConfig = config;
|
|
178
205
|
// Check both the system config agent map (if present) OR the local agents map passed to the tool
|
|
@@ -197,7 +224,7 @@ export function createAstroWorkflowProceedTool(opts) {
|
|
|
197
224
|
// Skip General fallback for stage agents to avoid malformed output
|
|
198
225
|
agentName = config.agents?.orchestrator_name || "Astro";
|
|
199
226
|
if (!agentExists(agentName)) {
|
|
200
|
-
throw new Error(`Critical: No agents available for delegation. Primary: ${resolveAgentName(next.stage_key, config)}, Orchestrator: ${agentName}`);
|
|
227
|
+
throw new Error(`Critical: No agents available for delegation. Primary: ${resolveAgentName(next.stage_key, config, agents, warnings)}, Orchestrator: ${agentName}`);
|
|
201
228
|
}
|
|
202
229
|
}
|
|
203
230
|
withTx(db, () => {
|
|
@@ -291,6 +318,11 @@ export function createAstroWorkflowProceedTool(opts) {
|
|
|
291
318
|
lines.push(``, `## Actions`);
|
|
292
319
|
for (const a of actions)
|
|
293
320
|
lines.push(`- ${a}`);
|
|
321
|
+
if (warnings.length > 0) {
|
|
322
|
+
lines.push(``, `## Warnings`);
|
|
323
|
+
for (const w of warnings)
|
|
324
|
+
lines.push(`⚠️ ${w}`);
|
|
325
|
+
}
|
|
294
326
|
return lines.join("\n").trim();
|
|
295
327
|
},
|
|
296
328
|
});
|
|
@@ -4,6 +4,14 @@ import type { RunRow, StageRunRow, StoryRow } from "../state/types";
|
|
|
4
4
|
export declare function getRun(db: SqliteDb, runId: string): RunRow | null;
|
|
5
5
|
export declare function getStory(db: SqliteDb, storyKey: string): StoryRow | null;
|
|
6
6
|
export declare function listStageRuns(db: SqliteDb, runId: string): StageRunRow[];
|
|
7
|
+
/**
|
|
8
|
+
* Check if a context snapshot is stale by comparing DB timestamps
|
|
9
|
+
*/
|
|
10
|
+
export declare function isContextSnapshotStale(snapshotText: string, db: SqliteDb, maxAgeSeconds?: number): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Add staleness indicator to context snapshot if needed
|
|
13
|
+
*/
|
|
14
|
+
export declare function addStalenessIndicator(snapshotText: string, db: SqliteDb, maxAgeSeconds?: number): string;
|
|
7
15
|
export declare function buildContextSnapshot(opts: {
|
|
8
16
|
db: SqliteDb;
|
|
9
17
|
config: AstrocodeConfig;
|
package/dist/workflow/context.js
CHANGED
|
@@ -27,6 +27,49 @@ function statusIcon(status) {
|
|
|
27
27
|
return "⬜";
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Check if a context snapshot is stale by comparing DB timestamps
|
|
32
|
+
*/
|
|
33
|
+
export function isContextSnapshotStale(snapshotText, db, maxAgeSeconds = 300) {
|
|
34
|
+
// Extract run_id from snapshot
|
|
35
|
+
const runIdMatch = snapshotText.match(/Run: `([^`]+)`/);
|
|
36
|
+
if (!runIdMatch)
|
|
37
|
+
return true; // Can't validate without run_id
|
|
38
|
+
const runId = runIdMatch[1];
|
|
39
|
+
// Extract snapshot's claimed updated_at
|
|
40
|
+
const snapshotUpdatedMatch = snapshotText.match(/updated: ([^\)]+)\)/);
|
|
41
|
+
if (!snapshotUpdatedMatch)
|
|
42
|
+
return true; // Fallback to age-based check
|
|
43
|
+
try {
|
|
44
|
+
const snapshotUpdatedAt = snapshotUpdatedMatch[1];
|
|
45
|
+
const currentRun = db.prepare("SELECT updated_at FROM runs WHERE run_id = ?").get(runId);
|
|
46
|
+
if (!currentRun?.updated_at)
|
|
47
|
+
return true; // Run doesn't exist
|
|
48
|
+
// Compare timestamps - if DB is newer than snapshot claims, snapshot is stale
|
|
49
|
+
const snapshotTime = new Date(snapshotUpdatedAt).getTime();
|
|
50
|
+
const currentTime = new Date(currentRun.updated_at).getTime();
|
|
51
|
+
return currentTime > snapshotTime;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
// Fallback to age-based staleness if parsing fails
|
|
55
|
+
const timestampMatch = snapshotText.match(/generated: (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)/);
|
|
56
|
+
if (!timestampMatch)
|
|
57
|
+
return false;
|
|
58
|
+
const generatedAt = new Date(timestampMatch[1]);
|
|
59
|
+
const now = new Date();
|
|
60
|
+
const ageSeconds = (now.getTime() - generatedAt.getTime()) / 1000;
|
|
61
|
+
return ageSeconds > maxAgeSeconds;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Add staleness indicator to context snapshot if needed
|
|
66
|
+
*/
|
|
67
|
+
export function addStalenessIndicator(snapshotText, db, maxAgeSeconds = 300) {
|
|
68
|
+
if (isContextSnapshotStale(snapshotText, db, maxAgeSeconds)) {
|
|
69
|
+
return snapshotText.replace(/# Astrocode Context \(generated: ([^\)]+)\)/, "# Astrocode Context (generated: $1) ⚠️ STALE - DB state has changed");
|
|
70
|
+
}
|
|
71
|
+
return snapshotText;
|
|
72
|
+
}
|
|
30
73
|
export function buildContextSnapshot(opts) {
|
|
31
74
|
const { db, config, run_id, next_action } = opts;
|
|
32
75
|
const run = getRun(db, run_id);
|
|
@@ -35,8 +78,11 @@ export function buildContextSnapshot(opts) {
|
|
|
35
78
|
const story = getStory(db, run.story_key);
|
|
36
79
|
const stageRuns = listStageRuns(db, run_id);
|
|
37
80
|
const lines = [];
|
|
38
|
-
|
|
39
|
-
|
|
81
|
+
// Add timestamps for staleness checking
|
|
82
|
+
const now = new Date();
|
|
83
|
+
const timestamp = now.toISOString();
|
|
84
|
+
lines.push(`# Astrocode Context (generated: ${timestamp})`);
|
|
85
|
+
lines.push(`- Run: \`${run.run_id}\` Status: **${run.status}** (updated: ${run.updated_at})`);
|
|
40
86
|
if (run.current_stage_key)
|
|
41
87
|
lines.push(`- Current stage: \`${run.current_stage_key}\``);
|
|
42
88
|
if (next_action)
|
package/package.json
CHANGED
|
@@ -32,14 +32,67 @@ export function createInjectProvider(opts: {
|
|
|
32
32
|
injectedCache.set(injectId, nowMs);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function getInjectionDiagnostics(nowIso: string, scopeAllowlist: string[], typeAllowlist: string[]): any {
|
|
36
|
+
// Get ALL injects to analyze filtering
|
|
37
|
+
const allInjects = db.prepare("SELECT * FROM injects").all() as any[];
|
|
38
|
+
|
|
39
|
+
let total = allInjects.length;
|
|
40
|
+
let selected = 0;
|
|
41
|
+
let skippedExpired = 0;
|
|
42
|
+
let skippedScope = 0;
|
|
43
|
+
let skippedType = 0;
|
|
44
|
+
let eligibleIds: string[] = [];
|
|
45
|
+
|
|
46
|
+
for (const inject of allInjects) {
|
|
47
|
+
// Check expiration
|
|
48
|
+
if (inject.expires_at && inject.expires_at <= nowIso) {
|
|
49
|
+
skippedExpired++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check scope
|
|
54
|
+
if (!scopeAllowlist.includes(inject.scope)) {
|
|
55
|
+
skippedScope++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check type
|
|
60
|
+
if (!typeAllowlist.includes(inject.type)) {
|
|
61
|
+
skippedType++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// This inject is eligible
|
|
66
|
+
selected++;
|
|
67
|
+
eligibleIds.push(inject.inject_id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
now: nowIso,
|
|
72
|
+
scopes_considered: scopeAllowlist,
|
|
73
|
+
types_considered: typeAllowlist,
|
|
74
|
+
total_injects: total,
|
|
75
|
+
selected_eligible: selected,
|
|
76
|
+
skipped: {
|
|
77
|
+
expired: skippedExpired,
|
|
78
|
+
scope: skippedScope,
|
|
79
|
+
type: skippedType,
|
|
80
|
+
},
|
|
81
|
+
eligible_ids: eligibleIds,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
35
85
|
async function injectEligibleInjects(sessionId: string) {
|
|
36
86
|
const now = nowISO();
|
|
37
87
|
const nowMs = Date.now();
|
|
38
88
|
|
|
39
|
-
// Get
|
|
89
|
+
// Get allowlists from config or defaults
|
|
40
90
|
const scopeAllowlist = config.inject?.scope_allowlist ?? ["repo", "global"];
|
|
41
91
|
const typeAllowlist = config.inject?.type_allowlist ?? ["note", "policy"];
|
|
42
92
|
|
|
93
|
+
// Get diagnostic data
|
|
94
|
+
const diagnostics = getInjectionDiagnostics(now, scopeAllowlist, typeAllowlist);
|
|
95
|
+
|
|
43
96
|
const eligibleInjects = selectEligibleInjects(db, {
|
|
44
97
|
nowIso: now,
|
|
45
98
|
scopeAllowlist,
|
|
@@ -47,11 +100,21 @@ export function createInjectProvider(opts: {
|
|
|
47
100
|
limit: config.inject?.max_per_turn ?? 5,
|
|
48
101
|
});
|
|
49
102
|
|
|
50
|
-
|
|
103
|
+
let injected = 0;
|
|
104
|
+
let skippedDeduped = 0;
|
|
105
|
+
|
|
106
|
+
if (eligibleInjects.length === 0) {
|
|
107
|
+
// Log when no injects are eligible
|
|
108
|
+
console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=0 skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:0}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
51
111
|
|
|
52
112
|
// Inject each eligible inject, skipping recently injected ones
|
|
53
113
|
for (const inject of eligibleInjects) {
|
|
54
|
-
if (shouldSkipInject(inject.inject_id, nowMs))
|
|
114
|
+
if (shouldSkipInject(inject.inject_id, nowMs)) {
|
|
115
|
+
skippedDeduped++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
55
118
|
|
|
56
119
|
// Format as injection message
|
|
57
120
|
const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
|
|
@@ -63,8 +126,12 @@ export function createInjectProvider(opts: {
|
|
|
63
126
|
agent: "Astrocode"
|
|
64
127
|
});
|
|
65
128
|
|
|
129
|
+
injected++;
|
|
66
130
|
markInjected(inject.inject_id, nowMs);
|
|
67
131
|
}
|
|
132
|
+
|
|
133
|
+
// Log diagnostic summary
|
|
134
|
+
console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=${injected} skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:${skippedDeduped}}`);
|
|
68
135
|
}
|
|
69
136
|
|
|
70
137
|
// Public hook handlers
|
package/src/tools/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { createAstroRunGetTool, createAstroRunAbortTool } from "./run";
|
|
|
10
10
|
import { createAstroWorkflowProceedTool } from "./workflow";
|
|
11
11
|
import { createAstroStageStartTool, createAstroStageCompleteTool, createAstroStageFailTool, createAstroStageResetTool } from "./stage";
|
|
12
12
|
import { createAstroArtifactPutTool, createAstroArtifactListTool, createAstroArtifactGetTool } from "./artifacts";
|
|
13
|
-
import { createAstroInjectPutTool, createAstroInjectListTool, createAstroInjectSearchTool, createAstroInjectGetTool } from "./injects";
|
|
13
|
+
import { createAstroInjectPutTool, createAstroInjectListTool, createAstroInjectSearchTool, createAstroInjectGetTool, createAstroInjectEligibleTool, createAstroInjectDebugDueTool } from "./injects";
|
|
14
14
|
import { createAstroRepairTool } from "./repair";
|
|
15
15
|
|
|
16
16
|
import { AgentConfig } from "@opencode-ai/sdk";
|
|
@@ -50,6 +50,8 @@ export function createAstroTools(opts: { ctx: any; config: AstrocodeConfig; db:
|
|
|
50
50
|
tools.astro_inject_list = createAstroInjectListTool({ ctx, config, db });
|
|
51
51
|
tools.astro_inject_search = createAstroInjectSearchTool({ ctx, config, db });
|
|
52
52
|
tools.astro_inject_get = createAstroInjectGetTool({ ctx, config, db });
|
|
53
|
+
tools.astro_inject_eligible = createAstroInjectEligibleTool({ ctx, config, db });
|
|
54
|
+
tools.astro_inject_debug_due = createAstroInjectDebugDueTool({ ctx, config, db });
|
|
53
55
|
tools.astro_repair = createAstroRepairTool({ ctx, config, db });
|
|
54
56
|
} else {
|
|
55
57
|
// Limited mode tools - provide helpful messages instead of failing
|
package/src/tools/injects.ts
CHANGED
|
@@ -174,3 +174,91 @@ export function createAstroInjectEligibleTool(opts: { ctx: any; config: Astrocod
|
|
|
174
174
|
},
|
|
175
175
|
});
|
|
176
176
|
}
|
|
177
|
+
|
|
178
|
+
export function createAstroInjectDebugDueTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
|
|
179
|
+
const { db } = opts;
|
|
180
|
+
|
|
181
|
+
return tool({
|
|
182
|
+
description: "Debug: show comprehensive injection diagnostics - why injects were selected/skipped.",
|
|
183
|
+
args: {
|
|
184
|
+
scopes_json: tool.schema.string().default('["repo","global"]'),
|
|
185
|
+
types_json: tool.schema.string().default('["note","policy"]'),
|
|
186
|
+
},
|
|
187
|
+
execute: async ({ scopes_json, types_json }) => {
|
|
188
|
+
const now = nowISO();
|
|
189
|
+
const scopes = JSON.parse(scopes_json) as string[];
|
|
190
|
+
const types = JSON.parse(types_json) as string[];
|
|
191
|
+
|
|
192
|
+
// Get ALL injects to analyze filtering
|
|
193
|
+
const allInjects = db.prepare("SELECT * FROM injects").all() as any[];
|
|
194
|
+
|
|
195
|
+
let total = allInjects.length;
|
|
196
|
+
let selected = 0;
|
|
197
|
+
let skippedExpired = 0;
|
|
198
|
+
let skippedScope = 0;
|
|
199
|
+
let skippedType = 0;
|
|
200
|
+
const excludedReasons: any[] = [];
|
|
201
|
+
const selectedInjects: any[] = [];
|
|
202
|
+
|
|
203
|
+
for (const inject of allInjects) {
|
|
204
|
+
const reasons: string[] = [];
|
|
205
|
+
|
|
206
|
+
// Check expiration
|
|
207
|
+
if (inject.expires_at && inject.expires_at <= now) {
|
|
208
|
+
reasons.push("expired");
|
|
209
|
+
skippedExpired++;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check scope
|
|
213
|
+
if (!scopes.includes(inject.scope)) {
|
|
214
|
+
reasons.push("scope");
|
|
215
|
+
skippedScope++;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Check type
|
|
219
|
+
if (!types.includes(inject.type)) {
|
|
220
|
+
reasons.push("type");
|
|
221
|
+
skippedType++;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (reasons.length > 0) {
|
|
225
|
+
excludedReasons.push({
|
|
226
|
+
inject_id: inject.inject_id,
|
|
227
|
+
title: inject.title,
|
|
228
|
+
reasons: reasons,
|
|
229
|
+
scope: inject.scope,
|
|
230
|
+
type: inject.type,
|
|
231
|
+
expires_at: inject.expires_at,
|
|
232
|
+
});
|
|
233
|
+
} else {
|
|
234
|
+
selected++;
|
|
235
|
+
selectedInjects.push({
|
|
236
|
+
inject_id: inject.inject_id,
|
|
237
|
+
title: inject.title,
|
|
238
|
+
scope: inject.scope,
|
|
239
|
+
type: inject.type,
|
|
240
|
+
expires_at: inject.expires_at,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return JSON.stringify({
|
|
246
|
+
now,
|
|
247
|
+
scopes_considered: scopes,
|
|
248
|
+
types_considered: types,
|
|
249
|
+
summary: {
|
|
250
|
+
total_injects: total,
|
|
251
|
+
selected_eligible: selected,
|
|
252
|
+
excluded_total: total - selected,
|
|
253
|
+
skipped_breakdown: {
|
|
254
|
+
expired: skippedExpired,
|
|
255
|
+
scope: skippedScope,
|
|
256
|
+
type: skippedType,
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
selected_injects: selectedInjects,
|
|
260
|
+
excluded_injects: excludedReasons,
|
|
261
|
+
}, null, 2);
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
}
|
package/src/tools/workflow.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { debug } from "../shared/log";
|
|
|
13
13
|
import { createToastManager } from "../ui/toasts";
|
|
14
14
|
|
|
15
15
|
// Agent name mapping for case-sensitive resolution
|
|
16
|
-
const STAGE_TO_AGENT_MAP: Record<string, string> = {
|
|
16
|
+
export const STAGE_TO_AGENT_MAP: Record<string, string> = {
|
|
17
17
|
frame: "Frame",
|
|
18
18
|
plan: "Plan",
|
|
19
19
|
spec: "Spec",
|
|
@@ -23,13 +23,40 @@ const STAGE_TO_AGENT_MAP: Record<string, string> = {
|
|
|
23
23
|
close: "Close"
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig): string {
|
|
26
|
+
export function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig, agents?: any, warnings?: string[]): string {
|
|
27
27
|
// Use configurable agent names from config, fallback to hardcoded map, then General
|
|
28
28
|
const agentNames = config.agents?.stage_agent_names;
|
|
29
|
+
let candidate: string;
|
|
30
|
+
|
|
29
31
|
if (agentNames && agentNames[stageKey]) {
|
|
30
|
-
|
|
32
|
+
candidate = agentNames[stageKey];
|
|
33
|
+
} else {
|
|
34
|
+
candidate = STAGE_TO_AGENT_MAP[stageKey] || "General";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Validate that the agent actually exists in the registry
|
|
38
|
+
if (agents && !agents[candidate]) {
|
|
39
|
+
const warning = `Agent "${candidate}" not found in registry for stage "${stageKey}". Falling back to General.`;
|
|
40
|
+
if (warnings) {
|
|
41
|
+
warnings.push(warning);
|
|
42
|
+
} else {
|
|
43
|
+
console.warn(`[Astrocode] ${warning}`);
|
|
44
|
+
}
|
|
45
|
+
candidate = "General";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Final guard: ensure General exists, fallback to built-in "general" if not
|
|
49
|
+
if (agents && !agents[candidate]) {
|
|
50
|
+
const finalWarning = `Critical: General agent not found in registry. Falling back to built-in "general".`;
|
|
51
|
+
if (warnings) {
|
|
52
|
+
warnings.push(finalWarning);
|
|
53
|
+
} else {
|
|
54
|
+
console.warn(`[Astrocode] ${finalWarning}`);
|
|
55
|
+
}
|
|
56
|
+
return "general"; // built-in, guaranteed by OpenCode
|
|
31
57
|
}
|
|
32
|
-
|
|
58
|
+
|
|
59
|
+
return candidate;
|
|
33
60
|
}
|
|
34
61
|
|
|
35
62
|
function stageGoal(stage: StageKey, cfg: AstrocodeConfig): string {
|
|
@@ -123,6 +150,7 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
123
150
|
const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
|
|
124
151
|
|
|
125
152
|
const actions: string[] = [];
|
|
153
|
+
const warnings: string[] = [];
|
|
126
154
|
const startedAt = nowISO();
|
|
127
155
|
|
|
128
156
|
for (let i = 0; i < steps; i++) {
|
|
@@ -212,7 +240,7 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
212
240
|
const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key) as any;
|
|
213
241
|
|
|
214
242
|
// Mark stage started + set subagent_type to the stage agent.
|
|
215
|
-
let agentName = resolveAgentName(next.stage_key, config);
|
|
243
|
+
let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
|
|
216
244
|
|
|
217
245
|
// Validate agent availability with fallback chain
|
|
218
246
|
const systemConfig = config as any;
|
|
@@ -239,7 +267,7 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
239
267
|
// Skip General fallback for stage agents to avoid malformed output
|
|
240
268
|
agentName = config.agents?.orchestrator_name || "Astro";
|
|
241
269
|
if (!agentExists(agentName)) {
|
|
242
|
-
|
|
270
|
+
throw new Error(`Critical: No agents available for delegation. Primary: ${resolveAgentName(next.stage_key, config, agents, warnings)}, Orchestrator: ${agentName}`);
|
|
243
271
|
}
|
|
244
272
|
}
|
|
245
273
|
|
|
@@ -355,6 +383,11 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
355
383
|
lines.push(``, `## Actions`);
|
|
356
384
|
for (const a of actions) lines.push(`- ${a}`);
|
|
357
385
|
|
|
386
|
+
if (warnings.length > 0) {
|
|
387
|
+
lines.push(``, `## Warnings`);
|
|
388
|
+
for (const w of warnings) lines.push(`⚠️ ${w}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
358
391
|
return lines.join("\n").trim();
|
|
359
392
|
},
|
|
360
393
|
});
|
package/src/workflow/context.ts
CHANGED
|
@@ -35,6 +35,57 @@ function statusIcon(status: string): string {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Check if a context snapshot is stale by comparing DB timestamps
|
|
40
|
+
*/
|
|
41
|
+
export function isContextSnapshotStale(snapshotText: string, db: SqliteDb, maxAgeSeconds: number = 300): boolean {
|
|
42
|
+
// Extract run_id from snapshot
|
|
43
|
+
const runIdMatch = snapshotText.match(/Run: `([^`]+)`/);
|
|
44
|
+
if (!runIdMatch) return true; // Can't validate without run_id
|
|
45
|
+
|
|
46
|
+
const runId = runIdMatch[1];
|
|
47
|
+
|
|
48
|
+
// Extract snapshot's claimed updated_at
|
|
49
|
+
const snapshotUpdatedMatch = snapshotText.match(/updated: ([^\)]+)\)/);
|
|
50
|
+
if (!snapshotUpdatedMatch) return true; // Fallback to age-based check
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const snapshotUpdatedAt = snapshotUpdatedMatch[1];
|
|
54
|
+
const currentRun = db.prepare("SELECT updated_at FROM runs WHERE run_id = ?").get(runId) as { updated_at?: string };
|
|
55
|
+
|
|
56
|
+
if (!currentRun?.updated_at) return true; // Run doesn't exist
|
|
57
|
+
|
|
58
|
+
// Compare timestamps - if DB is newer than snapshot claims, snapshot is stale
|
|
59
|
+
const snapshotTime = new Date(snapshotUpdatedAt).getTime();
|
|
60
|
+
const currentTime = new Date(currentRun.updated_at).getTime();
|
|
61
|
+
|
|
62
|
+
return currentTime > snapshotTime;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
// Fallback to age-based staleness if parsing fails
|
|
65
|
+
const timestampMatch = snapshotText.match(/generated: (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)/);
|
|
66
|
+
if (!timestampMatch) return false;
|
|
67
|
+
|
|
68
|
+
const generatedAt = new Date(timestampMatch[1]);
|
|
69
|
+
const now = new Date();
|
|
70
|
+
const ageSeconds = (now.getTime() - generatedAt.getTime()) / 1000;
|
|
71
|
+
|
|
72
|
+
return ageSeconds > maxAgeSeconds;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Add staleness indicator to context snapshot if needed
|
|
78
|
+
*/
|
|
79
|
+
export function addStalenessIndicator(snapshotText: string, db: SqliteDb, maxAgeSeconds: number = 300): string {
|
|
80
|
+
if (isContextSnapshotStale(snapshotText, db, maxAgeSeconds)) {
|
|
81
|
+
return snapshotText.replace(
|
|
82
|
+
/# Astrocode Context \(generated: ([^\)]+)\)/,
|
|
83
|
+
"# Astrocode Context (generated: $1) ⚠️ STALE - DB state has changed"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return snapshotText;
|
|
87
|
+
}
|
|
88
|
+
|
|
38
89
|
export function buildContextSnapshot(opts: {
|
|
39
90
|
db: SqliteDb;
|
|
40
91
|
config: AstrocodeConfig;
|
|
@@ -52,8 +103,11 @@ export function buildContextSnapshot(opts: {
|
|
|
52
103
|
|
|
53
104
|
const lines: string[] = [];
|
|
54
105
|
|
|
55
|
-
|
|
56
|
-
|
|
106
|
+
// Add timestamps for staleness checking
|
|
107
|
+
const now = new Date();
|
|
108
|
+
const timestamp = now.toISOString();
|
|
109
|
+
lines.push(`# Astrocode Context (generated: ${timestamp})`);
|
|
110
|
+
lines.push(`- Run: \`${run.run_id}\` Status: **${run.status}** (updated: ${run.updated_at})`);
|
|
57
111
|
if (run.current_stage_key) lines.push(`- Current stage: \`${run.current_stage_key}\``);
|
|
58
112
|
if (next_action) lines.push(`- Next action: ${next_action}`);
|
|
59
113
|
|
|
@@ -2,6 +2,7 @@ import type { AstrocodeConfig } from "../config/schema";
|
|
|
2
2
|
import { sha256Hex } from "../shared/hash";
|
|
3
3
|
import { clampChars, normalizeNewlines } from "../shared/text";
|
|
4
4
|
import type { StageKey } from "../state/types";
|
|
5
|
+
import { addStalenessIndicator } from "./context";
|
|
5
6
|
|
|
6
7
|
export type DirectiveKind = "continue" | "stage" | "blocked" | "repair";
|
|
7
8
|
|
|
@@ -77,8 +78,8 @@ export function buildBlockedDirective(opts: {
|
|
|
77
78
|
``,
|
|
78
79
|
`Question: ${question}`,
|
|
79
80
|
``,
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
`Context snapshot:`,
|
|
82
|
+
context_snapshot_md.trim(),
|
|
82
83
|
].join("\n")
|
|
83
84
|
).trim();
|
|
84
85
|
|