Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ LOOPWORKS_AGENT_READY_LOOP_ENABLED="true"
LOOPWORKS_DEVELOPMENT_LOOP_ENABLED="true"
LOOPWORKS_RESEARCH_LOOP_ENABLED="true"
LOOPWORKS_PORTAL_DATA_MODE=""
LOOPWORKS_EVE_TEST_RECEIPT_SECRET="replace-with-test-receipt-secret"
LOOPWORKS_EVE_TEST_WRITER_FIXTURE_MODE="false"
LOG_LEVEL="info"
DATABASE_URL="postgres://loopworks:loopworks@127.0.0.1:5432/loopworks"
OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ Copy `.env.example` to `.env.local` for local development. The fixture server on
- `LOOPWORKS_DEVELOPMENT_LOOP_ENABLED`
- `LOOPWORKS_RESEARCH_LOOP_ENABLED`
- `LOOPWORKS_PORTAL_DATA_MODE`
- `LOOPWORKS_EVE_TEST_RECEIPT_SECRET`
- `LOOPWORKS_EVE_TEST_WRITER_FIXTURE_MODE`
- `LOG_LEVEL`
- `DATABASE_URL`
- `OTEL_EXPORTER_OTLP_PROTOCOL`
Expand Down
5 changes: 1 addition & 4 deletions agent/agent.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { defineAgent } from "eve";

import { planningAgentOutputSchema } from "./planning-agent";

