astrocode-workflow 0.1.53 → 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/config/schema.d.ts +6 -0
- package/dist/config/schema.js +10 -0
- package/dist/hooks/inject-provider.d.ts +14 -0
- package/dist/hooks/inject-provider.js +112 -0
- package/dist/index.js +7 -0
- package/dist/tools/index.js +3 -1
- package/dist/tools/injects.d.ts +30 -0
- package/dist/tools/injects.js +119 -0
- package/dist/tools/workflow.d.ts +3 -0
- package/dist/tools/workflow.js +45 -13
- package/dist/workflow/context.d.ts +8 -0
- package/dist/workflow/context.js +48 -2
- package/package.json +1 -1
- package/src/config/schema.ts +11 -0
- package/src/hooks/inject-provider.ts +146 -0
- package/src/index.ts +11 -4
- package/src/tools/index.ts +3 -1
- package/src/tools/injects.ts +156 -0
- package/src/tools/workflow.ts +46 -13
- package/src/workflow/context.ts +56 -2
- package/src/workflow/directives.ts +3 -2
package/dist/config/schema.d.ts
CHANGED
|
@@ -172,6 +172,12 @@ export declare const AstrocodeConfigSchema: z.ZodDefault<z.ZodObject<{
|
|
|
172
172
|
idle_prompt_ms: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
173
173
|
}, z.core.$strip>>>;
|
|
174
174
|
}, z.core.$strip>>>;
|
|
175
|
+
inject: z.ZodOptional<z.ZodDefault<z.ZodObject<{
|
|
176
|
+
enabled: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
|
|
177
|
+
scope_allowlist: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
|
|
178
|
+
type_allowlist: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
|
|
179
|
+
max_per_turn: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
180
|
+
}, z.core.$strip>>>;
|
|
175
181
|
}, z.core.$strip>>;
|
|
176
182
|
export type AstrocodeConfig = z.infer<typeof AstrocodeConfigSchema>;
|
|
177
183
|
export {};
|
package/dist/config/schema.js
CHANGED
|
@@ -161,6 +161,15 @@ const GitSchema = z.object({
|
|
|
161
161
|
commit_message_template: z.string().default("astro: {{story_key}} {{title}}"),
|
|
162
162
|
persist_diff_artifacts: z.boolean().default(true),
|
|
163
163
|
}).partial().default({});
|
|
164
|
+
const InjectSchema = z
|
|
165
|
+
.object({
|
|
166
|
+
enabled: z.boolean().default(true),
|
|
167
|
+
scope_allowlist: z.array(z.string()).default(["repo", "global"]),
|
|
168
|
+
type_allowlist: z.array(z.string()).default(["note", "policy"]),
|
|
169
|
+
max_per_turn: z.number().int().positive().default(5),
|
|
170
|
+
})
|
|
171
|
+
.partial()
|
|
172
|
+
.default({});
|
|
164
173
|
const UiSchema = z
|
|
165
174
|
.object({
|
|
166
175
|
toasts: ToastsSchema,
|
|
@@ -196,4 +205,5 @@ export const AstrocodeConfigSchema = z.object({
|
|
|
196
205
|
permissions: PermissionsSchema,
|
|
197
206
|
git: GitSchema,
|
|
198
207
|
ui: UiSchema,
|
|
208
|
+
inject: InjectSchema,
|
|
199
209
|
}).partial().default({});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AstrocodeConfig } from "../config/schema";
|
|
2
|
+
import type { SqliteDb } from "../state/db";
|
|
3
|
+
type ChatMessageInput = {
|
|
4
|
+
sessionID: string;
|
|
5
|
+
agent: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function createInjectProvider(opts: {
|
|
8
|
+
ctx: any;
|
|
9
|
+
config: AstrocodeConfig;
|
|
10
|
+
db: SqliteDb;
|
|
11
|
+
}): {
|
|
12
|
+
onChatMessage(input: ChatMessageInput): Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { selectEligibleInjects } from "../tools/injects";
|
|
2
|
+
import { injectChatPrompt } from "../ui/inject";
|
|
3
|
+
import { nowISO } from "../shared/time";
|
|
4
|
+
export function createInjectProvider(opts) {
|
|
5
|
+
const { ctx, config, db } = opts;
|
|
6
|
+
// Cache to avoid re-injecting the same injects repeatedly
|
|
7
|
+
const injectedCache = new Map();
|
|
8
|
+
function shouldSkipInject(injectId, nowMs) {
|
|
9
|
+
const lastInjected = injectedCache.get(injectId);
|
|
10
|
+
if (!lastInjected)
|
|
11
|
+
return false;
|
|
12
|
+
// Skip if injected within the last 5 minutes (configurable?)
|
|
13
|
+
const cooldownMs = 5 * 60 * 1000;
|
|
14
|
+
return nowMs - lastInjected < cooldownMs;
|
|
15
|
+
}
|
|
16
|
+
function markInjected(injectId, nowMs) {
|
|
17
|
+
injectedCache.set(injectId, nowMs);
|
|
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
|
+
}
|
|
62
|
+
async function injectEligibleInjects(sessionId) {
|
|
63
|
+
const now = nowISO();
|
|
64
|
+
const nowMs = Date.now();
|
|
65
|
+
// Get allowlists from config or defaults
|
|
66
|
+
const scopeAllowlist = config.inject?.scope_allowlist ?? ["repo", "global"];
|
|
67
|
+
const typeAllowlist = config.inject?.type_allowlist ?? ["note", "policy"];
|
|
68
|
+
// Get diagnostic data
|
|
69
|
+
const diagnostics = getInjectionDiagnostics(now, scopeAllowlist, typeAllowlist);
|
|
70
|
+
const eligibleInjects = selectEligibleInjects(db, {
|
|
71
|
+
nowIso: now,
|
|
72
|
+
scopeAllowlist,
|
|
73
|
+
typeAllowlist,
|
|
74
|
+
limit: config.inject?.max_per_turn ?? 5,
|
|
75
|
+
});
|
|
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}`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Inject each eligible inject, skipping recently injected ones
|
|
84
|
+
for (const inject of eligibleInjects) {
|
|
85
|
+
if (shouldSkipInject(inject.inject_id, nowMs)) {
|
|
86
|
+
skippedDeduped++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Format as injection message
|
|
90
|
+
const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
|
|
91
|
+
await injectChatPrompt({
|
|
92
|
+
ctx,
|
|
93
|
+
sessionId,
|
|
94
|
+
text: formattedText,
|
|
95
|
+
agent: "Astrocode"
|
|
96
|
+
});
|
|
97
|
+
injected++;
|
|
98
|
+
markInjected(inject.inject_id, nowMs);
|
|
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}}`);
|
|
102
|
+
}
|
|
103
|
+
// Public hook handlers
|
|
104
|
+
return {
|
|
105
|
+
async onChatMessage(input) {
|
|
106
|
+
if (!config.inject?.enabled)
|
|
107
|
+
return;
|
|
108
|
+
// Inject eligible injects before processing the user's message
|
|
109
|
+
await injectEligibleInjects(input.sessionID);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { getAstroPaths, ensureAstroDirs } from "./shared/paths";
|
|
|
5
5
|
import { createAstroTools } from "./tools";
|
|
6
6
|
import { createContinuationEnforcer } from "./hooks/continuation-enforcer";
|
|
7
7
|
import { createToolOutputTruncatorHook } from "./hooks/tool-output-truncator";
|
|
8
|
+
import { createInjectProvider } from "./hooks/inject-provider";
|
|
8
9
|
import { createToastManager } from "./ui/toasts";
|
|
9
10
|
import { createAstroAgents } from "./agents/registry";
|
|
10
11
|
const Astrocode = async (ctx) => {
|
|
@@ -21,6 +22,7 @@ const Astrocode = async (ctx) => {
|
|
|
21
22
|
let configHandler = null;
|
|
22
23
|
let continuation = null;
|
|
23
24
|
let truncatorHook = null;
|
|
25
|
+
let injectProvider = null;
|
|
24
26
|
let toasts = null;
|
|
25
27
|
try {
|
|
26
28
|
db = openSqlite(paths.dbPath, { busyTimeoutMs: pluginConfig.db.busy_timeout_ms });
|
|
@@ -31,6 +33,7 @@ const Astrocode = async (ctx) => {
|
|
|
31
33
|
tools = createAstroTools({ ctx, config: pluginConfig, db, agents });
|
|
32
34
|
continuation = createContinuationEnforcer({ ctx, config: pluginConfig, db });
|
|
33
35
|
truncatorHook = createToolOutputTruncatorHook({ ctx, config: pluginConfig, db });
|
|
36
|
+
injectProvider = createInjectProvider({ ctx, config: pluginConfig, db });
|
|
34
37
|
toasts = createToastManager({ ctx, throttleMs: pluginConfig.ui.toasts.throttle_ms });
|
|
35
38
|
}
|
|
36
39
|
catch (e) {
|
|
@@ -46,6 +49,7 @@ const Astrocode = async (ctx) => {
|
|
|
46
49
|
tools = createAstroTools({ ctx, config: pluginConfig, db, agents });
|
|
47
50
|
continuation = null;
|
|
48
51
|
truncatorHook = null;
|
|
52
|
+
injectProvider = null;
|
|
49
53
|
toasts = null;
|
|
50
54
|
}
|
|
51
55
|
return {
|
|
@@ -80,6 +84,9 @@ const Astrocode = async (ctx) => {
|
|
|
80
84
|
}
|
|
81
85
|
},
|
|
82
86
|
"chat.message": async (input, output) => {
|
|
87
|
+
if (injectProvider && !pluginConfig.disabled_hooks.includes("inject-provider")) {
|
|
88
|
+
await injectProvider.onChatMessage(input);
|
|
89
|
+
}
|
|
83
90
|
if (continuation && !pluginConfig.disabled_hooks.includes("continuation-enforcer")) {
|
|
84
91
|
await continuation.onChatMessage(input);
|
|
85
92
|
}
|
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
|
@@ -21,3 +21,33 @@ export declare function createAstroInjectSearchTool(opts: {
|
|
|
21
21
|
config: AstrocodeConfig;
|
|
22
22
|
db: SqliteDb;
|
|
23
23
|
}): ToolDefinition;
|
|
24
|
+
export type InjectRow = {
|
|
25
|
+
inject_id: string;
|
|
26
|
+
type: string;
|
|
27
|
+
title: string;
|
|
28
|
+
body_md: string;
|
|
29
|
+
tags_json: string;
|
|
30
|
+
scope: string;
|
|
31
|
+
source: string;
|
|
32
|
+
priority: number;
|
|
33
|
+
expires_at: string | null;
|
|
34
|
+
sha256: string;
|
|
35
|
+
created_at: string;
|
|
36
|
+
updated_at: string;
|
|
37
|
+
};
|
|
38
|
+
export declare function selectEligibleInjects(db: SqliteDb, opts: {
|
|
39
|
+
nowIso: string;
|
|
40
|
+
scopeAllowlist: string[];
|
|
41
|
+
typeAllowlist: string[];
|
|
42
|
+
limit?: number;
|
|
43
|
+
}): InjectRow[];
|
|
44
|
+
export declare function createAstroInjectEligibleTool(opts: {
|
|
45
|
+
ctx: any;
|
|
46
|
+
config: AstrocodeConfig;
|
|
47
|
+
db: SqliteDb;
|
|
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
|
@@ -97,3 +97,122 @@ export function createAstroInjectSearchTool(opts) {
|
|
|
97
97
|
},
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
|
+
export function selectEligibleInjects(db, opts) {
|
|
101
|
+
const { nowIso, scopeAllowlist, typeAllowlist, limit = 50 } = opts;
|
|
102
|
+
// Build placeholders safely
|
|
103
|
+
const scopeQs = scopeAllowlist.map(() => "?").join(", ");
|
|
104
|
+
const typeQs = typeAllowlist.map(() => "?").join(", ");
|
|
105
|
+
const sql = `
|
|
106
|
+
SELECT *
|
|
107
|
+
FROM injects
|
|
108
|
+
WHERE (expires_at IS NULL OR expires_at > ?)
|
|
109
|
+
AND scope IN (${scopeQs})
|
|
110
|
+
AND type IN (${typeQs})
|
|
111
|
+
ORDER BY priority DESC, updated_at DESC
|
|
112
|
+
LIMIT ?
|
|
113
|
+
`;
|
|
114
|
+
const params = [nowIso, ...scopeAllowlist, ...typeAllowlist, limit];
|
|
115
|
+
return db.prepare(sql).all(...params);
|
|
116
|
+
}
|
|
117
|
+
export function createAstroInjectEligibleTool(opts) {
|
|
118
|
+
const { db } = opts;
|
|
119
|
+
return tool({
|
|
120
|
+
description: "Debug: show which injects are eligible right now for injection.",
|
|
121
|
+
args: {
|
|
122
|
+
scopes_json: tool.schema.string().default('["repo","global"]'),
|
|
123
|
+
types_json: tool.schema.string().default('["note","policy"]'),
|
|
124
|
+
limit: tool.schema.number().int().positive().default(50),
|
|
125
|
+
},
|
|
126
|
+
execute: async ({ scopes_json, types_json, limit }) => {
|
|
127
|
+
const now = nowISO();
|
|
128
|
+
const scopes = JSON.parse(scopes_json);
|
|
129
|
+
const types = JSON.parse(types_json);
|
|
130
|
+
const rows = selectEligibleInjects(db, {
|
|
131
|
+
nowIso: now,
|
|
132
|
+
scopeAllowlist: scopes,
|
|
133
|
+
typeAllowlist: types,
|
|
134
|
+
limit,
|
|
135
|
+
});
|
|
136
|
+
return JSON.stringify({ now, count: rows.length, rows }, null, 2);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
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,22 +9,48 @@ 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 = {
|
|
13
|
-
frame: "
|
|
14
|
-
plan: "
|
|
15
|
-
spec: "
|
|
16
|
-
implement: "
|
|
17
|
-
review: "
|
|
18
|
-
verify: "
|
|
19
|
-
close: "
|
|
12
|
+
export const STAGE_TO_AGENT_MAP = {
|
|
13
|
+
frame: "Frame",
|
|
14
|
+
plan: "Plan",
|
|
15
|
+
spec: "Spec",
|
|
16
|
+
implement: "Implement",
|
|
17
|
+
review: "Review",
|
|
18
|
+
verify: "Verify",
|
|
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
package/src/config/schema.ts
CHANGED
|
@@ -194,6 +194,16 @@ const GitSchema = z.object({
|
|
|
194
194
|
persist_diff_artifacts: z.boolean().default(true),
|
|
195
195
|
}).partial().default({});
|
|
196
196
|
|
|
197
|
+
const InjectSchema = z
|
|
198
|
+
.object({
|
|
199
|
+
enabled: z.boolean().default(true),
|
|
200
|
+
scope_allowlist: z.array(z.string()).default(["repo", "global"]),
|
|
201
|
+
type_allowlist: z.array(z.string()).default(["note", "policy"]),
|
|
202
|
+
max_per_turn: z.number().int().positive().default(5),
|
|
203
|
+
})
|
|
204
|
+
.partial()
|
|
205
|
+
.default({});
|
|
206
|
+
|
|
197
207
|
const UiSchema = z
|
|
198
208
|
.object({
|
|
199
209
|
toasts: ToastsSchema,
|
|
@@ -232,6 +242,7 @@ export const AstrocodeConfigSchema = z.object({
|
|
|
232
242
|
permissions: PermissionsSchema,
|
|
233
243
|
git: GitSchema,
|
|
234
244
|
ui: UiSchema,
|
|
245
|
+
inject: InjectSchema,
|
|
235
246
|
}).partial().default({});
|
|
236
247
|
|
|
237
248
|
export type AstrocodeConfig = z.infer<typeof AstrocodeConfigSchema>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { AstrocodeConfig } from "../config/schema";
|
|
2
|
+
import type { SqliteDb } from "../state/db";
|
|
3
|
+
import { selectEligibleInjects } from "../tools/injects";
|
|
4
|
+
import { injectChatPrompt } from "../ui/inject";
|
|
5
|
+
import { nowISO } from "../shared/time";
|
|
6
|
+
|
|
7
|
+
type ChatMessageInput = {
|
|
8
|
+
sessionID: string;
|
|
9
|
+
agent: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function createInjectProvider(opts: {
|
|
13
|
+
ctx: any;
|
|
14
|
+
config: AstrocodeConfig;
|
|
15
|
+
db: SqliteDb;
|
|
16
|
+
}) {
|
|
17
|
+
const { ctx, config, db } = opts;
|
|
18
|
+
|
|
19
|
+
// Cache to avoid re-injecting the same injects repeatedly
|
|
20
|
+
const injectedCache = new Map<string, number>();
|
|
21
|
+
|
|
22
|
+
function shouldSkipInject(injectId: string, nowMs: number): boolean {
|
|
23
|
+
const lastInjected = injectedCache.get(injectId);
|
|
24
|
+
if (!lastInjected) return false;
|
|
25
|
+
|
|
26
|
+
// Skip if injected within the last 5 minutes (configurable?)
|
|
27
|
+
const cooldownMs = 5 * 60 * 1000;
|
|
28
|
+
return nowMs - lastInjected < cooldownMs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function markInjected(injectId: string, nowMs: number) {
|
|
32
|
+
injectedCache.set(injectId, nowMs);
|
|
33
|
+
}
|
|
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
|
+
|
|
85
|
+
async function injectEligibleInjects(sessionId: string) {
|
|
86
|
+
const now = nowISO();
|
|
87
|
+
const nowMs = Date.now();
|
|
88
|
+
|
|
89
|
+
// Get allowlists from config or defaults
|
|
90
|
+
const scopeAllowlist = config.inject?.scope_allowlist ?? ["repo", "global"];
|
|
91
|
+
const typeAllowlist = config.inject?.type_allowlist ?? ["note", "policy"];
|
|
92
|
+
|
|
93
|
+
// Get diagnostic data
|
|
94
|
+
const diagnostics = getInjectionDiagnostics(now, scopeAllowlist, typeAllowlist);
|
|
95
|
+
|
|
96
|
+
const eligibleInjects = selectEligibleInjects(db, {
|
|
97
|
+
nowIso: now,
|
|
98
|
+
scopeAllowlist,
|
|
99
|
+
typeAllowlist,
|
|
100
|
+
limit: config.inject?.max_per_turn ?? 5,
|
|
101
|
+
});
|
|
102
|
+
|
|
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
|
+
}
|
|
111
|
+
|
|
112
|
+
// Inject each eligible inject, skipping recently injected ones
|
|
113
|
+
for (const inject of eligibleInjects) {
|
|
114
|
+
if (shouldSkipInject(inject.inject_id, nowMs)) {
|
|
115
|
+
skippedDeduped++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Format as injection message
|
|
120
|
+
const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
|
|
121
|
+
|
|
122
|
+
await injectChatPrompt({
|
|
123
|
+
ctx,
|
|
124
|
+
sessionId,
|
|
125
|
+
text: formattedText,
|
|
126
|
+
agent: "Astrocode"
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
injected++;
|
|
130
|
+
markInjected(inject.inject_id, nowMs);
|
|
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}}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Public hook handlers
|
|
138
|
+
return {
|
|
139
|
+
async onChatMessage(input: ChatMessageInput) {
|
|
140
|
+
if (!config.inject?.enabled) return;
|
|
141
|
+
|
|
142
|
+
// Inject eligible injects before processing the user's message
|
|
143
|
+
await injectEligibleInjects(input.sessionID);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { getAstroPaths, ensureAstroDirs } from "./shared/paths";
|
|
|
6
6
|
import { createAstroTools } from "./tools";
|
|
7
7
|
import { createContinuationEnforcer } from "./hooks/continuation-enforcer";
|
|
8
8
|
import { createToolOutputTruncatorHook } from "./hooks/tool-output-truncator";
|
|
9
|
+
import { createInjectProvider } from "./hooks/inject-provider";
|
|
9
10
|
import { createToastManager } from "./ui/toasts";
|
|
10
11
|
import { createAstroAgents } from "./agents/registry";
|
|
11
12
|
import { info, warn } from "./shared/log";
|
|
@@ -25,10 +26,11 @@ const Astrocode: Plugin = async (ctx) => {
|
|
|
25
26
|
|
|
26
27
|
let db: any = null;
|
|
27
28
|
let tools: any = null;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
let configHandler: any = null;
|
|
30
|
+
let continuation: any = null;
|
|
31
|
+
let truncatorHook: any = null;
|
|
32
|
+
let injectProvider: any = null;
|
|
33
|
+
let toasts: any = null;
|
|
32
34
|
|
|
33
35
|
try {
|
|
34
36
|
|
|
@@ -41,6 +43,7 @@ const Astrocode: Plugin = async (ctx) => {
|
|
|
41
43
|
tools = createAstroTools({ ctx, config: pluginConfig, db, agents });
|
|
42
44
|
continuation = createContinuationEnforcer({ ctx, config: pluginConfig, db });
|
|
43
45
|
truncatorHook = createToolOutputTruncatorHook({ ctx, config: pluginConfig, db });
|
|
46
|
+
injectProvider = createInjectProvider({ ctx, config: pluginConfig, db });
|
|
44
47
|
toasts = createToastManager({ ctx, throttleMs: pluginConfig.ui.toasts.throttle_ms });
|
|
45
48
|
} catch (e) {
|
|
46
49
|
// Database initialization failed - setup limited mode
|
|
@@ -58,6 +61,7 @@ const Astrocode: Plugin = async (ctx) => {
|
|
|
58
61
|
tools = createAstroTools({ ctx, config: pluginConfig, db, agents });
|
|
59
62
|
continuation = null;
|
|
60
63
|
truncatorHook = null;
|
|
64
|
+
injectProvider = null;
|
|
61
65
|
toasts = null;
|
|
62
66
|
}
|
|
63
67
|
|
|
@@ -102,6 +106,9 @@ const Astrocode: Plugin = async (ctx) => {
|
|
|
102
106
|
},
|
|
103
107
|
|
|
104
108
|
"chat.message": async (input: any, output: any) => {
|
|
109
|
+
if (injectProvider && !pluginConfig.disabled_hooks.includes("inject-provider")) {
|
|
110
|
+
await injectProvider.onChatMessage(input);
|
|
111
|
+
}
|
|
105
112
|
if (continuation && !pluginConfig.disabled_hooks.includes("continuation-enforcer")) {
|
|
106
113
|
await continuation.onChatMessage(input);
|
|
107
114
|
}
|
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
|
@@ -106,3 +106,159 @@ export function createAstroInjectSearchTool(opts: { ctx: any; config: AstrocodeC
|
|
|
106
106
|
},
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
|
+
|
|
110
|
+
export type InjectRow = {
|
|
111
|
+
inject_id: string;
|
|
112
|
+
type: string;
|
|
113
|
+
title: string;
|
|
114
|
+
body_md: string;
|
|
115
|
+
tags_json: string;
|
|
116
|
+
scope: string;
|
|
117
|
+
source: string;
|
|
118
|
+
priority: number;
|
|
119
|
+
expires_at: string | null;
|
|
120
|
+
sha256: string;
|
|
121
|
+
created_at: string;
|
|
122
|
+
updated_at: string;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export function selectEligibleInjects(db: SqliteDb, opts: {
|
|
126
|
+
nowIso: string;
|
|
127
|
+
scopeAllowlist: string[];
|
|
128
|
+
typeAllowlist: string[];
|
|
129
|
+
limit?: number;
|
|
130
|
+
}): InjectRow[] {
|
|
131
|
+
const { nowIso, scopeAllowlist, typeAllowlist, limit = 50 } = opts;
|
|
132
|
+
|
|
133
|
+
// Build placeholders safely
|
|
134
|
+
const scopeQs = scopeAllowlist.map(() => "?").join(", ");
|
|
135
|
+
const typeQs = typeAllowlist.map(() => "?").join(", ");
|
|
136
|
+
|
|
137
|
+
const sql = `
|
|
138
|
+
SELECT *
|
|
139
|
+
FROM injects
|
|
140
|
+
WHERE (expires_at IS NULL OR expires_at > ?)
|
|
141
|
+
AND scope IN (${scopeQs})
|
|
142
|
+
AND type IN (${typeQs})
|
|
143
|
+
ORDER BY priority DESC, updated_at DESC
|
|
144
|
+
LIMIT ?
|
|
145
|
+
`;
|
|
146
|
+
|
|
147
|
+
const params = [nowIso, ...scopeAllowlist, ...typeAllowlist, limit];
|
|
148
|
+
return db.prepare(sql).all(...params) as InjectRow[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createAstroInjectEligibleTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
|
|
152
|
+
const { db } = opts;
|
|
153
|
+
|
|
154
|
+
return tool({
|
|
155
|
+
description: "Debug: show which injects are eligible right now for injection.",
|
|
156
|
+
args: {
|
|
157
|
+
scopes_json: tool.schema.string().default('["repo","global"]'),
|
|
158
|
+
types_json: tool.schema.string().default('["note","policy"]'),
|
|
159
|
+
limit: tool.schema.number().int().positive().default(50),
|
|
160
|
+
},
|
|
161
|
+
execute: async ({ scopes_json, types_json, limit }) => {
|
|
162
|
+
const now = nowISO();
|
|
163
|
+
const scopes = JSON.parse(scopes_json) as string[];
|
|
164
|
+
const types = JSON.parse(types_json) as string[];
|
|
165
|
+
|
|
166
|
+
const rows = selectEligibleInjects(db, {
|
|
167
|
+
nowIso: now,
|
|
168
|
+
scopeAllowlist: scopes,
|
|
169
|
+
typeAllowlist: types,
|
|
170
|
+
limit,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return JSON.stringify({ now, count: rows.length, rows }, null, 2);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
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,23 +13,50 @@ 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> = {
|
|
17
|
-
frame: "
|
|
18
|
-
plan: "
|
|
19
|
-
spec: "
|
|
20
|
-
implement: "
|
|
21
|
-
review: "
|
|
22
|
-
verify: "
|
|
23
|
-
close: "
|
|
16
|
+
export const STAGE_TO_AGENT_MAP: Record<string, string> = {
|
|
17
|
+
frame: "Frame",
|
|
18
|
+
plan: "Plan",
|
|
19
|
+
spec: "Spec",
|
|
20
|
+
implement: "Implement",
|
|
21
|
+
review: "Review",
|
|
22
|
+
verify: "Verify",
|
|
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
|
|