Troubleshooting
Real symptoms and what usually causes them. If yours is not here, turning on report: true and opening report/index.html almost always shows where the run left the rails. That page lists every run; each one also writes its own report/<runId>/index.html and report/<runId>/report.json.
The agent answers in text instead of using the tool
Symptom: you asked for something that needs the tool, and it described what it would do.
Most common cause: the description does not say when to use it. It is prompt, and the model reads it literally.
description: "File operations." // vague
description: "Reads a project file. Use before answering about code."Second cause: the prompt does not tell it to act. Say so explicitly in the markdown: "Before answering, read the relevant files."
Third: the model is too small for the task. Open the report and look at toolCallSource on the chat nodes. If it says rescued, the model is emitting the call as text and the framework is recovering it — that works, but it means you are at the edge of what the model can do.
The loop ends on the first turn
Symptom: the agent calls a tool and the run finishes without it interpreting the result.
Almost always untilAnswered with a model that returned an empty response — which counts as "answered". Be stricter:
import { turnOf } from "@thenajs/core";
until: (ctx) => {
const t = turnOf(ctx);
return !!t && !t.calledTool && !!t.response?.trim();
};The other case is the model writing the call in prose, with no JSON at all — the rescue cannot recover that, because there is no call there. See the item above.
The loop never ends
Symptom: it hits maxIterations every time.
Confirm that is what is happening, rather than assuming:
loop({
steps: [MyAgent],
until: myCondition,
maxIterations: 8,
onExhausted: (ctx, n) => console.warn(`hit the ceiling after ${n}`),
});If it fires, until never became true. The usual reasons: the field until reads is never written (a typo, or the hook that writes it does not run), or the condition is inverted — remember that true means stop.
The second agent answers with nothing
Symptom: steps: [PlannerAgent, ExecutorAgent], and the executor returns nothing or finishes immediately.
The first agent's output entered the history as the assistant's turn. The second one reads the same history and concludes it already answered.
If the first one's output is context and not speech, promote it:
export class PlannerAgent {
afterResponse(plan: string, ctx: AgentContext) {
ctx.state.set("history", ctx.state.history.slice(0, -1));
ctx.state.append("memory", `Plan to follow:\n${plan}`);
}
}recall comes back empty
Check in this order:
- Did you write to the same dataset you are searching?
rememberwith nodatasetwrites to"default";recallwith{ dataset: "persistent" }will not find it. To search all of them:{ dataset: null }. - Is
scoreThresholdtoo high? Start without it and look at the real scores. - Does the collection exist? It is created on the first write, not on a read.
Dimension error in the vector store
This store was prepared with 768 dimensions, but received embeddings of 1536.Two agents with different embedding models pointing at the same store. One collection holds one size — each model needs its own:
stores: [QdrantNomic, QdrantOpenAI];The 1536 agent takes the second constructor parameter. Injection from ThenaConfig.stores is positional, so reordering that array silently changes which store each agent gets. Use @memory(QdrantOpenAI) to name it instead of depending on the order.
The run dies with fetch failed
If it took around 300 seconds before failing, that was the runtime's limit — the request hung and the retry never fired, because nothing rejected.
Turn on the per-attempt timeout:
super({ host, model, retry: { maxAttempts: 3, timeoutMs: 120_000 } });Pick a value above what your model takes in the worst case: too short a ceiling aborts legitimate work.
A tool error took down the whole run
That is not the default — a tool error normally becomes an observation the model reads and recovers from. A run that actually dies means one of:
- your
executethrewFatalToolError, which is designed to end the run; - the error escaped somewhere that is not a tool — a hook, a middleware or the provider.
To pick the message the model reads instead of throwing, return an error result:
return { content: `No file at "${path}". Check the path.`, isError: true };The prompt was not found
[@Agent] Prompt markdown not found: /path/…The relative path resolves from the agent's file. Moving the .ts without moving the .md breaks it. In a compiled build, confirm the .md files are copied into dist/ — the CLI's project template already does this.
A tool is not recognised
[thena] The class "MyTool" is not decorated with @Tool().
[thena] The class "MyTool" does not implement execute(input).The first is a missing decorator; the second is the method named wrong (run instead of execute, for example).
Nothing compiles, and the errors mention decorators
experimentalDecorators is off. ThenaJS uses TypeScript's legacy decorators because it needs parameter decorators, which the Stage 3 proposal does not have.
{ "compilerOptions": { "experimentalDecorators": true } }onEvent / textStream never fire
You will have seen this warning:
[thena] onEvent() will receive nothing: this run is not being observed.A run without an observer does not build the execution tree, does not emit events and does not ask the provider to stream — it is the zero-cost path, and it is about twice as fast per run. Turn observation on explicitly:
const exec = app.run({ prompt, observe: true });Or turn on report, log, or a plugin with onEvent, any of which enables it implicitly.
Different results on every run
Expected without sampling. Pin it while you iterate:
sampling: { temperature: 0, seed: 42 };Still stuck
Open an issue on GitHub with the run's report/<runId>/report.json — it has the complete tree and is what helps most. See Support.
