omp packages

@jocax/decision-maker0.3.0

Agent tools that ask a decision model a typed question about one file, without loading the file into the agent's context

jocax Published 7 days ago MIT ~189 downloads/mo

Untested on omp

This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.

Did it work for you?

Result

One report per person. A new report replaces your old one.

Reports 0

No reports yet.

Readme

@jocax/decision-maker

Agent tools that ask a decision model a typed question about a file, the same question about many files at once, or several questions the agent writes itself about one situation — without loading any of it into the agent's context.

What

Three entry points, six tools:

src/index.ts — one file at a time:

  • ask_decision_file_bool — yes or no
  • ask_decision_file_choice — pick one of a set of options
  • ask_decision_file_score — a position on a scale you define

src/index-files.ts — many files at once:

  • ask_decision_files — the same typed question asked of every file a pattern matches, one call per file, in parallel
  • ask_decision_files_pick — one choice over a list of paths, to pick which to open first

src/index-general.ts — one situation, several questions:

  • ask_decision — the agent writes its own questions_json block and state (a note, named file paths, or both) and gets back one answer per question in a single call

A file's text becomes part of the state of a decision call; it is sent to the decision model and never reaches the agent. The agent receives only the JSON answer(s). A consumer's package filter loads any one entry point, or any combination.

Why

Reading a file into context to answer one narrow question ("does this validate tokens?", "which layer is this?") spends context on text the agent will not quote or edit. These tools trade that read for one typed question and a small structured answer.

Install

{
  "packages": [
    {
      "source": "npm:@jocax/decision-maker"
    }
  ]
}

The package declares all three entry points; they are independent and share only their client resolution.

# Try it from a local checkout without installing
pi -e ./src/index.ts
pi -e ./src/index-files.ts
pi -e ./src/index-general.ts

Usage

A tool call and an illustrative result:

// call
{ "name": "ask_decision_file_bool", "params": {
    "path": "src/auth/session.ts",
    "question": "Does `content` validate authentication tokens?",
    "yes": "It verifies a signature, an expiry or an issuer",
    "no": "It does not touch tokens"
} }

// result (also the tool's `details`)
{
  "path": "src/auth/session.ts",
  "answer": true,
  "noul": 0.93,
  "meta": {
    "provider": "typesafe",
    "model": "jev-latest",
    "requestedModel": "jev-latest",
    "elapsedMs": 812,
    "attempts": 1,
    "usage": { "input_tokens": 512, "output_tokens": 40 }
  }
}

The same question as code, with an injected client (see examples/file-questions.ts for more):

import { createDecisionClient, resolveDecisionConfig } from "@jocax/decision-core";
import { askFileBool } from "@jocax/decision-maker/src/ask-file";

const { config } = resolveDecisionConfig({ cwd: process.cwd() });
const client = createDecisionClient(config!);

const result = await askFileBool(
  { client, cwd: process.cwd() },
  "src/auth/session.ts",
  "Does `content` validate authentication tokens?",
  { yes: "It verifies a signature, an expiry or an issuer", no: "It does not touch tokens" },
);
// result.answer, result.noul, result.meta

The same question over many files, with an illustrative result:

// call
{ "name": "ask_decision_files", "params": {
    "patterns": ["src/auth/**/*.ts"],
    "question_type": "bool",
    "question": "Does `content` take part in authentication?",
    "yes": "It issues, verifies or stores credentials, tokens or sessions",
    "no": "It does not touch authentication"
} }

// result (illustrative, also the tool's `details`)
{
  "type": "bool",
  "answers": [
    { "path": "src/auth/login.ts", "type": "bool", "answer": true, "noul": 0.88 },
    { "path": "src/auth/session.ts", "type": "bool", "answer": true, "noul": 0.93 }
  ],
  "skipped": [
    { "path": "src/auth/index.ts", "reason": "empty" }
  ],
  "meta": {
    "provider": "typesafe",
    "model": "jev-latest",
    "requestedModel": "jev-latest",
    "elapsedMs": 1340,
    "calls": 2,
    "attempts": 2,
    "concurrency": 16,
    "maxFiles": 255,
    "usage": { "input_tokens": 1024, "output_tokens": 80 }
  }
}

