@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
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.
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 noask_decision_file_choice— pick one of a set of optionsask_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 parallelask_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 ownquestions_jsonblock 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,coverageand.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
skippedwith the reason; a total failure (every attempted file failed) throws instead of returning an empty result. ask_decision_files_pickreserves one option fornone, 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.