Skip to content

refactor(server): fetch boot context from ContexGin HTTP API#358

Open
dimakis wants to merge 3 commits into
mainfrom
feat/contexgin-http-boot
Open

refactor(server): fetch boot context from ContexGin HTTP API#358
dimakis wants to merge 3 commits into
mainfrom
feat/contexgin-http-boot

Conversation

@dimakis
Copy link
Copy Markdown
Owner

@dimakis dimakis commented May 22, 2026

Summary

  • Replace inline import('contexgin') library call with HTTP fetch to running ContexGin server (GET /api/agents/:name/context)
  • The old approach used a hardcoded 8k token budget fallback and read the agent recipe from the worktree path (often stale/missing). The server already compiles correctly with the recipe's 12k budget.
  • Extract fetchBootContext() as a testable function, reducing ~140 lines of library import + recipe parsing + response mapping to a single HTTP call

Root cause

chat.ts imported contexgin as a Node library and called the legacy compile() function with DEFAULT_TOKEN_BUDGET = 8000. It attempted to read tokenBudget from .agents/mitzo-conversational.yaml in cwd (the worktree), which often didn't have the file or had a stale version. Meanwhile, the running ContexGin server at CONTEXGIN_URL was already compiling correctly with 12k. Mitzo was bypassing its own infrastructure.

Test plan

  • 7 new tests: success path, unreachable server, non-200 response, missing boot field, empty sources, non-string source filtering, env var URL
  • Existing contexgin-smoke, context-baseline, and prompt-compare tests still pass (21/21)
  • Full test suite: no new failures (2 pre-existing on main)
  • Manual: start a Mitzo session, verify boot context shows >8k budget in source explorer

🤖 Generated with Claude Code

Copy link
Copy Markdown
Owner Author

@dimakis dimakis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 5 issue(s) (2 warning).

server/chat.ts

Clean refactor from library import to HTTP API. Main concern is a missing .catch() on the fire-and-forget promise (unhandled rejection risk), and a UI regression where the included/trimmed section detail and budget-vs-actual distinction are silently lost from BootContextPill.

  • 🟡 bugs (L835): Missing .catch() on fire-and-forget promise chain. If transport.send() throws after the async gap (e.g., transport closed between fetch and send), this produces an unhandled promise rejection. The adjacent capturePromptComparison call correctly uses .catch(() => {}). Fix: fetchBootContext('mitzo-conversational').then((msg) => send(transport, msg)).catch(() => {}); [fixable]
  • 🟡 regressions (L160): included and trimmed are now always empty arrays. The old code populated these from compiled.included/compiled.trimmed with section-level detail (source, heading, token count, content). The frontend's BootContextPill renders expandable 'Included (N)' and 'N sections trimmed' sections from these fields — those UI sections will never appear now. If intentional (ContexGin manages compilation internally), these fields on BootContextMessage are effectively dead code and should be removed from the interface to avoid confusion.
  • 🔵 regressions (L160): tokenBudget is now always equal to tokenCount (both set to bootTokens). The old code distinguished configured budget (from agent recipe YAML, default 8000) from actual usage. The frontend displays these as 'X.Xk / X.Xk' (count / budget) which is now always identical, making the budget half of the label redundant. In the fallback case, budget changed from 8000 to 0, so the pill shows '0 / 0' instead of '0 / 8.0k'.

server/__tests__/boot-context.test.ts

Clean refactor from library import to HTTP API. Main concern is a missing .catch() on the fire-and-forget promise (unhandled rejection risk), and a UI regression where the included/trimmed section detail and budget-vs-actual distinction are silently lost from BootContextPill.

  • 🔵 missing_tests: Missing test for malformed JSON response — when res.json() throws (e.g., body is not valid JSON despite ok: true), the outer catch block should return FALLBACK_BOOT_CONTEXT. This edge case is handled correctly by the implementation but not verified by tests. [fixable]
  • 🔵 unsafe_assumptions (L167): Env var cleanup in the last test is not wrapped in try/finally. If an assertion fails before line 180, process.env.CONTEXGIN_URL leaks its test value into subsequent tests. Use try/finally or Vitest's vi.stubEnv() for safer cleanup. [fixable]

Comment thread server/chat.ts Outdated
}
})();
// Fire-and-forget: fetch boot context from running ContexGin server
fetchBootContext('mitzo-conversational').then((msg) => send(transport, msg));
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: Missing .catch() on fire-and-forget promise chain. If transport.send() throws after the async gap (e.g., transport closed between fetch and send), this produces an unhandled promise rejection. The adjacent capturePromptComparison call correctly uses .catch(() => {}). Fix: fetchBootContext('mitzo-conversational').then((msg) => send(transport, msg)).catch(() => {}); [fixable]