Picking which of the surviving paths to open first, with an illustrative result:

// call
{ "name": "ask_decision_files_pick", "params": {
    "question": "Which file should I open first to fix token expiry?",
    "candidates": [
      { "path": "src/auth/session.ts", "note": "validates tokens: true" },
      { "path": "src/auth/login.ts", "note": "validates tokens: true" }
    ]
} }

// result (illustrative, also the tool's `details`)
{
  "path": "src/auth/session.ts",
  "confidence": 0.81,
  "probabilities": { "src/auth/session.ts": 0.81, "src/auth/login.ts": 0.15, "none": 0.04 },
  "options": ["src/auth/session.ts", "src/auth/login.ts", "none"],
  "skipped": [],
  "meta": {
    "provider": "typesafe",
    "model": "jev-latest",
    "requestedModel": "jev-latest",
    "elapsedMs": 640,
    "attempts": 1,
    "usage": { "input_tokens": 220, "output_tokens": 30 }
  }
}

The scan as code, with an injected client:

import { createDecisionClient, resolveDecisionConfig } from "@jocax/decision-core";
import { askFiles } from "@jocax/decision-maker/src/ask-files";

const { config } = resolveDecisionConfig({ cwd: process.cwd() });
const client = createDecisionClient(config!);

const result = await askFiles(
  { client, cwd: process.cwd() },
  {
    patterns: ["src/auth/**/*.ts"],
    type: "bool",
    question: "Does `content` take part in authentication?",
    yes: "It issues, verifies or stores credentials, tokens or sessions",
    no: "It does not touch authentication",
  },
);
// result.answers, result.skipped, result.meta

One situation, several questions the agent wrote itself, with an illustrative result:

// call
{ "name": "ask_decision", "params": {
    "paths": ["src/auth/session.ts"],
    "questions_json": "{\"real\":{\"type\":\"noul\",\"instructions\":\"Is files[\\\"src/auth/session.ts\\\"] handling token expiry correctly?\",\"criteria\":{\"true\":\"Checks and rejects an expired token\",\"false\":\"No expiry check, or it is wrong\"}}}"
} }

// result (illustrative, also the tool's `details`)
{
  "answers": {
    "real": { "type": "noul", "noul": 0.91 }
  },
  "stateSummary": {
    "own_fields": [],
    "files": ["src/auth/session.ts"],
    "skipped": [],
    "tokens": 612,
    "budgetTokens": 28601
  },
  "meta": {
    "provider": "typesafe",
    "model": "jev-latest",
    "requestedModel": "jev-latest",
    "elapsedMs": 790,
    "attempts": 1,
    "usage": { "input_tokens": 640, "output_tokens": 38 }
  }
}

The same shape as code, with an injected client (see examples/general-questions.ts for more):

import { createDecisionClient, MOCK_CONFIG } from "@jocax/decision-core";
import { askGeneral } from "@jocax/decision-maker/src/ask-general";

const client = createDecisionClient(MOCK_CONFIG, {
  mockAnswers: {
    real: { type: "noul", noul: 0.82 },
    kind: {
      type: "choice",
      choice: "bug_in_code",
      confidence: 0.7,
      probabilities: { bug_in_code: 0.7, wrong_test: 0.2, environment: 0.05, other: 0.05 },
    },
  },
});

const result = await askGeneral(
  { client, cwd: process.cwd() },
  {
    state: "CI failed once on main; nothing in the touched files changed since the last green run.",
    questionsJson: JSON.stringify({
      real: { type: "noul", instructions: "Is this likely a real failure rather than a flaky one?" },
      kind: {
        type: "choice",
        instructions: "What kind of failure is it?",
        criteria: {
          bug_in_code: "The code under test is wrong",
          wrong_test: "The test asserts the wrong thing",
          environment: "Something outside the code failed",
          other: "None of the above",
        },
      },
    }),
  },
);
// result.answers.real, result.answers.kind, result.stateSummary, result.meta

