@nyxa/nyx-agent 0.1.0

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.
@@ -0,0 +1,244 @@
1
+ import path from "node:path";
2
+ import { execa } from "execa";
3
+ import { buildPhasePrompt, phaseRequiresStructuredResult } from "./buildPrompt.js";
4
+ import { getEffectiveHarness, getEffectiveModel } from "./effectiveConfig.js";
5
+ import { ensureDir, readText, writeJson, writeText } from "./files.js";
6
+ import { getGitSnapshot } from "./git.js";
7
+ import { parseNyxAgentResult, getOutcome } from "./parseResult.js";
8
+ import { resolveNyxPath } from "./paths.js";
9
+ import { renderTemplate } from "./renderTemplate.js";
10
+ import { validateAgainstSchema } from "./validateResult.js";
11
+ export async function runPhase(input) {
12
+ const phaseDir = path.join(input.iterationDir, "phases", input.phase.id);
13
+ await ensureDir(phaseDir);
14
+ const context = buildContext({ ...input, phaseDir });
15
+ const prompt = await buildPhasePrompt({
16
+ projectRoot: input.projectRoot,
17
+ runDir: input.runDir,
18
+ iterationDir: input.iterationDir,
19
+ phaseDir,
20
+ stateFile: input.stateFile,
21
+ config: input.config,
22
+ phase: input.phase,
23
+ context
24
+ });
25
+ const attempt = await runHarnessAttempt({
26
+ attemptDir: path.join(phaseDir, "attempt-001"),
27
+ prompt,
28
+ context,
29
+ config: input.config,
30
+ phase: input.phase,
31
+ projectRoot: input.projectRoot
32
+ });
33
+ if (attempt.exitCode !== 0) {
34
+ await writeAttemptMeta(attempt, {
35
+ failure: `Harness exited with code ${attempt.exitCode}`
36
+ });
37
+ return {
38
+ ok: false,
39
+ error: `Phase "${input.phase.id}" failed with exit code ${attempt.exitCode}`
40
+ };
41
+ }
42
+ if (!phaseRequiresStructuredResult(input.phase)) {
43
+ await writeAttemptMeta(attempt);
44
+ return { ok: true };
45
+ }
46
+ const parsed = await parseAndValidatePhaseResult({
47
+ input,
48
+ phaseDir,
49
+ attempt
50
+ });
51
+ if (parsed.ok) {
52
+ return parsed;
53
+ }
54
+ await writeAttemptMeta(attempt, {
55
+ result_error: parsed.error
56
+ });
57
+ const repaired = await repairStructuredResult({
58
+ input,
59
+ phaseDir,
60
+ originalPrompt: prompt,
61
+ originalAttempt: attempt,
62
+ validationError: parsed.error
63
+ });
64
+ if (repaired.ok) {
65
+ return repaired;
66
+ }
67
+ return {
68
+ ok: false,
69
+ error: repaired.error
70
+ };
71
+ }
72
+ function buildContext(input) {
73
+ const model = getEffectiveModel(input.config, input.phase);
74
+ const harness = getEffectiveHarness(input.config, input.phase);
75
+ return {
76
+ project_root: input.projectRoot,
77
+ run_dir: input.runDir,
78
+ iteration_dir: input.iterationDir,
79
+ phase_dir: input.phaseDir,
80
+ state_file: input.stateFile,
81
+ phase: input.phase,
82
+ workflow: input.config.workflow,
83
+ work_items: input.config.work_items ?? {},
84
+ work_item: input.state.work_item ?? {},
85
+ seen_work_item_keys: input.state.seen_work_item_keys ?? [],
86
+ phase_results: input.state.phase_results ?? {},
87
+ state: input.state,
88
+ model,
89
+ harness
90
+ };
91
+ }
92
+ async function runHarnessAttempt(input) {
93
+ await ensureDir(input.attemptDir);
94
+ await writeText(path.join(input.attemptDir, "prompt.md"), input.prompt);
95
+ const harness = getEffectiveHarness(input.config, input.phase);
96
+ if (harness.prompt_input !== "stdin") {
97
+ throw new Error(`Unsupported prompt_input "${harness.prompt_input}"`);
98
+ }
99
+ const command = renderTemplate(harness.command, input.context);
100
+ const args = harness.args.map((arg) => renderTemplate(arg, input.context));
101
+ const startedAt = new Date().toISOString();
102
+ const started = Date.now();
103
+ const gitBefore = await getGitSnapshot(input.projectRoot);
104
+ let stdout = "";
105
+ let stderr = "";
106
+ let exitCode = 0;
107
+ try {
108
+ const result = await execa(command, args, {
109
+ cwd: input.projectRoot,
110
+ input: input.prompt,
111
+ reject: false
112
+ });
113
+ stdout = result.stdout;
114
+ stderr = result.stderr;
115
+ exitCode = result.exitCode ?? 0;
116
+ }
117
+ catch (error) {
118
+ exitCode = 127;
119
+ stderr = error instanceof Error ? error.message : String(error);
120
+ }
121
+ const endedAt = new Date().toISOString();
122
+ const durationMs = Date.now() - started;
123
+ const gitAfter = await getGitSnapshot(input.projectRoot);
124
+ await writeText(path.join(input.attemptDir, "stdout.log"), stdout);
125
+ await writeText(path.join(input.attemptDir, "stderr.log"), stderr);
126
+ const attempt = {
127
+ attemptDir: input.attemptDir,
128
+ metaPath: path.join(input.attemptDir, "meta.json"),
129
+ stdout,
130
+ stderr,
131
+ exitCode,
132
+ command,
133
+ args,
134
+ startedAt,
135
+ endedAt,
136
+ durationMs,
137
+ gitBefore,
138
+ gitAfter
139
+ };
140
+ await writeAttemptMeta(attempt);
141
+ return attempt;
142
+ }
143
+ async function writeAttemptMeta(attempt, extra = {}) {
144
+ await writeJson(attempt.metaPath, {
145
+ command: attempt.command,
146
+ args: attempt.args,
147
+ started_at: attempt.startedAt,
148
+ ended_at: attempt.endedAt,
149
+ duration_ms: attempt.durationMs,
150
+ exit_code: attempt.exitCode,
151
+ git_before: attempt.gitBefore,
152
+ git_after: attempt.gitAfter,
153
+ ...extra
154
+ });
155
+ }
156
+ async function parseAndValidatePhaseResult(input) {
157
+ const parsed = parseNyxAgentResult(input.attempt.stdout);
158
+ if (!parsed.ok) {
159
+ return parsed;
160
+ }
161
+ if (input.input.phase.output_schema) {
162
+ const schemaPath = resolveNyxPath(input.input.projectRoot, input.input.phase.output_schema, `output_schema for phase "${input.input.phase.id}"`);
163
+ const validation = await validateAgainstSchema(schemaPath, parsed.value);
164
+ if (!validation.ok) {
165
+ return validation;
166
+ }
167
+ }
168
+ if (input.input.phase.transitions && !getOutcome(parsed.value)) {
169
+ return {
170
+ ok: false,
171
+ error: `Phase "${input.input.phase.id}" has transitions but result has no string outcome`
172
+ };
173
+ }
174
+ await writeJson(path.join(input.phaseDir, "result.json"), parsed.value);
175
+ return {
176
+ ok: true,
177
+ result: parsed.value,
178
+ outcome: getOutcome(parsed.value)
179
+ };
180
+ }
181
+ async function repairStructuredResult(input) {
182
+ const maxAttempts = input.input.config.repair.max_attempts;
183
+ if (maxAttempts <= 0) {
184
+ return {
185
+ ok: false,
186
+ error: input.validationError
187
+ };
188
+ }
189
+ const repairPromptTemplate = await readText(resolveNyxPath(input.input.projectRoot, input.input.config.repair.prompt, "repair prompt"));
190
+ let lastError = input.validationError;
191
+ for (let attemptNumber = 1; attemptNumber <= maxAttempts; attemptNumber += 1) {
192
+ const repairContext = buildContext({
193
+ ...input.input,
194
+ phaseDir: input.phaseDir
195
+ });
196
+ const renderedRepairPrompt = renderTemplate(repairPromptTemplate, {
197
+ ...repairContext,
198
+ original_prompt: input.originalPrompt,
199
+ original_stdout: input.originalAttempt.stdout,
200
+ original_stderr: input.originalAttempt.stderr,
201
+ validation_error: lastError
202
+ });
203
+ const repairPrompt = [
204
+ "# NyxAgent Result Repair",
205
+ "",
206
+ "Repair only the structured result for the previous phase attempt.",
207
+ "Do not modify project files. Do not redo the phase work.",
208
+ "Return a valid result inside the final <nyxagent_result> block.",
209
+ "",
210
+ renderedRepairPrompt
211
+ ].join("\n");
212
+ const repairAttempt = await runHarnessAttempt({
213
+ attemptDir: path.join(input.phaseDir, `repair-${String(attemptNumber).padStart(3, "0")}`),
214
+ prompt: repairPrompt,
215
+ context: repairContext,
216
+ config: input.input.config,
217
+ phase: input.input.phase,
218
+ projectRoot: input.input.projectRoot
219
+ });
220
+ if (repairAttempt.exitCode !== 0) {
221
+ lastError = `Repair harness exited with code ${repairAttempt.exitCode}`;
222
+ await writeAttemptMeta(repairAttempt, {
223
+ failure: lastError
224
+ });
225
+ continue;
226
+ }
227
+ const repaired = await parseAndValidatePhaseResult({
228
+ input: input.input,
229
+ phaseDir: input.phaseDir,
230
+ attempt: repairAttempt
231
+ });
232
+ if (repaired.ok) {
233
+ return repaired;
234
+ }
235
+ lastError = repaired.error;
236
+ await writeAttemptMeta(repairAttempt, {
237
+ result_error: repaired.error
238
+ });
239
+ }
240
+ return {
241
+ ok: false,
242
+ error: lastError
243
+ };
244
+ }
@@ -0,0 +1,158 @@
1
+ import path from "node:path";
2
+ import pc from "picocolors";
3
+ import { loadConfig } from "../config/loadConfig.js";
4
+ import { ensureDir, writeJson } from "./files.js";
5
+ import { getGitSnapshot } from "./git.js";
6
+ import { getNyxDir, relativeToProject } from "./paths.js";
7
+ import { runPhase } from "./runPhase.js";
8
+ import { createRunId } from "./time.js";
9
+ export async function runWorkflow(options) {
10
+ const projectRoot = path.resolve(options.projectRoot);
11
+ const nyxDir = getNyxDir(projectRoot);
12
+ const configPath = options.configPath ?? path.join(nyxDir, "config.toml");
13
+ const config = await loadConfig(configPath);
14
+ const runId = createRunId();
15
+ const runDir = path.join(nyxDir, "runs", runId);
16
+ await ensureDir(runDir);
17
+ const git = await getGitSnapshot(projectRoot);
18
+ const runState = {
19
+ run_id: runId,
20
+ status: "running",
21
+ current_iteration: 0,
22
+ completed_iterations: 0,
23
+ seen_work_item_keys: []
24
+ };
25
+ await writeJson(path.join(runDir, "run.json"), {
26
+ run_id: runId,
27
+ project_root: projectRoot,
28
+ started_at: new Date().toISOString(),
29
+ config_path: relativeToProject(projectRoot, configPath),
30
+ harness: {
31
+ preset: config.harness.preset,
32
+ command: config.harness.command
33
+ },
34
+ git
35
+ });
36
+ await writeJson(path.join(runDir, "state.json"), runState);
37
+ console.log(pc.bold(`NyxAgent run ${runId}`));
38
+ console.log(`Project: ${projectRoot}`);
39
+ console.log(`Harness: ${config.harness.preset ?? "custom"} (${config.harness.command})`);
40
+ console.log(`Model: ${config.model.name}`);
41
+ console.log("");
42
+ const phasesById = new Map(config.phases.map((phase) => [phase.id, phase]));
43
+ for (let iterationNumber = 1; iterationNumber <= config.workflow.max_iterations; iterationNumber += 1) {
44
+ runState.current_iteration = iterationNumber;
45
+ await writeJson(path.join(runDir, "state.json"), runState);
46
+ const iterationDir = path.join(runDir, "iterations", String(iterationNumber).padStart(3, "0"));
47
+ await ensureDir(iterationDir);
48
+ const iterationState = {
49
+ iteration: iterationNumber,
50
+ status: "running",
51
+ seen_work_item_keys: [...runState.seen_work_item_keys],
52
+ phase_results: {},
53
+ phase_visit_counts: {}
54
+ };
55
+ const iterationStateFile = path.join(iterationDir, "state.json");
56
+ await writeJson(iterationStateFile, iterationState);
57
+ let currentPhaseId = config.workflow.entry_phase;
58
+ while (currentPhaseId) {
59
+ const phase = phasesById.get(currentPhaseId);
60
+ if (!phase) {
61
+ throw new Error(`Unknown phase "${currentPhaseId}"`);
62
+ }
63
+ const visits = (iterationState.phase_visit_counts[phase.id] ?? 0) + 1;
64
+ iterationState.phase_visit_counts[phase.id] = visits;
65
+ if (visits > phase.max_visits_per_iteration) {
66
+ iterationState.status = "failed";
67
+ runState.status = "failed";
68
+ await writeJson(iterationStateFile, iterationState);
69
+ await writeJson(path.join(runDir, "state.json"), runState);
70
+ throw new Error(`Phase "${phase.id}" exceeded max_visits_per_iteration=${phase.max_visits_per_iteration}`);
71
+ }
72
+ process.stdout.write(`[${iterationNumber}/${config.workflow.max_iterations}] ${phase.id} ... `);
73
+ await writeJson(iterationStateFile, iterationState);
74
+ const phaseResult = await runPhase({
75
+ projectRoot,
76
+ runDir,
77
+ iterationDir,
78
+ stateFile: iterationStateFile,
79
+ config,
80
+ phase,
81
+ state: iterationState
82
+ });
83
+ if (!phaseResult.ok) {
84
+ console.log(pc.red("failed"));
85
+ iterationState.status = "failed";
86
+ iterationState.phase_results[phase.id] = {
87
+ status: "failed",
88
+ error: phaseResult.error
89
+ };
90
+ runState.status = "failed";
91
+ await writeJson(iterationStateFile, iterationState);
92
+ await writeJson(path.join(runDir, "state.json"), runState);
93
+ throw new Error(phaseResult.error);
94
+ }
95
+ iterationState.phase_results[phase.id] =
96
+ phaseResult.result ?? { status: "ok" };
97
+ const workItem = readObjectProperty(phaseResult.result, "work_item");
98
+ if (workItem !== undefined) {
99
+ iterationState.work_item = workItem;
100
+ const key = readObjectProperty(workItem, "key");
101
+ if (typeof key === "string" && !runState.seen_work_item_keys.includes(key)) {
102
+ runState.seen_work_item_keys.push(key);
103
+ iterationState.seen_work_item_keys = [...runState.seen_work_item_keys];
104
+ }
105
+ }
106
+ const nextTarget = resolveNextTarget(phase, phaseResult.outcome);
107
+ const label = phaseResult.outcome ?? "ok";
108
+ console.log(pc.green(label));
109
+ await writeJson(iterationStateFile, iterationState);
110
+ await writeJson(path.join(runDir, "state.json"), runState);
111
+ if (nextTarget === "stop_run") {
112
+ iterationState.status = phaseResult.outcome === "no_work" ? "no_work" : "stopped";
113
+ runState.status =
114
+ phaseResult.outcome === "no_work" ? "completed_no_work" : "stopped";
115
+ await writeJson(iterationStateFile, iterationState);
116
+ await writeJson(path.join(runDir, "state.json"), runState);
117
+ console.log("");
118
+ console.log(`Done: ${runState.status}`);
119
+ console.log(`Run dir: ${relativeToProject(projectRoot, runDir)}`);
120
+ return;
121
+ }
122
+ if (nextTarget === "stop_iteration" || nextTarget === "next_iteration") {
123
+ iterationState.status = "completed";
124
+ runState.completed_iterations += 1;
125
+ await writeJson(iterationStateFile, iterationState);
126
+ await writeJson(path.join(runDir, "state.json"), runState);
127
+ break;
128
+ }
129
+ currentPhaseId = nextTarget;
130
+ }
131
+ }
132
+ runState.status = "completed_max_iterations";
133
+ await writeJson(path.join(runDir, "state.json"), runState);
134
+ console.log("");
135
+ console.log(`Done: ${runState.status}`);
136
+ console.log(`Run dir: ${relativeToProject(projectRoot, runDir)}`);
137
+ }
138
+ function resolveNextTarget(phase, outcome) {
139
+ if (phase.transitions) {
140
+ if (!outcome) {
141
+ throw new Error(`Phase "${phase.id}" requires an outcome`);
142
+ }
143
+ const target = phase.transitions[outcome];
144
+ if (!target) {
145
+ throw new Error(`Phase "${phase.id}" returned unknown outcome "${outcome}"`);
146
+ }
147
+ return target;
148
+ }
149
+ return phase.next;
150
+ }
151
+ function readObjectProperty(value, key) {
152
+ if (value &&
153
+ typeof value === "object" &&
154
+ Object.prototype.hasOwnProperty.call(value, key)) {
155
+ return value[key];
156
+ }
157
+ return undefined;
158
+ }
@@ -0,0 +1,3 @@
1
+ export function createRunId(date = new Date()) {
2
+ return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[:]/g, "-");
3
+ }
@@ -0,0 +1,15 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { Ajv2020 } from "ajv/dist/2020.js";
3
+ export async function validateAgainstSchema(schemaPath, value) {
4
+ const schema = JSON.parse(await readFile(schemaPath, "utf8"));
5
+ const ajv = new Ajv2020({ allErrors: true });
6
+ const validate = ajv.compile(schema);
7
+ const valid = validate(value);
8
+ if (valid) {
9
+ return { ok: true };
10
+ }
11
+ return {
12
+ ok: false,
13
+ error: ajv.errorsText(validate.errors, { separator: "\n" })
14
+ };
15
+ }