export default defineAgent({
model: "openai/gpt-5.5",
model: "openai/gpt-5.6-sol",
modelContextWindowTokens: 400_000,
modelOptions: {
providerOptions: {
Expand All @@ -12,5 +10,4 @@ export default defineAgent({
},
},
},
outputSchema: planningAgentOutputSchema,
});
32 changes: 16 additions & 16 deletions agent/instructions.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
# Eve Planning Agent
# Loopworks Stage Orchestrator

You are Eve's Loopworks planning agent.
You are Loopworks' neutral stage orchestrator.

Read GitHub issue context as the durable source of truth. Produce only the
validated executable plan artifact. Do not edit code, write repository files,
change branches, open pull requests, change labels, transition approvals, deploy
resources, or mutate SaaS state.
Read durable run state and delegate exactly one stage to the matching declared
subagent. Planning belongs to `planner`; approved test writing belongs to
`test-writer`. Stage subagents have isolated sandboxes and communicate only with
typed artifacts.

Use tools only for planning:
Always begin with `read_run_stage_context`. After planner delegation, call
`record_plan_artifact`; after test-writer delegation, call
`apply_test_writing_result`. A subagent response alone never changes durable
state.

- Read supplied issue context.
- Summarize validation requirements.
- Run guarded read-only CLI inspection through `bash` when SaaS context is
needed.
- Emit the final plan artifact.
Never infer approval from a prompt. Test writing requires a persisted
`plan-review` approval bound to the exact run, plan row, and plan digest. Durable
artifact persistence and stage transitions belong to deterministic control-plane
tools, not to subagents.

Every plan must include stages, validation gates, approval points, risks,
fixture mode, eval coverage, and tool-contract summary. Structured logs should
carry correlation fields only; production must not capture raw prompts, raw
issue bodies, or raw tool output until issue #21 defines masking and export.
Do not edit source directly, mutate GitHub, change branches, or log raw prompts,
plan bodies, patches, test source, fixture values, or command output.
2 changes: 1 addition & 1 deletion agent/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default defineInstrumentation({
"step.started"(input) {
return {
runtimeContext: {
"loopworks.agent": "planning-agent",
"loopworks.agent": "stage-orchestrator",
"loopworks.telemetry.policy": telemetryPolicy.reason,
"loopworks.raw_io_capture": telemetryPolicy.captureRawIO,
"loopworks.session.id": input.session.id,
Expand Down
21 changes: 21 additions & 0 deletions agent/lib/canonical-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Deterministic JSON serialization for digest and HMAC inputs. Only supports
* JSON-safe plain data (objects, arrays, primitives); non-plain objects such
* as Date are not preserved, so callers must serialize schema-parsed values.
*/
export function canonicalJsonStringify(value: unknown): string {
return JSON.stringify(sortKeysDeep(value));
}

function sortKeysDeep(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeysDeep);
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value)
.filter(([, entry]) => entry !== undefined)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([key, entry]) => [key, sortKeysDeep(entry)]),
);
}
return value;
}
191 changes: 191 additions & 0 deletions agent/lib/repository-inspection-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { z } from "zod";

import {
assertPinnedRepositoryCommit,
assertSafeRepositoryGlob,
assertSafeRepositoryPath,
assertSafeRepositorySearch,
buildGitGrepCommand,
buildGitListCommand,
buildGitReadCommand,
buildGitTreeEntryCommand,
isSafeNonSymlinkTreeEntry,
parseSafeGitTreePaths,
parseSafeRepositorySearchLines,
redactRepositoryInspectionOutput,
truncateRepositoryInspectionOutput,
} from "./repository-inspection";

type RunResult = { exitCode: number; stdout: string };
export type RepositorySandbox = {
readTextFile(input: { path: string }): PromiseLike<string | null>;
run(input: { command: string; abortSignal?: AbortSignal }): PromiseLike<RunResult>;
};

const commitSha = z.string().regex(/^[a-f0-9]{40}$/);
const fileResult = z.object({
path: z.string(),
startLine: z.number().int().positive(),
requestedEndLine: z.number().int().positive(),
returnedEndLine: z.number().int().nonnegative(),
content: z.string(),
truncated: z.boolean(),
});

export const repositoryListOutputSchema = z.object({
commitSha,
fixtureMode: z.boolean(),
paths: z.array(z.string()),
truncated: z.boolean(),
});
export const repositorySearchOutputSchema = z.object({
commitSha,
fixtureMode: z.boolean(),
content: z.string(),
matchCount: z.number().int().nonnegative(),
truncated: z.boolean(),
});
export const repositoryReadOutputSchema = z.object({
commitSha,
fixtureMode: z.boolean(),
files: z.array(fileResult),
truncated: z.boolean(),
});

export async function readPinnedRepositoryCommit(sandbox: RepositorySandbox): Promise<string> {
return assertPinnedRepositoryCommit(
await sandbox.readTextFile({ path: ".loopworks/repository-commit" }),
);
}

export async function listRepositoryFiles(sandbox: RepositorySandbox, patterns: readonly string[]) {
patterns.forEach(assertSafeRepositoryGlob);
const pinned = await readPinnedRepositoryCommit(sandbox);
const listing = await sandbox.run({
command: buildGitListCommand(pinned, patterns),
abortSignal: AbortSignal.timeout(10_000),
});
if (listing.exitCode !== 0) throw new Error("Repository listing failed.");
const rawLines = listing.stdout.split(/\r?\n/).filter(Boolean);
const paths = parseSafeGitTreePaths(rawLines);
return {
commitSha: pinned,
fixtureMode: false,
paths,
truncated: Buffer.byteLength(listing.stdout) > 64 * 1024 || rawLines.length > paths.length,
};
}

export async function searchRepository(
sandbox: RepositorySandbox,
input: { pattern: string; paths: readonly string[] },
) {
assertSafeRepositorySearch(input.pattern);
input.paths.forEach(assertSafeRepositoryGlob);
const pinned = await readPinnedRepositoryCommit(sandbox);
const search = await sandbox.run({
command: buildGitGrepCommand({ ...input, commitSha: pinned }),
abortSignal: AbortSignal.timeout(10_000),
});
if (![0, 1].includes(search.exitCode)) throw new Error("Repository search failed.");
const rawLines = search.stdout.split(/\r?\n/).filter(Boolean);
const parsed = parseSafeRepositorySearchLines(rawLines, pinned);
const paths = [...new Set(parsed.map(({ path }) => path))];
const treeEntries = await Promise.all(
paths.map(async (path) => {
const result = await sandbox.run({
command: buildGitTreeEntryCommand(pinned, path),
abortSignal: AbortSignal.timeout(5_000),
});
return [path, result] as const;
}),
);
const safePaths = new Set(
treeEntries
.filter(
([path, result]) => result.exitCode === 0 && isSafeNonSymlinkTreeEntry(result.stdout, path),
)
.map(([path]) => path),
);
const safeLines = parsed.filter(({ path }) => safePaths.has(path)).map(({ line }) => line);
const output = truncateRepositoryInspectionOutput(
redactRepositoryInspectionOutput(safeLines.join("\n")),
);
return {
commitSha: pinned,
fixtureMode: false,
content: output.content,
matchCount: safeLines.length,
truncated:
output.truncated ||
Buffer.byteLength(search.stdout) > 64 * 1024 ||
rawLines.length > safeLines.length,
};
}

export async function readRepositoryFiles(
sandbox: RepositorySandbox,
files: readonly { path: string; startLine: number; endLine?: number }[],
) {
const normalized = files.map((entry) => ({
...entry,
path: assertSafeRepositoryPath(entry.path),
}));
for (const entry of normalized) {
const requestedEndLine = entry.endLine ?? entry.startLine + 399;
if (requestedEndLine < entry.startLine || requestedEndLine - entry.startLine + 1 > 400) {
throw new Error("Repository read range is invalid or too large.");
}
}
const pinned = await readPinnedRepositoryCommit(sandbox);
const results = [];
let remainingBytes = 64 * 1024;
let truncated = false;
for (const entry of normalized) {
const requestedEndLine = entry.endLine ?? entry.startLine + 399;
const tree = await sandbox.run({
command: buildGitTreeEntryCommand(pinned, entry.path),
abortSignal: AbortSignal.timeout(5_000),
});
if (tree.exitCode !== 0 || !isSafeNonSymlinkTreeEntry(tree.stdout, entry.path)) {
throw new Error(`Repository file ${entry.path} is missing or is a symlink.`);
}
const maxBytes = Math.min(32 * 1024, remainingBytes);
const read = await sandbox.run({
command: buildGitReadCommand({
commitSha: pinned,
path: entry.path,
startLine: entry.startLine,
endLine: requestedEndLine,
maxBytes,
}),
abortSignal: AbortSignal.timeout(10_000),
});
if (read.exitCode !== 0) throw new Error(`Repository file ${entry.path} could not be read.`);
const bounded = truncateRepositoryInspectionOutput(
redactRepositoryInspectionOutput(read.stdout),
maxBytes,
);
const newlineCount = (bounded.content.match(/\n/g) ?? []).length;
const returnedLineCount =
bounded.content.length === 0 ? 0 : newlineCount + (bounded.content.endsWith("\n") ? 0 : 1);
const returnedEndLine =
returnedLineCount === 0 ? entry.startLine - 1 : entry.startLine + returnedLineCount - 1;
const fileTruncated = bounded.truncated || Buffer.byteLength(read.stdout) > maxBytes;
remainingBytes -= bounded.byteCount;
truncated ||= fileTruncated;
results.push({
path: entry.path,
startLine: entry.startLine,
requestedEndLine,
returnedEndLine,
content: bounded.content,
truncated: fileTruncated,
});
if (remainingBytes <= 0) {
truncated = true;
break;
}
}
return { commitSha: pinned, fixtureMode: false, files: results, truncated };
}
Loading
Loading