Tools

Tool Parameters Returns
ask_decision_file_bool path, question, yes?, no? — what counts as each side { path, answer, noul, meta } — answer is noul > 0.5
ask_decision_file_choice path, question, options: { name, description }[] { path, choice, confidence, probabilities, options, meta } — options is the array of option ids actually asked about, including an added other
ask_decision_file_score path, question, levels: string[] (low to high) { path, score, top, nearest, normalized, confidence, legend, meta }
ask_decision_files patterns: string[], question_type: "bool" | "choice" | "score", question, yes?/no? (bool only), options?: { name, description }[] (choice only), levels?: string[] (score only), recursive?: boolean { type, answers, skipped, options?, legend?, meta } — each answer carries path plus its type's fields: bool → { answer, noul }, choice → { choice, confidence, probabilities }, score → { score, top, nearest, normalized, confidence }
ask_decision_files_pick question, candidates: { path, note? }[] { path, confidence, probabilities, options, skipped, meta } — path is the chosen candidate, or null when the model picks none
ask_decision state? — a note, plain text or a JSON object as a string; paths? — file paths, globs or directories, read into state.files["path"], at most AE_PI_DECISION_MAKER_MAX_STATE_FILES (default 20); questions_json — a JSON object keyed by question id, written by the agent { answers, stateSummary, meta } — answers is keyed by the agent's own question ids; stateSummary names what went into the state (own_fields, files, skipped, tokens, budgetTokens) without sending the content back

options for the choice tool is an array of { name, description } objects, not a map — a plain object-array schema is accepted by every provider this repo targets, where a string-keyed map is not. When the agent names none of other, none or none_of_the_above, an other option ("None of the above") is added and appears in the returned options.

Every result carries meta: provider, model (as the provider reports it), requestedModel, elapsedMs, attempts, usage.

Configuration

Variable Required Description
AE_PI_DECISION_MAKER_ENABLED No "false" registers no tool at all. Any other value, including unset, leaves the package on
AE_PI_DECISION_MAKER_TIMEOUT_MS No Total time budget for one decision call, retries included. A positive integer of milliseconds; unset uses @jocax/decision-core's own provider default
AE_PI_DECISION_MAKER_CONCURRENCY No How many decision calls ask_decision_files keeps in flight. A positive integer from 1 to 64; unset uses a provider-aware default
AE_PI_DECISION_MAKER_MAX_FILES No How many files one ask_decision_files or ask_decision_files_pick call may consider. A positive integer; unset uses the provider's own choice-option limit
AE_PI_DECISION_MAKER_MAX_STATE_FILES No How many files one ask_decision call may put in its state. A positive integer; unset uses 20
AE_PI_DECISION_MAKER_PRICE_INPUT No USD per million input tokens, used only for the ask_decision spend ledger. Unset reports no money
AE_PI_DECISION_MAKER_PRICE_OUTPUT No USD per million output tokens, for the ledger. Both prices must be set together — one alone is treated as neither, and the ledger reports no money
AE_PI_DECISION_MAKER_NUDGE No "false" leaves the system prompt alone. Any other value, including unset, adds a short block pointing the agent at ask_decision

Both of the scale defaults come from the provider, not a constant:

Provider Concurrency default File cap default
Ollama 4 26
TypeSafe 16 255

Provider, model, base URL and API key are not settings of this package — they come from @jocax/decision-core's own AE_PI_DECISION_CORE_* variables. See software/packages/decision-core/README.md.

With no decision provider configured, the extension still loads: it warns once at session start and every tool call fails with a message naming AE_PI_DECISION_CORE_PROVIDER.

Package events

With AE_PI_EVENT_CORE_ENABLED=true (a setting of @jocax/event-core), each of ask_decision_file_bool, ask_decision_file_choice and ask_decision_file_score also writes one package event per answer: which decision model read which file, how many tokens it read, the answer, and that the file stayed out of the agent's context. The tool result itself is unchanged. The scale and general tools write none.

Scale

  • One decision call per matched file, in parallel, bounded by AE_PI_DECISION_MAKER_CONCURRENCY.
  • A pattern is a path, a glob or a directory; a directory means the files directly in it unless recursive: true.
  • A glob follows node's own rules: * does not match a leading dot, so a dotfile must be named explicitly.
  • Dropped before any call, with a reason in skipped: paths outside the project; node_modules, .git, .sessions, dist, build, coverage and .pi; a missing path; a zero-byte file; a known binary or lock extension.
  • Size and binary-content checks stay per file in the file reader, not in the scan — the content budget is per file and per question, so a list-level size rule would duplicate or contradict it.
  • A partial failure lands in skipped with the reason; a total failure (every attempted file failed) throws instead of returning an empty result.
  • ask_decision_files_pick reserves one option for none, so a pick's option count never exceeds the provider's limit.

Situations

ask_decision judges one situation in one call: the agent's own note, the files it names, or both, against as many questions as it wants answered together — they share the one state. State always lands as an object, so a question can always name a field: an own note parses as a JSON object or array when it looks like one, keeping the agent's own field names ({ ... } or { items: [...] }); plain text becomes { text }; named files land in state.files["path"], never at the top level.

The token budget for one call is derived from the client's limits and the questions actually being asked (stateTokenBudget, src/budget.ts) — it is not a fixed number, and it shrinks as the question block grows. Approximate ranges for one short noul question:

Limits Approx. budget Binding cap
JEV_LIMITS (TypeSafe) ~28.6k tokens maxStatePlusQuestionTokens 32,000
NIMBLE_LIMITS ~7.2k tokens maxRequestTokens 8,192
TEV_LIMITS ~1.6k tokens maxRequestTokens 2,000
OLLAMA_LIMITS (plain) ~16.8k tokens maxBodyBytes 65,536

A longer questions_json block leaves less room for state on every provider. The agent's own state note is further capped at a quarter of that budget (OWN_STATE_SHARE) — a nudge against pasting content code could fetch instead, not a protocol limit. Files are capped at AE_PI_DECISION_MAKER_MAX_STATE_FILES (default 20) before any file is even read; past that, use ask_decision_files to ask the same question of many files one call per file.

When the situation does not fit, the call is refused rather than truncated — a truncated state would still get a confident answer about content the model never saw. The refusal names every part with its size and proposes a split that fits, for example (illustrative, TEV_LIMITS, two 4,000-byte log files):

ask_decision: the state is 2.3k tokens, the limit for this call is 1.6k tokens. Parts: big-a.log 1.1k tokens,
big-b.log 1.1k tokens. Split into 2 calls with the same questions_json: call 1: paths [big-a.log] (1.1k tokens);
call 2: paths [big-b.log] (1.1k tokens).

Commands and output

ask_decision runs no commands and has no command parameter. To judge a command's output, the agent runs it itself with the bash tool, redirects the output to a file, and passes that path:

bash:  mkdir -p .decisions && npm test > .decisions/test.log 2>&1
ask_decision:
  paths: [".decisions/test.log"]
  questions_json: {"real":{"type":"noul","instructions":"Is files[\".decisions/test.log\"] a real failure rather than a flaky one?"}}

Because the agent runs the command itself with the bash tool, every installed guard (for example security-guard) sees an ordinary bash call and applies as usual — ask_decision adds no second, unchecked way to run a command. .decisions/ is the documented convention for the redirect target: no SKIP_DIRS entry matches it, so the file is not silently dropped before it can be read, and the project's .gitignore ignores the directory so it does not add noise to git status.

What reaches the model is wider than with read: ask_decision sends the text of any in-project file the agent names. security-guard's path tiers govern the read and bash tools, not this one, so they do not restrict what ask_decision sends. A project that needs path restrictions on decision calls should not load src/index-general.ts.

Ledger

Each src/index-general.ts instance keeps a running ledger of its own ask_decision calls only — calls, questions, input/output tokens, elapsed time and, when both prices are configured, money. It does not cover the other two entry points, and it does not survive a restart.

The summary line is reported on agent_end, and only when the count changed since the last report, so a run that made no ask_decision call stays silent; /decisions prints the same line on demand at any time:

1 decision call, 2 questions, 1.8k tokens in, 40 out, 1.2s, no price configured
2 decision calls, 3 questions, 3.6k tokens in, 80 out, 1.5s, $0.000176
No decision calls this session.

Money is absent, never a silent zero, until both AE_PI_DECISION_MAKER_PRICE_INPUT and _PRICE_OUTPUT are set.

Budgets

How much of a file's content fits in one call is derived per call, not fixed, from the client's limits, its model and the question actually being asked (src/budget.ts). Observed budgets for the path src/auth/session.ts and one question:

Limits noul choice (4 options) score (4 levels) Binding cap
NIMBLE_LIMITS 25,149 25,137 25,143 maxRequestTokens 8,192
JEV_LIMITS 100,144 100,132 100,138 maxStatePlusQuestionTokens 32,000
TEV_LIMITS 5,644 5,632 5,638 maxRequestTokens 2,000
OLLAMA_LIMITS 58,734 58,720 58,725 maxBodyBytes 65,536

These numbers are estimates for that one example question and path; a different question or path recomputes them. The reader compares the file's byte size against this character budget, which is conservative for multibyte text — a UTF-8 byte length is never smaller than the JavaScript string length. A file that passes the reader can still be rejected inside client.decide with a limit_exceeded error: the client's own limit check is the final authority, not this estimate.

As with @jocax/decision-core: a noul near 0.5 means the model is undecided, not that the answer is no; confidence says how concentrated the probabilities are, not how likely the answer is to be right.

Errors

The file reader refuses with one of six reasons, each thrown as a FileStateError:

Reason Meaning
not_found the path does not stat
not_a_file the path is a directory or similar
too_large the file's byte size is over the budget for this question
binary a NUL byte in the first 8192 bytes
empty the file is empty or whitespace-only
unreadable stat succeeded but the read itself failed

A DecisionError from @jocax/decision-core (for example limit_exceeded, timeout) can also surface, from the call to the provider itself. Every failure is thrown, never returned as a result object — a returned isError is ignored by pi, so only a throw out of execute marks the tool result as failed.

Testing with the mock

import { createDecisionClient, MOCK_CONFIG } from "@jocax/decision-core";
import { askFileBool } from "@jocax/decision-maker/src/ask-file";

const client = createDecisionClient(MOCK_CONFIG, {
  mockAnswers: { answer: { type: "noul", noul: 0.9 } },
});

const result = await askFileBool(
  { client, cwd: process.cwd() },
  "src/auth/session.ts",
  "Does `content` validate authentication tokens?",
);
// result.answer === true, result.noul === 0.9