Comment thread server/chat.ts
source: 'contexgin',
sourceCount: sources.length,
tokenCount: bootTokens,
tokenBudget: bootTokens, // server compiles to its own budget — report actual
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 regressions: included and trimmed are now always empty arrays. The old code populated these from compiled.included/compiled.trimmed with section-level detail (source, heading, token count, content). The frontend's BootContextPill renders expandable 'Included (N)' and 'N sections trimmed' sections from these fields — those UI sections will never appear now. If intentional (ContexGin manages compilation internally), these fields on BootContextMessage are effectively dead code and should be removed from the interface to avoid confusion.

Comment thread server/chat.ts
source: 'contexgin',
sourceCount: sources.length,
tokenCount: bootTokens,
tokenBudget: bootTokens, // server compiles to its own budget — report actual
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 regressions: tokenBudget is now always equal to tokenCount (both set to bootTokens). The old code distinguished configured budget (from agent recipe YAML, default 8000) from actual usage. The frontend displays these as 'X.Xk / X.Xk' (count / budget) which is now always identical, making the budget half of the label redundant. In the fallback case, budget changed from 8000 to 0, so the pill shows '0 / 0' instead of '0 / 8.0k'.

Comment thread server/__tests__/boot-context.test.ts Outdated
expect(result.sourceCount).toBe(2);
});

it('uses default URL from env when not provided', async () => {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 unsafe_assumptions: Env var cleanup in the last test is not wrapped in try/finally. If an assertion fails before line 180, process.env.CONTEXGIN_URL leaks its test value into subsequent tests. Use try/finally or Vitest's vi.stubEnv() for safer cleanup. [fixable]

Copy link
Copy Markdown
Owner Author

@dimakis dimakis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 7 issue(s) (2 warning).

server/chat.ts

Clean refactoring from npm-imported contexgin to HTTP API with solid fallback chain and good test coverage. Main concerns: missing .catch() on fire-and-forget promise, and tokenBudget now always equals tokenCount which may be a behavioral regression for the frontend budget display.

  • 🟡 bugs (L880): Unhandled rejection risk: fetchBootContext never rejects, but send(transport, msg) inside .then() could throw (e.g., if transport.send() throws on a closed/errored socket). Add .catch() to match the pattern used on the adjacent line (capturePromptComparison(...).catch(() => {})). [fixable]
  • 🟡 regressions (L205): The old code reported a separate tokenBudget read from the agent recipe YAML (.agents/mitzo-conversational.yaml), which could differ from actual token count. The new code sets tokenBudget: bootTokens — always equal to tokenCount. If the frontend uses tokenBudget to show budget vs. actual usage, it will now always show 100% utilization. Verify the frontend BootContextPill doesn't rely on budget != count. [fixable]
  • 🔵 regressions (L200): The old code populated included and trimmed arrays with section-level metadata (source, heading, tokens, content) from the contexgin compile result. The new HTTP API path always sends empty arrays for both. If ContexGin's HTTP response includes this data, it's being silently dropped. If it doesn't, the BootContextMessage interface could be simplified to make included/trimmed optional. [fixable]
  • 🔵 unsafe_assumptions (L135): Hardcoded sourceCount: 5 and a hardcoded list of 5 source paths in localBootContextFallback. If the Python script's SOURCE_PATHS changes, this will silently drift. Consider parsing the actual sources from the script output if it provides them, or adding a comment noting this coupling. [fixable]
  • 🔵 unsafe_assumptions (L167): agentName is interpolated directly into the URL path without encoding. Currently hardcoded to 'mitzo-conversational' at the call site, so safe in practice. But if the function is reused with user-supplied names containing slashes or special characters, the URL would be malformed. Consider encodeURIComponent(agentName) for defense in depth. [fixable]
  • 🔵 style (L198): Redundant cast: (boot.content as string) is already inside a typeof boot.content === 'string' guard, so the as string is unnecessary. [fixable]

server/__tests__/boot-context.test.ts

Clean refactoring from npm-imported contexgin to HTTP API with solid fallback chain and good test coverage. Main concerns: missing .catch() on fire-and-forget promise, and tokenBudget now always equals tokenCount which may be a behavioral regression for the frontend budget display.

  • 🔵 missing_tests: No test for when fetch succeeds but res.json() throws (e.g., invalid JSON body). The implementation's outer catch handles it, but an explicit test would document this contract. [fixable]

Comment thread server/chat.ts Outdated
}
})();
// Fire-and-forget: fetch boot context from running ContexGin server
fetchBootContext('mitzo-conversational').then((msg) => send(transport, msg));
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: Unhandled rejection risk: fetchBootContext never rejects, but send(transport, msg) inside .then() could throw (e.g., if transport.send() throws on a closed/errored socket). Add .catch() to match the pattern used on the adjacent line (capturePromptComparison(...).catch(() => {})). [fixable]

