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: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm

- name: Install
Expand Down
16 changes: 16 additions & 0 deletions openspec/changes/P4-execution-signals/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# P4 — Execution Signals Extractor

## Why
To implement intelligent, adaptive routing, the router must understand the status and complexity of the developer session. Modlane does this by mining execution signals (consecutive errors, files touched, repeated edits, context size) from incoming request history.

## What changes
- `src/providers/types.ts`: added `dialect` and `rawBody` properties to neutral `ChatRequest`.
- `src/server.ts`: populated `dialect` and `rawBody` on request parsing.
- `src/signals.ts` [NEW]: implements the `extractSignals` parser, extracting tool usage, results, test failures, files modified, consecutive failures, and repeated edits from the raw dialect messages.
- `src/signals.test.ts` [NEW]: unit tests covering signal extraction for both OpenAI and Anthropic formats.

## Impact
Allows downstream classification (P5) and routing (P6) systems to inspect the current state of a development session and make routing decisions based on actual execution metrics rather than stubs.

## Status
Done — implemented, fully tested, and compiling.
8 changes: 8 additions & 0 deletions openspec/changes/P4-execution-signals/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# P4 — Tasks

- [x] Extend `ChatRequest` in `src/providers/types.ts` to retain `dialect` and `rawBody`.
- [x] Update request parser in `src/server.ts` to populate `dialect` and `rawBody`.
- [x] Implement signal extraction and heuristics in `src/signals.ts`.
- [x] Cover extraction rules for OpenAI format with unit tests in `src/signals.test.ts`.
- [x] Cover extraction rules for Anthropic format with unit tests in `src/signals.test.ts`.
- [x] Verify build and vitest run successfully.
4 changes: 4 additions & 0 deletions src/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ export interface ChatRequest {
stream?: boolean;
/** Provider-native tool definitions, passed through unchanged. */
tools?: unknown;
/** The inbound dialect and original raw body for execution signal mining. */
dialect?: "openai" | "anthropic";
rawBody?: unknown;
}


export interface Usage {
promptTokens: number | null;
completionTokens: number | null;
Expand Down
2 changes: 2 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ async function handleChat(config: Config, req: IncomingMessage, res: ServerRespo
}

const chatReq = dialect === "openai" ? parseOpenAIRequest(body) : parseAnthropicRequest(body);
chatReq.dialect = dialect;
chatReq.rawBody = body;
const requestedModel = typeof body.model === "string" ? body.model : "";

if (chatReq.stream) {
Expand Down
223 changes: 223 additions & 0 deletions src/signals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { expect, test } from "vitest";
import type { ChatRequest } from "./providers/types.js";
import { extractSignals } from "./signals.js";

test("extracts basic metrics from ChatRequest", () => {
const req: ChatRequest = {
model: "test-model",
messages: [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
],
};

const signals = extractSignals(req);
expect(signals.totalMessages).toBe(2);
expect(signals.totalCharacters).toBe(14); // 5 + 9
expect(signals.toolCalls).toHaveLength(0);
expect(signals.toolResults).toHaveLength(0);
expect(signals.filesTouched).toHaveLength(0);
expect(signals.repeatedEdits).toBe(false);
expect(signals.hasTestFailures).toBe(false);
expect(signals.consecutiveFailures).toBe(0);
});

test("extracts OpenAI tool calls and results", () => {
const rawBody = {
messages: [
{ role: "user", content: "fix tests" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: {
name: "edit_file",
arguments: JSON.stringify({ path: "src/server.ts", content: "new code" }),
},
},
],
},
{
role: "tool",
tool_call_id: "call_1",
content: "Success",
},
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_2",
type: "function",
function: {
name: "run_command",
arguments: JSON.stringify({ command: "pnpm test" }),
},
},
],
},
{
role: "tool",
tool_call_id: "call_2",
content: "FAIL: test_server failed with code 1",
},
],
};

const req: ChatRequest = {
model: "test-model",
messages: [
{ role: "user", content: "fix tests" },
{ role: "assistant", content: "" },
{ role: "tool", content: "Success" },
{ role: "assistant", content: "" },
{ role: "tool", content: "FAIL: test_server failed with code 1" },
],
dialect: "openai",
rawBody,
};

const signals = extractSignals(req);
expect(signals.toolCalls).toHaveLength(2);
expect(signals.toolCalls[0].name).toBe("edit_file");
expect(signals.toolResults).toHaveLength(2);
expect(signals.toolResults[1].isError).toBe(true);
expect(signals.filesTouched).toEqual(["src/server.ts"]);
expect(signals.hasTestFailures).toBe(true);
expect(signals.consecutiveFailures).toBe(1); // last one failed, previous succeeded
});

test("extracts Anthropic tool calls and results", () => {
const rawBody = {
messages: [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "call_a1",
name: "str_replace_editor",
input: { path: "src/server.ts", replacement: "foo" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_a1",
content: "Success replacing text",
is_error: false,
},
],
},
{
role: "assistant",
content: [
{
type: "tool_use",
id: "call_a2",
name: "str_replace_editor",
input: { path: "src/server.ts", replacement: "bar" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_a2",
content: "Some compilation warning",
is_error: true,
},
],
},
],
};

const req: ChatRequest = {
model: "test-model",
messages: [
{ role: "assistant", content: "" },
{ role: "user", content: "Success replacing text" },
{ role: "assistant", content: "" },
{ role: "user", content: "Some compilation warning" },
],
dialect: "anthropic",
rawBody,
};

const signals = extractSignals(req);
expect(signals.toolCalls).toHaveLength(2);
expect(signals.toolResults).toHaveLength(2);
expect(signals.toolResults[1].isError).toBe(true);
expect(signals.filesTouched).toEqual(["src/server.ts"]);
expect(signals.repeatedEdits).toBe(true); // src/server.ts was edited twice in a row
expect(signals.consecutiveFailures).toBe(1);
});

test("correctly calculates consecutive failures", () => {
const rawBody = {
messages: [
{
role: "assistant",
content: [
{ type: "tool_use", id: "1", name: "tool1", input: {} },
{ type: "tool_use", id: "2", name: "tool2", input: {} },
{ type: "tool_use", id: "3", name: "tool3", input: {} },
],
},
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "1", content: "err1", is_error: true },
{ type: "tool_result", tool_use_id: "2", content: "err2", is_error: true },
{ type: "tool_result", tool_use_id: "3", content: "err3", is_error: true },
],
},
],
};

const req: ChatRequest = {
model: "test-model",
messages: [
{ role: "assistant", content: "" },
{ role: "user", content: "" },
],
dialect: "anthropic",
rawBody,
};

const signals = extractSignals(req);
expect(signals.consecutiveFailures).toBe(3);
});

test("does not flag benign words containing 'fail' as failures", () => {
const rawBody = {
messages: [
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "1", content: "failover completed; failsafe engaged" },
],
},
],
};

const req: ChatRequest = {
model: "test-model",
messages: [{ role: "user", content: "" }],
dialect: "anthropic",
rawBody,
};

const signals = extractSignals(req);
expect(signals.toolResults[0].isError).toBe(false);
expect(signals.hasTestFailures).toBe(false);
expect(signals.consecutiveFailures).toBe(0);
});
Loading
Loading