mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
The `hindsight_reflect` MCP tool aborted every call at a hardcoded 120s, no matter what `reflectTimeoutMs` was set to: the handler passed no `timeoutMs`, so `HindsightClient.reflect()` fell back to its own 120s default. On a populated bank, `budget: "high"` synthesis routinely runs longer than that — the identical direct API call succeeded — so the tool was unusable and the config field was dead. Both paths that build the tools dropped the setting, not just the one filed: `selectTools()` (MCP server) and `RuntimeCore.toolSpecs()` (the persistent plugin harnesses — opencode, Kilo, Cline, dsh, Prime Agent). The tool's window is now its own knob, `reflectToolTimeoutMs`, defaulting to 330s — above the server's own reflect wall timeout (300s), so the server decides when to give up rather than an arbitrary client deadline. It inherits an explicitly raised `reflectTimeoutMs` (the field users already reach for), but a short one never lowers it: that value bounds an automatic hook which must fit the host's 25s window, not a call the agent is waiting on. `reflectBudget` makes the hardcoded `budget: "high"` configurable too, for large banks where high-budget synthesis exceeds the server's wall timeout. To stop this recurring, `reflect()`'s `timeoutMs` is now required — the right deadline differs by an order of magnitude between the hook and the tool, so there is no sensible default to fall back to silently.
This commit is contained in:
@@ -361,7 +361,9 @@ hook by Codex...), so one shared config serves several agents side by side:
|
||||
| `retainMetadata` | — | extra metadata on every document written by the integration, e.g. `{"repo": "{gitProject}"}` |
|
||||
| `observationScopes` | `"shared"` | how consolidation groups observations: `"shared"` (default) = ONE global scope per bank, so every agent on a repo builds one set of beliefs; also `"combined"` (the server default), `"per_tag"`, `"all_combinations"`, `[["t"]]` |
|
||||
| `disabled` | `false` | hard off-switch (inert plugin/hook — a no-memory baseline) |
|
||||
| `reflectTimeoutMs` | `120000` | session-reflect timeout (hook harnesses additionally cap it at 25s to fit the host's hook window); on timeout the session runs without reflect (recorded) |
|
||||
| `reflectTimeoutMs` | `120000` | **automatic** session-reflect timeout (hook harnesses additionally cap it at 25s to fit the host's hook window); on timeout the session runs without reflect (recorded) |
|
||||
| `reflectToolTimeoutMs` | `330000` | timeout for the agent-invoked `hindsight_reflect` tool — a call the agent waits on, whose high-budget synthesis on a populated bank runs for minutes. Defaults above the server's own reflect wall timeout (`HINDSIGHT_API_REFLECT_WALL_TIMEOUT`, 300s) so the server decides when to give up. Unset, it inherits an explicitly raised `reflectTimeoutMs`, but a short one never lowers it |
|
||||
| `reflectBudget` | `"high"` | reflect budget for the `hindsight_reflect` tool: `"low"`, `"mid"` or `"high"`. Drop it on a large bank where high-budget synthesis exceeds the server's wall timeout. The automatic session-start reflect always uses `"low"` to fit its hook window and is unaffected |
|
||||
| `pageRefreshEveryTurns` | `10` | refetch the knowledge pages and re-inject the page roster + tool guide every N user turns |
|
||||
| `pageTriggerType` | `"auto-refresh"` | when NEW knowledge pages refresh, i.e. what keeping them current costs — `"auto-refresh"` after every consolidation that produced new material, `"cron"` on `pageTriggerCron` only, `"manual"` never on their own. Auto-refresh is the most current and the most expensive: one synthesis per page per consolidation. Maps to the page's `trigger.refresh_after_consolidation` in the Hindsight API (`true` for auto-refresh, `false` for manual) |
|
||||
| `pageTriggerCron` | — | schedule for `pageTriggerType: "cron"` — UTC, standard 5-field cron, e.g. `"0 3 * * *"`. Sets the page's `trigger.refresh_cron`, which the API treats as mutually exclusive with `refresh_after_consolidation`; a scheduled refresh is skipped when nothing changed |
|
||||
|
||||
@@ -354,7 +354,9 @@ hook by Codex...), so one shared config serves several agents side by side:
|
||||
| `retainMetadata` | — | extra metadata on every document written by the integration, e.g. `{"repo": "{gitProject}"}` |
|
||||
| `observationScopes` | `"shared"` | how consolidation groups observations: `"shared"` (default) = ONE global scope per bank, so every agent on a repo builds one set of beliefs; also `"combined"` (the server default), `"per_tag"`, `"all_combinations"`, `[["t"]]` |
|
||||
| `disabled` | `false` | hard off-switch (inert plugin/hook — a no-memory baseline) |
|
||||
| `reflectTimeoutMs` | `120000` | session-reflect timeout (hook harnesses additionally cap it at 25s to fit the host's hook window); on timeout the session runs without reflect (recorded) |
|
||||
| `reflectTimeoutMs` | `120000` | **automatic** session-reflect timeout (hook harnesses additionally cap it at 25s to fit the host's hook window); on timeout the session runs without reflect (recorded) |
|
||||
| `reflectToolTimeoutMs` | `330000` | timeout for the agent-invoked `hindsight_reflect` tool — a call the agent waits on, whose high-budget synthesis on a populated bank runs for minutes. Defaults above the server's own reflect wall timeout (`HINDSIGHT_API_REFLECT_WALL_TIMEOUT`, 300s) so the server decides when to give up. Unset, it inherits an explicitly raised `reflectTimeoutMs`, but a short one never lowers it |
|
||||
| `reflectBudget` | `"high"` | reflect budget for the `hindsight_reflect` tool: `"low"`, `"mid"` or `"high"`. Drop it on a large bank where high-budget synthesis exceeds the server's wall timeout. The automatic session-start reflect always uses `"low"` to fit its hook window and is unaffected |
|
||||
| `pageRefreshEveryTurns` | `10` | refetch the knowledge pages and re-inject the page roster + tool guide every N user turns |
|
||||
| `pageTriggerType` | `"auto-refresh"` | when NEW knowledge pages refresh, i.e. what keeping them current costs — `"auto-refresh"` after every consolidation that produced new material, `"cron"` on `pageTriggerCron` only, `"manual"` never on their own. Auto-refresh is the most current and the most expensive: one synthesis per page per consolidation. Maps to the page's `trigger.refresh_after_consolidation` in the Hindsight API (`true` for auto-refresh, `false` for manual) |
|
||||
| `pageTriggerCron` | — | schedule for `pageTriggerType: "cron"` — UTC, standard 5-field cron, e.g. `"0 3 * * *"`. Sets the page's `trigger.refresh_cron`, which the API treats as mutually exclusive with `refresh_after_consolidation`; a scheduled refresh is skipped when nothing changed |
|
||||
|
||||
@@ -77,8 +77,10 @@ Layering, later wins: defaults → file → `harnesses.<name>` → `banks.<resol
|
||||
```
|
||||
|
||||
Key behavioral fields (any of them valid per-harness or per-bank): `disabled`,
|
||||
`retainSessions` (write-back opt-out), `gitIngest`, `reflectTimeoutMs` (default 120000; hooks cap
|
||||
at 25s), `autoReflect` (true; false = no injected first-prompt synthesis — the agent is instead
|
||||
`retainSessions` (write-back opt-out), `gitIngest`, `reflectTimeoutMs` (AUTOMATIC session reflect,
|
||||
default 120000; hooks cap at 25s), `reflectToolTimeoutMs`/`reflectBudget` (the agent-invoked
|
||||
`hindsight_reflect` tool: default 330000 — above the server's 300s reflect wall timeout — and
|
||||
"high"), `autoReflect` (true; false = no injected first-prompt synthesis — the agent is instead
|
||||
told to call `hindsight_reflect` on new goals), `pageRefreshEveryTurns` (10),
|
||||
`pageTriggerType`/`pageTriggerCron` (when NEW knowledge pages refresh: `auto-refresh` (default) after
|
||||
each consolidation, `cron` on a schedule, `manual` never — existing pages keep the trigger they were
|
||||
|
||||
@@ -109,6 +109,55 @@ describe("maxParallelRetains", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #3590: the hindsight_reflect tool aborted at a hardcoded 120s. The tool's window is now its own
|
||||
* knob, defaulting ABOVE the server's 300s reflect wall timeout — and it inherits an explicitly
|
||||
* raised reflectTimeoutMs, because that is the field users reaching for a longer reflect set.
|
||||
*/
|
||||
describe("reflectToolTimeoutMs / reflectBudget", () => {
|
||||
it("defaults above the server's reflect wall timeout, leaving the hook window untouched", () => {
|
||||
const cfg = resolveConfig({});
|
||||
expect(cfg.reflectToolTimeoutMs).toBe(330000);
|
||||
expect(cfg.reflectTimeoutMs).toBe(120000);
|
||||
expect(cfg.reflectBudget).toBe("high");
|
||||
});
|
||||
|
||||
it("inherits an explicitly raised reflectTimeoutMs", () => {
|
||||
expect(resolveConfig({ reflectTimeoutMs: 660000 }).reflectToolTimeoutMs).toBe(660000);
|
||||
});
|
||||
|
||||
it("is never LOWERED by a short reflectTimeoutMs — that bounds the hook, not the tool", () => {
|
||||
const cfg = resolveConfig({ reflectTimeoutMs: 5000 });
|
||||
expect(cfg.reflectTimeoutMs).toBe(5000);
|
||||
expect(cfg.reflectToolTimeoutMs).toBe(330000);
|
||||
});
|
||||
|
||||
it("an explicit reflectToolTimeoutMs wins over both", () => {
|
||||
expect(
|
||||
resolveConfig({ reflectTimeoutMs: 660000, reflectToolTimeoutMs: 90000 }).reflectToolTimeoutMs
|
||||
).toBe(90000);
|
||||
});
|
||||
|
||||
const ENV = { ...process.env };
|
||||
afterEach(() => {
|
||||
process.env = { ...ENV };
|
||||
});
|
||||
|
||||
it("reads the env fallbacks", () => {
|
||||
writeJson(globalCfg, {});
|
||||
process.env.HINDSIGHT_REFLECT_TOOL_TIMEOUT_MS = "600000";
|
||||
process.env.HINDSIGHT_REFLECT_BUDGET = "mid";
|
||||
const cfg = loadConfig({ path: globalCfg });
|
||||
expect(cfg.reflectToolTimeoutMs).toBe(600000);
|
||||
expect(cfg.reflectBudget).toBe("mid");
|
||||
});
|
||||
|
||||
it("falls back to high on an unknown budget rather than sending it to the API", () => {
|
||||
// The API rejects an unknown budget outright, so a typo here would fail every reflect call.
|
||||
expect(resolveConfig({ reflectBudget: "highest" as never }).reflectBudget).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
// A project-local .hindsight/coding-agent.json comes from the (untrusted) opened repo. It must not be
|
||||
// able to redirect the API endpoint/token or the global bank map — otherwise a malicious repo could
|
||||
// exfiltrate the user's token + prompts to its own server just by being opened.
|
||||
|
||||
@@ -84,6 +84,19 @@ export interface RawConfig {
|
||||
* lower this rather than raising it. */
|
||||
maxParallelRetains?: number;
|
||||
reflectTimeoutMs?: number; // session-start reflect timeout (default 120000; hooks cap lower internally)
|
||||
/** Timeout for the agent-invoked `hindsight_reflect` tool (default 330000). Deliberately its own
|
||||
* knob and much larger than `reflectTimeoutMs`: that one bounds an automatic hook that must fit
|
||||
* the host's 25s window, whereas this one bounds a call the agent made on purpose and waits on,
|
||||
* whose `budget: "high"` synthesis on a populated bank can run for minutes. The default sits
|
||||
* ABOVE the server's own reflect wall timeout (HINDSIGHT_API_REFLECT_WALL_TIMEOUT, 300s) so the
|
||||
* server decides when to give up, not an arbitrary client deadline (#3590). Unset, it inherits
|
||||
* an explicitly-raised `reflectTimeoutMs` — a user who raised that meant "let reflect run". */
|
||||
reflectToolTimeoutMs?: number;
|
||||
/** Reflect budget for the `hindsight_reflect` tool: "low" | "mid" | "high" (default "high").
|
||||
* Drop to "mid"/"low" on a large bank where high-budget synthesis exceeds the server's wall
|
||||
* timeout. The automatic session-start reflect is NOT affected — it always uses "low" to fit
|
||||
* its hook window. */
|
||||
reflectBudget?: "low" | "mid" | "high";
|
||||
autoReflect?: boolean; // inject a one-time reflect synthesis on the session's first prompt (default true; false = the agent reflects only via the hindsight_reflect tool, and the tool guide tells it to do so on new goals)
|
||||
pageRefreshEveryTurns?: number; // knowledge-page refresh cadence in user turns (default 10)
|
||||
/** What it COSTS to keep this project's knowledge pages current — the trigger stamped on every
|
||||
@@ -164,6 +177,8 @@ export interface Config {
|
||||
retainSessions: boolean;
|
||||
maxParallelRetains: number;
|
||||
reflectTimeoutMs: number;
|
||||
reflectToolTimeoutMs: number;
|
||||
reflectBudget: "low" | "mid" | "high";
|
||||
autoReflect: boolean;
|
||||
pageRefreshEveryTurns: number;
|
||||
pageTriggerType: "auto-refresh" | "cron" | "manual";
|
||||
@@ -202,6 +217,26 @@ function resolvePageTriggerType(raw: RawConfig): "auto-refresh" | "cron" | "manu
|
||||
return "auto-refresh";
|
||||
}
|
||||
|
||||
/** Default timeout for the agent-invoked `hindsight_reflect` tool — see RawConfig.reflectToolTimeoutMs. */
|
||||
export const DEFAULT_REFLECT_TOOL_TIMEOUT_MS = 330_000;
|
||||
|
||||
const REFLECT_BUDGETS = ["low", "mid", "high"] as const;
|
||||
|
||||
/**
|
||||
* Which reflect budget the `hindsight_reflect` tool should ask for.
|
||||
*
|
||||
* An unrecognized value takes the default rather than travelling: the API rejects an unknown budget
|
||||
* outright, which would turn a typo here into a hard tool failure on every call.
|
||||
*/
|
||||
function resolveReflectBudget(raw: RawConfig): "low" | "mid" | "high" {
|
||||
const value: unknown = raw.reflectBudget;
|
||||
if (value === undefined) return "high";
|
||||
if (typeof value === "string" && (REFLECT_BUDGETS as readonly string[]).includes(value))
|
||||
return value as "low" | "mid" | "high";
|
||||
log.warn("config", `ignoring reflectBudget=${JSON.stringify(value)} — expected low|mid|high`);
|
||||
return "high";
|
||||
}
|
||||
|
||||
/** The server's scalar scoping modes; anything else in this field has to be an explicit scope list. */
|
||||
const OBSERVATION_SCOPE_MODES = ["shared", "combined", "per_tag", "all_combinations"] as const;
|
||||
|
||||
@@ -267,6 +302,13 @@ export function resolveConfig(raw: RawConfig = {}): Config {
|
||||
retainSessions: raw.retainSessions ?? true, // opencode: write back by default (parity with hook-harness Stop)
|
||||
maxParallelRetains: raw.maxParallelRetains || 10,
|
||||
reflectTimeoutMs: raw.reflectTimeoutMs || 120000,
|
||||
// Inherit an explicitly-raised reflectTimeoutMs (that is what users reaching for a longer
|
||||
// reflect already set), but never let it LOWER the tool below the default — a short window is
|
||||
// set to bound the automatic hook, not to cut off a call the agent is waiting on.
|
||||
reflectToolTimeoutMs:
|
||||
raw.reflectToolTimeoutMs ||
|
||||
Math.max(raw.reflectTimeoutMs || 0, DEFAULT_REFLECT_TOOL_TIMEOUT_MS),
|
||||
reflectBudget: resolveReflectBudget(raw),
|
||||
autoReflect: raw.autoReflect ?? true,
|
||||
pageRefreshEveryTurns: raw.pageRefreshEveryTurns || 10,
|
||||
pageTriggerType: resolvePageTriggerType(raw),
|
||||
@@ -372,6 +414,8 @@ const ENV_KEYS = {
|
||||
retainSessions: "HINDSIGHT_RETAIN_SESSIONS",
|
||||
maxParallelRetains: "HINDSIGHT_MAX_PARALLEL_RETAINS",
|
||||
reflectTimeoutMs: "HINDSIGHT_REFLECT_TIMEOUT_MS",
|
||||
reflectToolTimeoutMs: "HINDSIGHT_REFLECT_TOOL_TIMEOUT_MS",
|
||||
reflectBudget: "HINDSIGHT_REFLECT_BUDGET",
|
||||
autoReflect: "HINDSIGHT_AUTO_REFLECT",
|
||||
pageRefreshEveryTurns: "HINDSIGHT_PAGE_REFRESH_EVERY_TURNS",
|
||||
pageTriggerType: "HINDSIGHT_PAGE_TRIGGER_TYPE",
|
||||
@@ -409,6 +453,7 @@ const ENV_NUMBERS = new Set<keyof RawConfig>([
|
||||
"daemonIdleTimeout",
|
||||
"maxParallelRetains",
|
||||
"reflectTimeoutMs",
|
||||
"reflectToolTimeoutMs",
|
||||
"pageRefreshEveryTurns",
|
||||
"seedLimit",
|
||||
"surveyBudgetUsd",
|
||||
|
||||
@@ -397,13 +397,16 @@ export class HindsightClient {
|
||||
);
|
||||
}
|
||||
|
||||
/** Reflect: synthesized, root-cause answer over the bank. Bounded so a slow server never hangs a caller. */
|
||||
async reflect(
|
||||
query: string,
|
||||
opts: { budget?: string; timeoutMs?: number } = {}
|
||||
): Promise<string> {
|
||||
/**
|
||||
* Reflect: synthesized, root-cause answer over the bank. Bounded so a slow server never hangs a
|
||||
* caller — but `timeoutMs` is REQUIRED, deliberately: the right deadline differs by an order of
|
||||
* magnitude between the automatic hook (25s, to fit the host's window) and the agent-invoked
|
||||
* tool (minutes, on a populated bank). This used to default to 120s, which silently overrode the
|
||||
* tool's configured window and aborted every high-budget synthesis mid-flight (#3590).
|
||||
*/
|
||||
async reflect(query: string, opts: { budget?: string; timeoutMs: number }): Promise<string> {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 120000);
|
||||
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs);
|
||||
try {
|
||||
const resp = await fetch(this.bankUrl("/reflect"), {
|
||||
method: "POST",
|
||||
|
||||
@@ -38,7 +38,7 @@ const UNRELATED_PROMPT = "completely unrelated banana smoothie question";
|
||||
|
||||
function makeClient(
|
||||
overrides: Partial<{
|
||||
reflect: (query: string, opts: { budget?: string; timeoutMs?: number }) => Promise<string>;
|
||||
reflect: (query: string, opts: { budget?: string; timeoutMs: number }) => Promise<string>;
|
||||
listPages: () => Promise<unknown>;
|
||||
getPage: (pageId: string) => Promise<unknown>;
|
||||
}> = {}
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface HookSpec {
|
||||
|
||||
/** Minimal client shape `buildHookOutput` needs — `HindsightClient` satisfies it structurally. */
|
||||
interface HookClient {
|
||||
reflect(query: string, opts: { budget?: string; timeoutMs?: number }): Promise<string>;
|
||||
reflect(query: string, opts: { budget?: string; timeoutMs: number }): Promise<string>;
|
||||
listPages(): Promise<unknown>;
|
||||
knowledgePagesSupported?: boolean;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildKnowledgeTools } from "./knowledge-tools";
|
||||
import { DEFAULT_REFLECT_TOOL_TIMEOUT_MS } from "./config";
|
||||
import type { HindsightClient } from "./hindsight";
|
||||
|
||||
/** Minimal stub of the HindsightClient surface the tools call — no SDK, no network. */
|
||||
@@ -191,10 +192,33 @@ describe("buildKnowledgeTools", () => {
|
||||
const tool = findTool(buildKnowledgeTools(client, "repo-a"), "hindsight_reflect");
|
||||
const result = await tool.handler({ query: "why is X 3?" });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(client.reflect).toHaveBeenCalledWith("why is X 3?", { budget: "high" });
|
||||
expect(client.reflect).toHaveBeenCalledWith("why is X 3?", {
|
||||
budget: "high",
|
||||
timeoutMs: DEFAULT_REFLECT_TOOL_TIMEOUT_MS,
|
||||
});
|
||||
expect(JSON.parse(result.content[0].text)).toBe("the decided rule is X=3");
|
||||
});
|
||||
|
||||
// #3590: the handler used to pass NO timeout, so the client fell back to a hardcoded 120s and
|
||||
// aborted every high-budget synthesis on a populated bank — with the configured value dead.
|
||||
it("hindsight_reflect passes the configured timeout and budget through to the client", async () => {
|
||||
const client = stubClient({ reflect: vi.fn(async () => "answer") });
|
||||
const tool = findTool(
|
||||
buildKnowledgeTools(client, "repo-a", { reflectTimeoutMs: 660_000, reflectBudget: "mid" }),
|
||||
"hindsight_reflect"
|
||||
);
|
||||
await tool.handler({ query: "why?" });
|
||||
expect(client.reflect).toHaveBeenCalledWith("why?", { budget: "mid", timeoutMs: 660_000 });
|
||||
});
|
||||
|
||||
it("hindsight_reflect never leaves the timeout unset (the client default would abort at 120s)", async () => {
|
||||
const client = stubClient({ reflect: vi.fn(async () => "answer") });
|
||||
const tool = findTool(buildKnowledgeTools(client, "repo-a"), "hindsight_reflect");
|
||||
await tool.handler({ query: "why?" });
|
||||
const opts = (client.reflect as unknown as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(opts.timeoutMs).toBeGreaterThan(300_000); // above the server's own reflect wall timeout
|
||||
});
|
||||
|
||||
it("hindsight_capture_initiative calls client.captureInitiative({title, summary, relatesToPageId}) and returns the page id", async () => {
|
||||
const client = stubClient({
|
||||
captureInitiative: vi.fn(async (_a: unknown) => ({ page_id: "initiative-retry-backoff" })),
|
||||
|
||||
@@ -19,7 +19,7 @@ import { join } from "node:path";
|
||||
import type { ZodRawShape } from "zod";
|
||||
import type { HindsightClient } from "./hindsight";
|
||||
import { syncStatus } from "./status";
|
||||
import { loadConfig } from "./config";
|
||||
import { DEFAULT_REFLECT_TOOL_TIMEOUT_MS, loadConfig } from "./config";
|
||||
import { describeError } from "./log";
|
||||
import type { RetainStamp } from "./retain-stamp";
|
||||
import type { PageTrigger } from "./missions";
|
||||
@@ -70,6 +70,12 @@ export function buildKnowledgeTools(
|
||||
stampFor?: () => RetainStamp;
|
||||
/** Refresh policy for a page `hindsight_capture_initiative` creates (core/missions.ts). */
|
||||
pageTrigger?: PageTrigger;
|
||||
/** How long `hindsight_reflect` waits on the server (cfg.reflectToolTimeoutMs). Must be
|
||||
* threaded in by every caller: left unset, the client falls back to a 120s deadline that
|
||||
* aborts high-budget synthesis on a populated bank mid-flight (#3590). */
|
||||
reflectTimeoutMs?: number;
|
||||
/** Reflect budget for `hindsight_reflect` (cfg.reflectBudget, default "high"). */
|
||||
reflectBudget?: "low" | "mid" | "high";
|
||||
} = {}
|
||||
): ToolSpec[] {
|
||||
return [
|
||||
@@ -187,7 +193,10 @@ export function buildKnowledgeTools(
|
||||
'your reply, credit it visibly with a blockquote header: "> 🧠 **From Hindsight memory** — <summary>".',
|
||||
inputSchema: { query: z.string().describe("the question to reason over memory about") },
|
||||
handler: guarded(async ({ query }: { query: string }) =>
|
||||
client.reflect(query, { budget: "high" })
|
||||
client.reflect(query, {
|
||||
budget: opts.reflectBudget ?? "high",
|
||||
timeoutMs: opts.reflectTimeoutMs ?? DEFAULT_REFLECT_TOOL_TIMEOUT_MS,
|
||||
})
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -168,6 +168,27 @@ describe("RuntimeCore daemon lifecycle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The plugin harnesses (opencode, kilo, cline, dsh, ...) register the tools through toolSpecs()
|
||||
* rather than the MCP server, so they hit the same #3590 bug: without these forwarded, every
|
||||
* hindsight_reflect on those hosts aborts at the client's hardcoded 120s.
|
||||
*/
|
||||
describe("RuntimeCore reflect tool settings", () => {
|
||||
it("forwards the resolved reflect timeout and budget into hindsight_reflect", async () => {
|
||||
const reflect = vi.fn(async () => "answer");
|
||||
const client = { reflect } as unknown as HindsightClient;
|
||||
const core = new RuntimeCore(
|
||||
client,
|
||||
"bank-1",
|
||||
resolveConfig({ reflectToolTimeoutMs: 660_000, reflectBudget: "mid" })
|
||||
);
|
||||
|
||||
const tool = core.toolSpecs().find((spec) => spec.name === "hindsight_reflect")!;
|
||||
await tool.handler({ query: "why?" });
|
||||
expect(reflect).toHaveBeenCalledWith("why?", { budget: "mid", timeoutMs: 660_000 });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* dsh serves several repositories from ONE process, launched in a directory that is routinely not
|
||||
* the session's — so it constructs a core per workspace and passes that root. Every tool that
|
||||
|
||||
@@ -79,6 +79,8 @@ export class RuntimeCore {
|
||||
repoDir: this.projectDir,
|
||||
harness: this.harness,
|
||||
pageTrigger: buildPageTrigger(this.cfg),
|
||||
reflectTimeoutMs: this.cfg.reflectToolTimeoutMs,
|
||||
reflectBudget: this.cfg.reflectBudget,
|
||||
stampFor: () =>
|
||||
buildRetainStamp(this.cfg, {
|
||||
directory: this.projectDir,
|
||||
|
||||
@@ -40,6 +40,18 @@ describe("selectTools", () => {
|
||||
expect(JSON.parse(result.content[0].text)).toMatchObject({ harness: "codex" });
|
||||
});
|
||||
|
||||
// #3590: selectTools built the tools WITHOUT the reflect settings, so hindsight_reflect fell
|
||||
// back to the client's hardcoded 120s deadline and reflectTimeoutMs was dead config.
|
||||
it("forwards the resolved reflect timeout and budget into hindsight_reflect", async () => {
|
||||
const reflect = vi.fn().mockResolvedValue("answer");
|
||||
const client = { reflect } as unknown as HindsightClient;
|
||||
const cfg = resolveConfig({ reflectToolTimeoutMs: 660_000, reflectBudget: "mid" });
|
||||
const tools = selectTools(cfg, client, "b");
|
||||
|
||||
await tools.find((tool) => tool.name === "hindsight_reflect")!.handler({ query: "why?" });
|
||||
expect(reflect).toHaveBeenCalledWith("why?", { budget: "mid", timeoutMs: 660_000 });
|
||||
});
|
||||
|
||||
it("also attributes documents ingested through the MCP tool to that harness", async () => {
|
||||
// The same option feeds hindsight_ingest_document, which until now stamped nothing: the
|
||||
// documents list resolves a document's agent logo from `metadata.harness` / `harness:<id>`,
|
||||
|
||||
@@ -38,6 +38,8 @@ export function selectTools(
|
||||
repoDir: cwd,
|
||||
harness,
|
||||
pageTrigger: buildPageTrigger(cfg),
|
||||
reflectTimeoutMs: cfg.reflectToolTimeoutMs,
|
||||
reflectBudget: cfg.reflectBudget,
|
||||
stampFor: () => buildRetainStamp(cfg, { directory: cwd, harness, bankId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -356,7 +356,9 @@ hook by Codex...), so one shared config serves several agents side by side:
|
||||
| `retainMetadata` | — | extra metadata on every document written by the integration, e.g. `{"repo": "{gitProject}"}` |
|
||||
| `observationScopes` | `"shared"` | how consolidation groups observations: `"shared"` (default) = ONE global scope per bank, so every agent on a repo builds one set of beliefs; also `"combined"` (the server default), `"per_tag"`, `"all_combinations"`, `[["t"]]` |
|
||||
| `disabled` | `false` | hard off-switch (inert plugin/hook — a no-memory baseline) |
|
||||
| `reflectTimeoutMs` | `120000` | session-reflect timeout (hook harnesses additionally cap it at 25s to fit the host's hook window); on timeout the session runs without reflect (recorded) |
|
||||
| `reflectTimeoutMs` | `120000` | **automatic** session-reflect timeout (hook harnesses additionally cap it at 25s to fit the host's hook window); on timeout the session runs without reflect (recorded) |
|
||||
| `reflectToolTimeoutMs` | `330000` | timeout for the agent-invoked `hindsight_reflect` tool — a call the agent waits on, whose high-budget synthesis on a populated bank runs for minutes. Defaults above the server's own reflect wall timeout (`HINDSIGHT_API_REFLECT_WALL_TIMEOUT`, 300s) so the server decides when to give up. Unset, it inherits an explicitly raised `reflectTimeoutMs`, but a short one never lowers it |
|
||||
| `reflectBudget` | `"high"` | reflect budget for the `hindsight_reflect` tool: `"low"`, `"mid"` or `"high"`. Drop it on a large bank where high-budget synthesis exceeds the server's wall timeout. The automatic session-start reflect always uses `"low"` to fit its hook window and is unaffected |
|
||||
| `pageRefreshEveryTurns` | `10` | refetch the knowledge pages and re-inject the page roster + tool guide every N user turns |
|
||||
| `pageTriggerType` | `"auto-refresh"` | when NEW knowledge pages refresh, i.e. what keeping them current costs — `"auto-refresh"` after every consolidation that produced new material, `"cron"` on `pageTriggerCron` only, `"manual"` never on their own. Auto-refresh is the most current and the most expensive: one synthesis per page per consolidation. Maps to the page's `trigger.refresh_after_consolidation` in the Hindsight API (`true` for auto-refresh, `false` for manual) |
|
||||
| `pageTriggerCron` | — | schedule for `pageTriggerType: "cron"` — UTC, standard 5-field cron, e.g. `"0 3 * * *"`. Sets the page's `trigger.refresh_cron`, which the API treats as mutually exclusive with `refresh_after_consolidation`; a scheduled refresh is skipped when nothing changed |
|
||||
|
||||
Reference in New Issue
Block a user