Comment thread server/chat.ts
source: 'contexgin',
sourceCount: sources.length,
tokenCount: bootTokens,
tokenBudget: bootTokens, // server compiles to its own budget — report actual
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 regressions: The old code reported a separate tokenBudget read from the agent recipe YAML (.agents/mitzo-conversational.yaml), which could differ from actual token count. The new code sets tokenBudget: bootTokens — always equal to tokenCount. If the frontend uses tokenBudget to show budget vs. actual usage, it will now always show 100% utilization. Verify the frontend BootContextPill doesn't rely on budget != count. [fixable]

Comment thread server/chat.ts

const fullMarkdown = typeof boot.content === 'string' ? (boot.content as string) : undefined;

return {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 regressions: The old code populated included and trimmed arrays with section-level metadata (source, heading, tokens, content) from the contexgin compile result. The new HTTP API path always sends empty arrays for both. If ContexGin's HTTP response includes this data, it's being silently dropped. If it doesn't, the BootContextMessage interface could be simplified to make included/trimmed optional. [fixable]

Comment thread server/chat.ts Outdated
return {
type: 'boot_context',
source: 'local-fallback',
sourceCount: 5, // hardcoded source count from build_boot_context.py SOURCE_PATHS
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 unsafe_assumptions: Hardcoded sourceCount: 5 and a hardcoded list of 5 source paths in localBootContextFallback. If the Python script's SOURCE_PATHS changes, this will silently drift. Consider parsing the actual sources from the script output if it provides them, or adding a comment noting this coupling. [fixable]

Comment thread server/chat.ts Outdated
repoRoot: string = BASE_REPO,
): Promise<BootContextMessage> {
try {
const url = `${contexginUrl}/api/agents/${agentName}/context`;
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 unsafe_assumptions: agentName is interpolated directly into the URL path without encoding. Currently hardcoded to 'mitzo-conversational' at the call site, so safe in practice. But if the function is reused with user-supplied names containing slashes or special characters, the URL would be malformed. Consider encodeURIComponent(agentName) for defense in depth. [fixable]

Comment thread server/chat.ts Outdated
.filter((s): s is string => typeof s === 'string')
.map((s) => ({ path: s, kind: 'reference' }));

const fullMarkdown = typeof boot.content === 'string' ? (boot.content as string) : undefined;
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: Redundant cast: (boot.content as string) is already inside a typeof boot.content === 'string' guard, so the as string is unnecessary. [fixable]

dimakis and others added 3 commits May 26, 2026 07:09
Replace the inline `import('contexgin')` library call with an HTTP fetch
to the running ContexGin server (`GET /api/agents/:name/context`).

The old approach imported contexgin as a library, read the agent recipe
from the worktree (which could be stale or missing), used the legacy
`compile()` pipeline, and fell back to a hardcoded 8k token budget.
The running server already compiles correctly with the recipe's 12k
budget — this change makes Mitzo use it.

- Extract `fetchBootContext()` as a testable, exported function
- Use `CONTEXGIN_URL` env var (already configured in .env)
- 5s timeout with graceful local-fallback on any error
- Remove ~140 lines of library import, recipe parsing, and response
  mapping code
- Add 7 focused tests covering success, failure, and edge cases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When ContexGin is unreachable, fall back to `build_boot_context.py --json`
which reads the canonical source files directly (Profile, Constitution,
Services) without any server dependency. This is the deterministic "old
way" — always works, no budget drift.

Fallback chain: ContexGin HTTP → build_boot_context.py → zero-value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add .catch() on fire-and-forget boot context promise
- Use encodeURIComponent for agent name in URL path
- Remove redundant `as string` cast inside type guard
- Add coupling comment on hardcoded SOURCE_PATHS list
- Add test for malformed JSON response fallback
- Wrap env var test cleanup in try/finally

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dimakis dimakis force-pushed the feat/contexgin-http-boot branch from 330bb8d to a1c5508 Compare May 26, 2026 06:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant