From b1e915b8234817ee8155425b861fe44d20ad1939 Mon Sep 17 00:00:00 2001 From: imer Date: Sat, 9 May 2026 11:00:36 +0200 Subject: [PATCH 1/4] fix(IN-04): preserve MAX_BUDGET_USD=0 instead of treating it as unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _coerce_budget validator used a falsy check (if not v:) which treated both None/empty-string AND 0/0.0 as "unset" — silently disabling the budget guard when the user explicitly set the cap to zero. Switched to explicit None/empty-string check so 0.0 propagates and BudgetGuard correctly blocks all calls. Co-Authored-By: Claude Sonnet 4.6 --- core/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/config.py b/core/config.py index 441e6ee..fa764c1 100644 --- a/core/config.py +++ b/core/config.py @@ -81,7 +81,7 @@ class Settings(BaseSettings): @field_validator("max_budget_usd", mode="before") @classmethod def _coerce_budget(cls, v: Any) -> Optional[float]: - if not v: + if v is None or v == "": return None try: return float(v) From 1ecd1d82ee0be4e91bd4645df3a57852161a0838 Mon Sep 17 00:00:00 2001 From: imer Date: Sat, 9 May 2026 11:00:42 +0200 Subject: [PATCH 2/4] chore(IN-01): remove unused worker stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/worker/main.py was an infinite sleep loop — never wired into docker-compose, never imported, no queue infrastructure exists. The FastAPI server handles backgrounding via in-process asyncio.create_task. Removing the stub rather than implementing a feature that was never actually planned. Updated logging and architecture docs to remove worker entrypoint references. Co-Authored-By: Claude Sonnet 4.6 --- apps/worker/__init__.py | 0 apps/worker/main.py | 19 ------------------- core/logging.py | 2 +- docs/DEVELOPMENT.md | 2 +- docs/architecture.md | 2 +- 5 files changed, 3 insertions(+), 22 deletions(-) delete mode 100644 apps/worker/__init__.py delete mode 100644 apps/worker/main.py diff --git a/apps/worker/__init__.py b/apps/worker/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/worker/main.py b/apps/worker/main.py deleted file mode 100644 index 83b6d7d..0000000 --- a/apps/worker/main.py +++ /dev/null @@ -1,19 +0,0 @@ -import asyncio -import logging - -from core.logging import configure_logging - -configure_logging() -logger = logging.getLogger(__name__) - - -async def main(): - logger.info("Worker process starting. Awaiting async tasks...") - # In a real app, this might connect to a Redis queue or Celery broker - while True: - await asyncio.sleep(10) - logger.debug("Worker heartbeat...") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/core/logging.py b/core/logging.py index c510c22..ea0a1ce 100644 --- a/core/logging.py +++ b/core/logging.py @@ -13,7 +13,7 @@ Usage ----- Call ``configure_logging()`` once at each app entrypoint (``apps/api/``, -``apps/mcp_server/``, ``apps/worker/``). Do **not** call it in library code +``apps/mcp_server/``). Do **not** call it in library code or tests — tests rely on pytest's default capture. from core.logging import configure_logging diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index bfc1daa..bebf6fc 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -114,7 +114,7 @@ This runs the full hook stack: trailing whitespace, YAML/JSON/TOML validity, lar ## Project layout ``` -apps/ Entry points — CLI, FastAPI server, MCP server, worker stub. +apps/ Entry points — CLI, FastAPI server, MCP server. Nothing in apps/ is imported by other packages. core/ Orchestrator, router, blackboard, memory, schemas, evals, sessions. diff --git a/docs/architecture.md b/docs/architecture.md index 07b97f0..1784b7b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,7 +99,7 @@ Output format is driven by the `ENVIRONMENT` setting: Each entry carries `log_level`, `logger_name`, and an ISO-8601 timestamp. `structlog.contextvars` is included in the chain so any key bound with `structlog.contextvars.bind_contextvars()` (e.g., `session_id`) propagates to every log line emitted within that async context. -`configure_logging()` must be called once at each app entrypoint (`apps/api/`, `apps/mcp_server/`, `apps/worker/`). It must not be called in library code or tests. +`configure_logging()` must be called once at each app entrypoint (`apps/api/`, `apps/mcp_server/`). It must not be called in library code or tests. ## Embedding cache From 0b13662b6d3d73988c9b920e13f976895a9c1581 Mon Sep 17 00:00:00 2001 From: imer Date: Sat, 9 May 2026 11:00:49 +0200 Subject: [PATCH 3/4] refactor(IN-02, IN-03): consolidate placeholder builders into shared module Created providers/placeholders.py as the single home for the placeholder helpers. Resolves two issues at once: - IN-02: _generate_placeholder_json was duplicated byte-for-byte between providers/adapters.py and providers/unified.py. Both now import from providers.placeholders. - IN-03: core/orchestrator/thesis_flow.py imported five private symbols (_build_placeholder_*) from providers/thesis_agents.py, creating a cross-module private-API dependency. Promoted them to public symbols in the new shared module so both thesis_agents and thesis_flow consume them through a stable public interface. Co-Authored-By: Claude Sonnet 4.6 --- core/orchestrator/thesis_flow.py | 38 ++++++------- providers/adapters.py | 22 +------- providers/placeholders.py | 97 ++++++++++++++++++++++++++++++++ providers/thesis_agents.py | 77 +++++-------------------- providers/unified.py | 23 +------- tests/test_integrations.py | 11 ++-- 6 files changed, 139 insertions(+), 129 deletions(-) create mode 100644 providers/placeholders.py diff --git a/core/orchestrator/thesis_flow.py b/core/orchestrator/thesis_flow.py index af6c1e0..937e108 100644 --- a/core/orchestrator/thesis_flow.py +++ b/core/orchestrator/thesis_flow.py @@ -37,11 +37,11 @@ ResearcherAgent, SynthesizerAgent, ThesisHeadProvider, - _build_placeholder_citation_audit, - _build_placeholder_critique_result, - _build_placeholder_lit_map, - _build_placeholder_research_plan, - _build_placeholder_synthesis_report, + build_placeholder_citation_audit, + build_placeholder_critique_result, + build_placeholder_lit_map, + build_placeholder_research_plan, + build_placeholder_synthesis_report, ) from providers.unified import UnifiedLLM @@ -157,7 +157,7 @@ async def _execute_inner(self, research_context: ResearchContext) -> ResearchSes async def _run_planner() -> ResearchPlan: if not route.activate_head_planner: obs_logger.log_event("stage_skipped", session_id, {"stage": "head_planner"}) - return _build_placeholder_research_plan(research_context.research_question) + return build_placeholder_research_plan(research_context.research_question) try: result = await self.head.execute( task, @@ -174,7 +174,7 @@ async def _run_planner() -> ResearchPlan: obs_logger.log_event( "stage_failed", session_id, {"stage": "head_planner", "error": str(e)} ) - return _build_placeholder_research_plan(research_context.research_question) + return build_placeholder_research_plan(research_context.research_question) async def _run_memory() -> MemoryBrief: try: @@ -277,12 +277,12 @@ async def _run_corpus() -> None: ) except Exception as e: errors.append(f"researcher: {e}") - lit_map = _build_placeholder_lit_map(research_context.research_question) + lit_map = build_placeholder_lit_map(research_context.research_question) obs_logger.log_event( "stage_failed", session_id, {"stage": "researcher", "error": str(e)} ) else: - lit_map = _build_placeholder_lit_map(research_context.research_question) + lit_map = build_placeholder_lit_map(research_context.research_question) obs_logger.log_event("stage_skipped", session_id, {"stage": "researcher"}) # ── Phase C: checker ∥ synthesizer (both consume lit_map, neither @@ -296,7 +296,7 @@ async def _run_corpus() -> None: async def _run_checker() -> CitationAudit: if not route.activate_checker: obs_logger.log_event("stage_skipped", session_id, {"stage": "checker"}) - return _build_placeholder_citation_audit() + return build_placeholder_citation_audit() try: result = await self.checker.execute(task, {"blackboard_entries": shared_entries}) audit = cast(CitationAudit, result["output"]) @@ -310,12 +310,12 @@ async def _run_checker() -> CitationAudit: obs_logger.log_event( "stage_failed", session_id, {"stage": "checker", "error": str(e)} ) - return _build_placeholder_citation_audit() + return build_placeholder_citation_audit() async def _run_synthesizer() -> SynthesisReport: if not route.activate_synthesizer: obs_logger.log_event("stage_skipped", session_id, {"stage": "synthesizer"}) - return _build_placeholder_synthesis_report(research_context.research_question) + return build_placeholder_synthesis_report(research_context.research_question) try: result = await self.synthesizer.execute( task, {"blackboard_entries": shared_entries} @@ -338,7 +338,7 @@ async def _run_synthesizer() -> SynthesisReport: obs_logger.log_event( "stage_failed", session_id, {"stage": "synthesizer", "error": str(e)} ) - return _build_placeholder_synthesis_report(research_context.research_question) + return build_placeholder_synthesis_report(research_context.research_question) citation_audit, synthesis_report = await asyncio.gather( _run_checker(), @@ -359,12 +359,12 @@ async def _run_synthesizer() -> SynthesisReport: ) except Exception as e: errors.append(f"critic: {e}") - critique = _build_placeholder_critique_result() + critique = build_placeholder_critique_result() obs_logger.log_event( "stage_failed", session_id, {"stage": "critic", "error": str(e)} ) else: - critique = _build_placeholder_critique_result() + critique = build_placeholder_critique_result() obs_logger.log_event("stage_skipped", session_id, {"stage": "critic"}) # ── Stage 7: HEAD final pass (supervisor) ── @@ -383,12 +383,12 @@ async def _run_synthesizer() -> SynthesisReport: ) except Exception as e: errors.append(f"head_supervisor: {e}") - final_critique = _build_placeholder_critique_result() + final_critique = build_placeholder_critique_result() obs_logger.log_event( "stage_failed", session_id, {"stage": "head_supervisor", "error": str(e)} ) else: - final_critique = _build_placeholder_critique_result() + final_critique = build_placeholder_critique_result() obs_logger.log_event("stage_skipped", session_id, {"stage": "head_supervisor"}) # ── Stage 8: Assemble ResearchSession ── @@ -669,6 +669,6 @@ async def _critique_only(self, research_context: ResearchContext) -> CritiqueRes output: Any = result.get("output") if isinstance(output, CritiqueResult): return output - return _build_placeholder_critique_result() + return build_placeholder_critique_result() except Exception: - return _build_placeholder_critique_result() + return build_placeholder_critique_result() diff --git a/providers/adapters.py b/providers/adapters.py index c26f0a8..049fc8c 100644 --- a/providers/adapters.py +++ b/providers/adapters.py @@ -9,6 +9,7 @@ from core.orchestrator.compiler import PromptCompiler from core.schemas import Task from providers.interfaces import HeadProvider, MiddleProvider, WorkerProvider +from providers.placeholders import generate_placeholder_json from providers.unified import UnifiedLLM _PROVIDER_API_KEY_ATTR = { @@ -59,7 +60,7 @@ async def generate( if self.dry_run: await asyncio.sleep(0.01) if response_schema: - return _generate_placeholder_json(response_schema) + return generate_placeholder_json(response_schema) return f"[{self.provider.upper()}/{model_name} DRY_RUN] Processed: {prompt[:50]}..." if not self.api_key: raise ValueError(f"No API key configured for provider '{self.provider}'") @@ -166,25 +167,6 @@ async def _generate_openai( return str(response.choices[0].message.content) -def _generate_placeholder_json(schema: Dict[str, Any]) -> str: - props = schema.get("properties", {}) - required = schema.get("required", []) - result: Dict[str, Any] = {} - for key, prop in props.items(): - prop_type = prop.get("type", "string") - if prop_type == "string": - result[key] = "[DRY_RUN]" if key in required else "" - elif prop_type == "integer" or prop_type == "number": - result[key] = 0 - elif prop_type == "boolean": - result[key] = False - elif prop_type == "array": - result[key] = [] - elif prop_type == "object": - result[key] = {} - return json.dumps(result) - - def _get_available_providers() -> list[str]: from core.config import get_settings diff --git a/providers/placeholders.py b/providers/placeholders.py new file mode 100644 index 0000000..a712341 --- /dev/null +++ b/providers/placeholders.py @@ -0,0 +1,97 @@ +"""Shared placeholder builders used by adapters, unified, and thesis_flow. + +These helpers fabricate well-formed but empty results for two cases: +* ``generate_placeholder_json`` — DRY_RUN mode in the LLM adapter layer. +* ``build_placeholder_*`` — orchestrator fallback when an agent fails or + is skipped, so downstream stages always receive a valid object. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict + +from core.schemas import ( + CitationAudit, + CritiqueResult, + LitMap, + ResearchPlan, + SynthesisReport, +) + + +def generate_placeholder_json(schema: Dict[str, Any]) -> str: + props = schema.get("properties", {}) + required = schema.get("required", []) + result: Dict[str, Any] = {} + for key, prop in props.items(): + prop_type = prop.get("type", "string") + if prop_type == "string": + result[key] = "[DRY_RUN]" if key in required else "" + elif prop_type in ("integer", "number"): + result[key] = 0 + elif prop_type == "boolean": + result[key] = False + elif prop_type == "array": + result[key] = [] + elif prop_type == "object": + result[key] = {} + return json.dumps(result) + + +def build_placeholder_research_plan(question: str) -> ResearchPlan: + return ResearchPlan( + plan_id="placeholder-plan-001", + research_question=question, + subquestions=["What does the existing literature say?", "What methodological gaps exist?"], + strategy="broad_survey", + search_lanes=[ + { + "query": "placeholder search", + "source": "semantic_scholar", + "purpose": "initial survey", + }, + ], + evidence_needs=["literature review", "methodology assessment"], + budget_allocation={"max_searches": 5, "max_papers_per_search": 10}, + suggested_methodology="systematic review", + ) + + +def build_placeholder_lit_map(question: str) -> LitMap: + return LitMap( + research_question=question, + supporting=[], + challenging=[], + adjacent=[], + total_found=0, + search_query_used="placeholder_query", + ) + + +def build_placeholder_citation_audit() -> CitationAudit: + return CitationAudit(claims_checked=0, verified_claims=0) + + +def build_placeholder_synthesis_report(question: str = "") -> SynthesisReport: + return SynthesisReport( + research_question=question, + method_summary={}, + dataset_summary={}, + metric_summary={}, + corpus_insights={}, + recommended_reading=[], + cross_paper_comparisons=[], + ) + + +def build_placeholder_critique_result() -> CritiqueResult: + return CritiqueResult( + strengths=[], + weaknesses=[], + gaps=[], + counterarguments=[], + suggestions=[], + methodological_notes=[], + overall_assessment="", + ) diff --git a/providers/thesis_agents.py b/providers/thesis_agents.py index 032bbc8..157de19 100644 --- a/providers/thesis_agents.py +++ b/providers/thesis_agents.py @@ -16,6 +16,13 @@ SynthesisReport, Task, ) +from providers.placeholders import ( + build_placeholder_citation_audit, + build_placeholder_critique_result, + build_placeholder_lit_map, + build_placeholder_research_plan, + build_placeholder_synthesis_report, +) from providers.unified import UnifiedLLM logger = logging.getLogger(__name__) @@ -117,64 +124,6 @@ def _parse_structured_output( return dict_converter(parsed_dict) -def _build_placeholder_research_plan(question: str) -> ResearchPlan: - return ResearchPlan( - plan_id="placeholder-plan-001", - research_question=question, - subquestions=["What does the existing literature say?", "What methodological gaps exist?"], - strategy="broad_survey", - search_lanes=[ - { - "query": "placeholder search", - "source": "semantic_scholar", - "purpose": "initial survey", - }, - ], - evidence_needs=["literature review", "methodology assessment"], - budget_allocation={"max_searches": 5, "max_papers_per_search": 10}, - suggested_methodology="systematic review", - ) - - -def _build_placeholder_lit_map(question: str) -> LitMap: - return LitMap( - research_question=question, - supporting=[], - challenging=[], - adjacent=[], - total_found=0, - search_query_used="placeholder_query", - ) - - -def _build_placeholder_citation_audit() -> CitationAudit: - return CitationAudit(claims_checked=0, verified_claims=0) - - -def _build_placeholder_synthesis_report(question: str = "") -> SynthesisReport: - return SynthesisReport( - research_question=question, - method_summary={}, - dataset_summary={}, - metric_summary={}, - corpus_insights={}, - recommended_reading=[], - cross_paper_comparisons=[], - ) - - -def _build_placeholder_critique_result() -> CritiqueResult: - return CritiqueResult( - strengths=[], - weaknesses=[], - gaps=[], - counterarguments=[], - suggestions=[], - methodological_notes=[], - overall_assessment="", - ) - - class ThesisHeadProvider: def __init__(self, unified: UnifiedLLM, compiler: Optional[PromptCompiler] = None): self.unified = unified @@ -212,7 +161,7 @@ async def _execute_planner(self, task: Task, entries: List[BlackboardEntry]) -> if task.research_context else task.description ) - plan = _build_placeholder_research_plan(question) + plan = build_placeholder_research_plan(question) else: plan = _parse_structured_output(response.content, ResearchPlan, _dict_to_research_plan) @@ -244,7 +193,7 @@ async def _execute_supervisor( ) if response.dry_run: - critique = _build_placeholder_critique_result() + critique = build_placeholder_critique_result() else: critique = _parse_structured_output( response.content, CritiqueResult, _dict_to_critique_result @@ -286,7 +235,7 @@ async def execute(self, task: Task, context: Optional[Dict[str, Any]] = None) -> ) if response.dry_run: - lit_map = _build_placeholder_lit_map(question) + lit_map = build_placeholder_lit_map(question) else: lit_map = _parse_structured_output(response.content, LitMap, _dict_to_lit_map) @@ -323,7 +272,7 @@ async def execute(self, task: Task, context: Optional[Dict[str, Any]] = None) -> ) if response.dry_run: - audit = _build_placeholder_citation_audit() + audit = build_placeholder_citation_audit() else: audit = _parse_structured_output( response.content, CitationAudit, _dict_to_citation_audit @@ -365,7 +314,7 @@ async def execute(self, task: Task, context: Optional[Dict[str, Any]] = None) -> if response.dry_run: question = task.research_context.research_question if task.research_context else "" - report = _build_placeholder_synthesis_report(question) + report = build_placeholder_synthesis_report(question) else: report = _parse_structured_output( response.content, SynthesisReport, _dict_to_synthesis_report @@ -404,7 +353,7 @@ async def execute(self, task: Task, context: Optional[Dict[str, Any]] = None) -> ) if response.dry_run: - critique = _build_placeholder_critique_result() + critique = build_placeholder_critique_result() else: critique = _parse_structured_output( response.content, CritiqueResult, _dict_to_critique_result diff --git a/providers/unified.py b/providers/unified.py index 572466f..0d9c61e 100644 --- a/providers/unified.py +++ b/providers/unified.py @@ -1,4 +1,3 @@ -import json import logging import time from dataclasses import dataclass, field @@ -7,6 +6,7 @@ import pybreaker from providers.budget import BudgetExceededError, get_current_guard +from providers.placeholders import generate_placeholder_json from providers.resilience import ( ProviderBreakerRegistry, call_with_resilience, @@ -44,25 +44,6 @@ class LLMResponse: } -def _generate_placeholder_json(schema: Dict[str, Any]) -> str: - props = schema.get("properties", {}) - required = schema.get("required", []) - result: Dict[str, Any] = {} - for key, prop in props.items(): - prop_type = prop.get("type", "string") - if prop_type == "string": - result[key] = "[DRY_RUN]" if key in required else "" - elif prop_type in ("integer", "number"): - result[key] = 0 - elif prop_type == "boolean": - result[key] = False - elif prop_type == "array": - result[key] = [] - elif prop_type == "object": - result[key] = {} - return json.dumps(result) - - def _read_mode_config(mode: str, dry_run: bool = False) -> tuple[str, str]: from core.config import get_settings @@ -164,7 +145,7 @@ async def generate( elapsed = (time.monotonic() - t0) * 1000 content = f"[DRY_RUN mode={mode} provider={preferred_provider} model={preferred_model}] Would call {preferred_provider}/{preferred_model}. Prompt: {prompt[:80]}..." if response_schema: - content = _generate_placeholder_json(response_schema) + content = generate_placeholder_json(response_schema) response = LLMResponse( content=content, provider_used=preferred_provider, diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 081dd03..05994b8 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -7,7 +7,8 @@ from core.evals.harness import EvaluationHarness from core.schemas import CitationAudit, CritiqueResult, LitMap, ResearchPlan, SynthesisReport -from providers.adapters import LLMAdapter, _generate_placeholder_json +from providers.adapters import LLMAdapter +from providers.placeholders import generate_placeholder_json from tools.mcp.engine import ToolRegistry @@ -50,11 +51,11 @@ async def test_adapters_dry_run(monkeypatch): def test_placeholder_json_validates_pydantic_models(): - """Placeholder JSON from _generate_placeholder_json passes Pydantic model_validate_json.""" + """Placeholder JSON from generate_placeholder_json passes Pydantic model_validate_json.""" for model_cls in (ResearchPlan, LitMap, CitationAudit, SynthesisReport, CritiqueResult): schema = model_cls.model_json_schema() schema.pop("title", None) - raw = _generate_placeholder_json(schema) + raw = generate_placeholder_json(schema) parsed = json.loads(raw) assert isinstance(parsed, dict), f"{model_cls.__name__} placeholder is not a JSON object" instance = model_cls.model_validate_json(raw) @@ -107,13 +108,13 @@ def test_structured_output_schema_includes_all_required_fields(): schema.pop("title", None) required = schema.get("required", []) - raw = json.loads(_generate_placeholder_json(schema)) + raw = json.loads(generate_placeholder_json(schema)) for key in required: assert key in raw, f"Required key '{key}' missing from placeholder for CitationAudit" schema2 = LitMap.model_json_schema() schema2.pop("title", None) - raw2 = json.loads(_generate_placeholder_json(schema2)) + raw2 = json.loads(generate_placeholder_json(schema2)) for key in schema2.get("required", []): assert key in raw2, f"Required key '{key}' missing from placeholder for LitMap" From c1e06d79f3367063be518e8ed8ceb005080bf01f Mon Sep 17 00:00:00 2001 From: imer Date: Fri, 15 May 2026 16:08:21 +0200 Subject: [PATCH 4/4] fix(mypy): re-export placeholder builders from providers.thesis_agents Add __all__ so mypy --strict (--no-implicit-reexport) accepts the placeholder builder imports in core/orchestrator/thesis_flow.py. Co-Authored-By: Claude Opus 4.7 --- providers/thesis_agents.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/providers/thesis_agents.py b/providers/thesis_agents.py index 157de19..f43fcc4 100644 --- a/providers/thesis_agents.py +++ b/providers/thesis_agents.py @@ -28,6 +28,20 @@ logger = logging.getLogger(__name__) +__all__ = [ + "CheckerAgent", + "CriticAgent", + "ResearcherAgent", + "SynthesizerAgent", + "ThesisHeadProvider", + "build_placeholder_citation_audit", + "build_placeholder_critique_result", + "build_placeholder_lit_map", + "build_placeholder_research_plan", + "build_placeholder_synthesis_report", +] + + _MODEL_SCHEMAS: Dict[Type[BaseModel], Dict[str, Any]] = {} _SCHEMA_NAMES: Dict[Type[BaseModel], str] = { ResearchPlan: "ResearchPlan",