mockAnswers forces the answer for the question id answer (the single id every ask_decision_file_* and scale call uses; ask_decision is keyed by whatever ids the agent's own questions_json chooses), so a test can assert a threshold or a mapping without a network call.

Examples

File Functions
examples/file-questions.ts validatesTokens, fileLayer, refactorRisk
examples/files-questions.ts filesTouchingAuth, riskiestFiles, firstFileToOpen
examples/general-questions.ts classifyFailure, judgeWithContext

Each function in file-questions.ts takes (client, path, cwd) and maps its askFile* result to the field the calling code would branch on. Each function in files-questions.ts takes a client plus patterns or candidates, and maps an askFiles/pickFirstFile result the same way. Each function in general-questions.ts takes a client plus a note and/or paths, and maps an askGeneral result the same way — classifyFailure judges one log file with two questions in one call, judgeWithContext combines a note and files.

Live tests

Skipped unless AE_PI_DECISION_MAKER_LIVE names the provider to test:

AE_PI_DECISION_MAKER_LIVE=ollama npx vitest run software/packages/decision-maker/tests/live.test.ts
AE_PI_DECISION_MAKER_LIVE=typesafe npx vitest run software/packages/decision-maker/tests/live.test.ts

The TypeSafe suite needs AE_PI_DECISION_CORE_API_KEY or TYPESAFE_API_KEY.

Run the demo

just demo-web decision-maker   # browser
just demo decision-maker       # herdr

The demo loads src/index-files.ts, the scale entry point, so the session has ask_decision_files and ask_decision_files_pick. The single-file tools and the general tool are other entry points.

The sandbox is a copy of auth-service: 30 files under src/. The first two scenarios use the offline mock mock/mock-decision-1, written into the copy as .pi/decision-core.json; the status line reads 🗂 File scans: mock/mock-decision-1. The third asks a local Ollama model instead.

Scenario "Which files check authentication"

Step Do this Watch
1 Send the first prompt The agent lists files, then calls ask_decision_files over src/**/*.js. The result is one row per path, e.g. {"path":"src/auth/verify-token.js","type":"bool","answer":true,"noul":0.7251} — 30 rows, skipped: []. No read call appears anywhere: the agent gets back a few kilobytes of answers for 30 files whose text only the decision model read
2 Look at the answer set The mock scores token overlap, not judgment — a live run scored 20 yes and 10 no, with six files sitting at exactly 0.5 (undecided, reported as false). Several non-auth files come back true; do not treat the answers as correct
3 Watch the pick ask_decision_files_pick returns one of the offered paths with a confidence and a probabilities map. Live runs land near a tie among several candidates — treat the winner as arbitrary, not a judgment
4 Send the second prompt The same files, now scored: each row carries score, nearest and normalized. The browser's timeline clips a 30-row result at 4,000 characters (… (truncated)); the agent still receives the full result

Scenario "Pick one file out of a list"

Step Do this Watch
1 Send the prompt ls runs twice, then one ask_decision_files_pick call
2 Read the result The chosen path is always one of the candidates offered

Scenario "Ask a real model" — .pi/decision-core.json names ollama, so the files go to nimble on a local Ollama, and the status line reads 🗂 File scans: ollama/nimble.

Step Do this Watch
1 Send the prompt ls twice, one ask_decision_files over src/auth and src/middleware, one ask_decision_files_pick. Still no read call
2 Look at the answer set A live run: 11 files, 3 yes (password.js, verify-token.js, require-auth.js, each above 0.99) and 8 no, most below 0.03. session-store.js at 0.26 was the one uncertain file. The scan took about 11 s
3 Watch the pick src/auth/verify-token.js at 0.97 confidence — the file the mock never singled out

Compared with the mock, a real model gives decisive answers rather than word-overlap ties, and its limits are tighter. Nimble takes at most 26 options per question, so one scan covers at most 26 files (the mock allows 255), and four calls run at once instead of sixteen. That is why this scenario scans two directories rather than all of src/. It needs Ollama on http://localhost:11434 serving nimble.

TypeSafe is not offered in a session: the demo kit passes no decision key to the agent's environment. The decision-core demo asks TypeSafe directly.

None of the scenarios grants the read tool, in herdr or the browser. That is the point: if the agent cannot read the files, an answer about their content can only have come from the decision model.

No threshold in this package is calibrated against a real model; the live answers above are one run, not a benchmark.

Needs herdr 0.9.3 or newer and one reachable model provider — see demo-kit.

Development

npx vitest run software/packages/decision-maker
npx tsc --noEmit -p software/packages/decision-maker/tsconfig.json

The typecheck covers tests/ and examples/ as well as src/.

Attribution

src/file-state.ts, src/ask-file.ts, src/expand.ts, src/ask-files.ts, src/pick-file.ts, src/assemble.ts, src/ask-general.ts, src/ledger.ts, src/tools-general.ts, examples/file-questions.ts, examples/files-questions.ts and examples/general-questions.ts are adapted from a MIT-licensed project; see THIRD-PARTY-NOTICES.md.