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
19 changes: 18 additions & 1 deletion docs/GRAZE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Agents call named combos like `gary-scout`, `gary-adversarial`. The combo defini

### 4. Header contract

Defined here; parsed in a subsequent workstream. GaryOS injects these headers on every Claude Code worker invocation:
Implemented in PR #3. GaryOS injects these headers on every Claude Code worker invocation:

| Header | Values | Purpose |
|---|---|---|
Expand All @@ -63,6 +63,23 @@ Defined here; parsed in a subsequent workstream. GaryOS injects these headers on

Headers are observability and policy inputs only. They never override combo-resolved model selection.

**Implementation:** `src/lib/gary/context.ts` — `parseGaryContext(request)` parses all four headers, normalises enum values to lowercase, treats unknown enum values as `null` (warn + absent), appends to an in-memory 20-entry audit ring buffer, and best-effort-logs to SQLite via `logAuditEvent`.

**Coverage surface** — `parseGaryContext` is called at the entry point of every `/v1/*` POST handler that handles agent traffic:

| Route | Handler |
|---|---|
| `/v1/chat/completions` | `src/sse/handlers/chat.ts` → `handleChat()` |
| `/v1/embeddings` | `src/app/api/v1/embeddings/route.ts` → `POST()` |
| `/v1/search` | `src/app/api/v1/search/route.ts` → `POST()` |
| `/v1/rerank` | `src/app/api/v1/rerank/route.ts` → `POST()` |

Not covered (intentionally): `GET /v1/embeddings` (list), `GET /v1/search` (list), `GET /v1/models` (read-only), `/v1/registered-keys` (admin), `/v1/batches` (async submit), `ws://` (WebSocket handshake).

**Health endpoint:** `GET /monitoring/health` returns the last 20 gary-context entries under `garyContextHistory`.

**Architecture note:** No global Next.js middleware. Next.js middleware runs on Edge Runtime, which is incompatible with the in-process SQLite audit log and the Node.js in-memory ring buffer. Per-handler injection is the only viable pattern given this constraint.

### 5. Provider metadata fields

Added to the provider config schema. Schema invariant: if `local=true` OR `e2ee=true`, then `trains_on_data` must be `false` (neither architecture permits training on the data).
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/v1/embeddings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { v1EmbeddingsSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";

import { getAllCustomModels, getProviderNodes } from "@/lib/localDb";
import { parseGaryContext } from "@/lib/gary/context";

/**
* Handle CORS preflight
Expand Down Expand Up @@ -216,6 +217,7 @@ export async function handleValidatedEmbeddingRequestBody(body: ValidatedEmbeddi
}

export async function POST(request) {
parseGaryContext(request);
let rawBody;
try {
rawBody = await request.json();
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/v1/rerank/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { v1RerankSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getProviderNodes } from "@/lib/localDb";
import { parseGaryContext } from "@/lib/gary/context";

/**
* Handle CORS preflight
Expand Down Expand Up @@ -45,6 +46,7 @@ function buildDynamicRerankProvider(node: any) {
* and local provider_nodes (oMLX, vLLM, etc.) via dynamic routing.
*/
export async function POST(request) {
parseGaryContext(request);
let rawBody;
try {
rawBody = await request.json();
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/v1/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
getOrCoalesce,
SEARCH_CACHE_DEFAULT_TTL_MS,
} from "@omniroute/open-sse/services/searchCache.ts";
import { parseGaryContext } from "@/lib/gary/context";

const CORS_HEADERS = {
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
Expand Down Expand Up @@ -88,6 +89,7 @@ function buildDomainFilter(filters?: {
* POST /v1/search — execute a web search
*/
export async function POST(request: Request) {
parseGaryContext(request);
let rawBody: unknown;
try {
rawBody = await request.json();
Expand Down
142 changes: 142 additions & 0 deletions tests/unit/gary-coverage-extension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "graze-gary-coverage-"));
process.env.DATA_DIR = TEST_DATA_DIR;

// Dynamic imports so DATA_DIR is set before any module initialises SQLite
const { POST: embeddingsPost } = await import("../../src/app/api/v1/embeddings/route.ts");
const { POST: searchPost } = await import("../../src/app/api/v1/search/route.ts");
const { POST: rerankPost } = await import("../../src/app/api/v1/rerank/route.ts");
const { getRecentGaryContexts } = await import("../../src/lib/gary/context.ts");

test.after(() => {
rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});

function makeRequest(url: string, garyHeaders: Record<string, string>) {
return new Request(url, {
method: "POST",
headers: { "Content-Type": "application/json", ...garyHeaders },
body: "not-valid-json",
});
}

test("embeddings POST captures gary context in ring buffer", async () => {
const req = makeRequest("http://localhost/v1/embeddings", {
"x-gary-action-id": "embed-action-001",
"x-gary-stage": "draft",
"x-gary-playbook": "code-pr",
"x-gary-sensitivity": "tier-2",
});
await embeddingsPost(req);

const entry = getRecentGaryContexts()[0];
assert.equal(entry.actionId, "embed-action-001");
assert.equal(entry.stage, "draft");
assert.equal(entry.playbook, "code-pr");
assert.equal(entry.sensitivity, "tier-2");
});

test("embeddings POST captures null gary fields when no headers sent", async () => {
const req = makeRequest("http://localhost/v1/embeddings", {});
await embeddingsPost(req);

const entry = getRecentGaryContexts()[0];
assert.equal(entry.actionId, null);
assert.equal(entry.stage, null);
assert.equal(entry.playbook, null);
assert.equal(entry.sensitivity, null);
});

test("search POST captures gary context in ring buffer", async () => {
const req = makeRequest("http://localhost/v1/search", {
"x-gary-action-id": "search-action-002",
"x-gary-stage": "scout",
"x-gary-playbook": "research",
"x-gary-sensitivity": "tier-1",
});
await searchPost(req);

const entry = getRecentGaryContexts()[0];
assert.equal(entry.actionId, "search-action-002");
assert.equal(entry.stage, "scout");
assert.equal(entry.playbook, "research");
assert.equal(entry.sensitivity, "tier-1");
});

test("search POST captures null gary fields when no headers sent", async () => {
const req = makeRequest("http://localhost/v1/search", {});
await searchPost(req);

const entry = getRecentGaryContexts()[0];
assert.equal(entry.actionId, null);
assert.equal(entry.stage, null);
assert.equal(entry.playbook, null);
assert.equal(entry.sensitivity, null);
});

test("rerank POST captures gary context in ring buffer", async () => {
const req = makeRequest("http://localhost/v1/rerank", {
"x-gary-action-id": "rerank-action-003",
"x-gary-stage": "validate",
"x-gary-playbook": "decision",
"x-gary-sensitivity": "tier-3",
});
await rerankPost(req);

const entry = getRecentGaryContexts()[0];
assert.equal(entry.actionId, "rerank-action-003");
assert.equal(entry.stage, "validate");
assert.equal(entry.playbook, "decision");
assert.equal(entry.sensitivity, "tier-3");
});

test("rerank POST captures null gary fields when no headers sent", async () => {
const req = makeRequest("http://localhost/v1/rerank", {});
await rerankPost(req);

const entry = getRecentGaryContexts()[0];
assert.equal(entry.actionId, null);
assert.equal(entry.stage, null);
assert.equal(entry.playbook, null);
assert.equal(entry.sensitivity, null);
});

test("gary context ring buffer entries from non-chat handlers have same shape as chat entries", async () => {
const req = makeRequest("http://localhost/v1/embeddings", {
"x-gary-action-id": "shape-check-004",
"x-gary-stage": "triage",
});
await embeddingsPost(req);

const entry = getRecentGaryContexts()[0];
assert.ok("actionId" in entry, "missing actionId");
assert.ok("stage" in entry, "missing stage");
assert.ok("playbook" in entry, "missing playbook");
assert.ok("sensitivity" in entry, "missing sensitivity");
assert.ok("timestamp" in entry, "missing timestamp");
assert.ok("requestId" in entry, "missing requestId");
assert.equal(typeof entry.timestamp, "string");
assert.ok(!Number.isNaN(Date.parse(entry.timestamp)), "timestamp is not ISO 8601");
});

test("all three handlers register entries that accumulate in the shared ring buffer", async () => {
const before = getRecentGaryContexts().length;

await embeddingsPost(makeRequest("http://localhost/v1/embeddings", { "x-gary-stage": "plan" }));
await searchPost(makeRequest("http://localhost/v1/search", { "x-gary-stage": "review-plan" }));
await rerankPost(makeRequest("http://localhost/v1/rerank", { "x-gary-stage": "submit" }));

const entries = getRecentGaryContexts();
const added = Math.min(entries.length, entries.length - before + 3);
assert.ok(entries.length >= Math.min(before + 3, 20), "expected at least 3 new entries (or ring at cap)");

// Most-recent three entries are in newest-first order
assert.equal(entries[0].stage, "submit");
assert.equal(entries[1].stage, "review-plan");
assert.equal(entries[2].stage, "plan");
});
Loading