Skip to content

Commit 23bb552

Browse files
✨ feat(graph,api,mcp): self-facing agentic_search tool for task agents + gpt-oss-120b search default
Expose Agentic Search to Mewbo's own task-spawned engine agents (not just the external MCP), and default every search tier's "Auto" model to GPT OSS 120B. - New `agentic_search` SessionTool (mewbo_graph.plugins.scg.search): async by handle — `query` starts a real scg-search run and returns a run_id + status:"processing" immediately; `run_id` fetches the cited answer + `computed_at`; an identical recent query is idempotently reused. Owns no orchestration (no-parallel-loop invariant) — drives through a new down-only `mewbo_graph.scg.search_launcher.SearchLauncher` seam (mirrors MapPhaseSink). - App registers `RunStoreSearchLauncher` (reuses SearchRun.start + run store) in init_agentic_search; degrades to a structured "unavailable" error when unwired. - `search` is GET-classified → default-allowed → surfaces to any task agent the `scg` capability grants (#84). Direct graph tools (scg_route/observe/memory/ results) already self-available. - MCP `search`/`get_search_run` now surface `computed_at` on the fetch paths. - `ScgTierModelsConfig` fast/auto/deep all default to `openai/gpt-oss-120b` (schema regenerated); the tier is now a budget knob only. Tests: test_search_tool.py + test_search_launcher_impl.py (tool + app backend: start/fetch/idempotent-reuse/workspace-resolution). ruff + mypy clean. Co-authored-by: Mewbo <268600793+mewbo-ai[bot]@users.noreply.github.com> (cherry picked from commit 15494ddd75cfa68d0bdb28c9333e334bdb0bfb10)
1 parent 95586ba commit 23bb552

16 files changed

Lines changed: 792 additions & 29 deletions

File tree

apps/mewbo_api/src/mewbo_api/agentic_search/routes.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,30 @@ def init_agentic_search(
530530

531531
init_agentic_search_graph(api, require_api_key, runtime) # #79 workspace graph
532532
_register_map_phase_sink()
533+
_register_search_launcher()
534+
535+
536+
def _register_search_launcher() -> None:
537+
"""Wire the self-facing agentic-search launcher for the ``scg`` plugin.
538+
539+
The ``agentic_search`` SessionTool (in ``mewbo_graph``) lets a task-spawned
540+
engine agent RUN a search, but the run lifecycle (session + run store) lives
541+
here, up-layer. So — exactly like :func:`_register_map_phase_sink` — the api
542+
injects a concrete backend bound to this run store + the session runtime via
543+
:class:`~mewbo_graph.scg.search_launcher.SearchLauncher`. No-op when SCG is
544+
disabled or the graph library is absent (the tool then degrades to a
545+
structured "unavailable" error).
546+
"""
547+
if not ScgConfig.enabled():
548+
return
549+
try:
550+
from mewbo_graph.scg.search_launcher import SearchLauncher
551+
552+
from .search_launcher_impl import RunStoreSearchLauncher
553+
except ImportError:
554+
return
555+
556+
SearchLauncher.register(RunStoreSearchLauncher(runtime=_runtime))
533557

534558

535559
def _register_map_phase_sink() -> None:

apps/mewbo_api/src/mewbo_api/agentic_search/scg/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ projects a finished session transcript onto the run event log.
2828
**Tiers (Fast / Auto / Deep) are ONE budget knob over the single loop**
2929
decomposition depth + probe-count fan-out (see `scg-search.md`) **and, since
3030
2026-06, the MODEL**: `ScgConfig.model_for_tier(run.tier)` reads
31-
`scg.traversal.tier_models` (defaults fast`openai/gpt-5.4-nano`,
32-
auto→`openai/claude-sonnet-4-6`, deep→`openai/gpt-5.5`) into the drive's
31+
`scg.traversal.tier_models` (defaults fast/auto/deep all →
32+
`openai/gpt-oss-120b`) into the drive's
3333
`model_name`; probes inherit the session model, so the one knob moves the
3434
whole run. Blank/unknown → `llm.default_model`; an explicit request `model`
3535
(`POST /runs` body, riding `RunRecord.model`) wins over the tier map at the
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""Concrete ``SearchLauncher`` — the self-facing async agentic-search backend.
2+
3+
Bridges the down-only ``mewbo_graph.scg.search_launcher.SearchLauncher`` seam to
4+
THIS app's run lifecycle so a task-spawned engine agent's ``agentic_search``
5+
tool call drives a real ``scg-search`` run — reusing :class:`SearchRun.start`
6+
and the run store — WITHOUT the engine importing up into the app. The exact
7+
counterpart of ``_register_map_phase_sink``: the engine can't reach the run
8+
store/runtime, so the api injects a writer here at startup.
9+
10+
Async-by-handle: :meth:`start` hands off to the (orchestrated) runner, which
11+
returns a ``running`` snapshot promptly; we surface the ``run_id`` immediately
12+
so the calling agent never blocks on a multi-minute session. A run that settled
13+
synchronously (the echo runner) — or an idempotent reuse of a recent completed
14+
run for the same question — is returned fully formed. :meth:`fetch` projects the
15+
durable snapshot (cited answer + ``computed_at``).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from dataclasses import dataclass
21+
from typing import Any
22+
23+
from mewbo_core.common import get_logger
24+
25+
from . import store as store_mod
26+
from .runs import SearchRun
27+
from .schemas import TERMINAL_RUN_STATUSES, RunRecord, Workspace
28+
29+
logging = get_logger(name="api.agentic_search.search_launcher_impl")
30+
31+
# Keep the agent-facing result list compact — a task agent wants the cited
32+
# answer + an index it can cite, not the full console payload.
33+
_MAX_RESULTS = 25
34+
35+
36+
@dataclass(frozen=True)
37+
class RunStoreSearchLauncher:
38+
"""A :class:`SearchLauncher` impl over the api run store + session runtime."""
39+
40+
runtime: Any = None
41+
42+
# -- launcher protocol --------------------------------------------------
43+
44+
def start(
45+
self, query: str, *, workspace: str | None = None, tier: str | None = None
46+
) -> dict[str, object]:
47+
"""Resolve the workspace, idempotently reuse or launch a run, return it."""
48+
store = store_mod.get_store()
49+
ws = self._resolve_workspace(store, workspace)
50+
51+
reuse = self._recent_completed(store, ws.id, query)
52+
if reuse is not None:
53+
snap = self._shape(reuse)
54+
snap["reused"] = True
55+
return snap
56+
57+
payload = SearchRun.start(
58+
workspace_id=ws.id,
59+
query=query,
60+
store=store,
61+
runtime=self.runtime,
62+
tier=tier,
63+
source_platform="agent",
64+
)
65+
if payload is None: # pragma: no cover — ws resolved above
66+
raise ValueError(f"workspace '{ws.id}' is no longer available")
67+
68+
# A synchronous (echo) or already-terminal run: return the full snapshot
69+
# so the agent gets its answer in one call. An async run is still
70+
# ``running`` — hand back the resumable handle.
71+
if payload.status in TERMINAL_RUN_STATUSES:
72+
record = store.get_run(payload.run_id)
73+
if record is not None:
74+
return self._shape(record)
75+
return {
76+
"run_id": payload.run_id,
77+
"session_id": payload.session_id,
78+
"workspace_id": ws.id,
79+
"workspace": ws.name,
80+
"query": query,
81+
"tier": payload.tier,
82+
"status": "processing" if payload.status == "running" else payload.status,
83+
"note": (
84+
"The search is running in its own session. Call agentic_search "
85+
"again with this run_id to retrieve the cited answer when ready."
86+
),
87+
}
88+
89+
def fetch(self, run_id: str) -> dict[str, object] | None:
90+
"""Return the run's last-known snapshot, or ``None`` if unknown."""
91+
record = store_mod.get_store().get_run(run_id)
92+
if record is None:
93+
return None
94+
return self._shape(record)
95+
96+
# -- internals ----------------------------------------------------------
97+
98+
@staticmethod
99+
def _resolve_workspace(store: Any, ref: str | None) -> Workspace:
100+
"""Resolve *ref* (id or unique name) → a workspace; raise with guidance.
101+
102+
Mirrors the MCP ``_resolve_workspace`` ergonomics so the self and
103+
outside surfaces resolve identically: exact id first, then a unique
104+
case-insensitive name. With no ref, default to the only workspace; raise
105+
a list-bearing error when zero or several exist so the agent can pick.
106+
"""
107+
workspaces = store.list_workspaces()
108+
if not workspaces:
109+
raise ValueError("no search workspaces are configured")
110+
if not ref:
111+
if len(workspaces) == 1:
112+
return workspaces[0]
113+
names = sorted(w.name for w in workspaces)
114+
raise ValueError(
115+
"several workspaces exist — pass 'workspace' (id or name). "
116+
f"Available: {names}"
117+
)
118+
for ws in workspaces:
119+
if ws.id == ref:
120+
return ws
121+
matches = [w for w in workspaces if w.name.lower() == ref.lower()]
122+
if len(matches) == 1:
123+
return matches[0]
124+
names = sorted(w.name for w in workspaces)
125+
if not matches:
126+
raise ValueError(f"no workspace matches '{ref}'. Available: {names}")
127+
raise ValueError(f"workspace name '{ref}' is ambiguous — use its id. Names: {names}")
128+
129+
@staticmethod
130+
def _recent_completed(
131+
store: Any, workspace_id: str, query: str
132+
) -> RunRecord | None:
133+
"""The most recent COMPLETED run for the exact same query, or ``None``.
134+
135+
Gives the "re-invoke the same query → last-known answer" idempotency:
136+
an identical question returns its prior cited answer (+ ``computed_at``)
137+
instead of launching a duplicate session. Exact-match on the query text
138+
only (a different question, or a not-yet-completed run, launches anew).
139+
"""
140+
runs = store.list_runs(workspace_id)
141+
best: RunRecord | None = None
142+
for run in runs:
143+
if run.status != "completed" or run.query != query:
144+
continue
145+
if best is None or (run.created_at or "") > (best.created_at or ""):
146+
best = run
147+
return best
148+
149+
@classmethod
150+
def _shape(cls, record: RunRecord) -> dict[str, object]:
151+
"""Project a :class:`RunRecord` into the compact agent-facing snapshot.
152+
153+
The cited synthesis + a compact result index (so citations resolve) +
154+
``computed_at`` (when the answer was calculated) — never the per-source
155+
trace or decorative fields.
156+
"""
157+
payload = record.payload
158+
answer = payload.answer if payload is not None else None
159+
results = payload.results[:_MAX_RESULTS] if payload is not None else []
160+
status = "processing" if record.status == "running" else record.status
161+
out: dict[str, object] = {
162+
"run_id": record.run_id,
163+
"session_id": record.session_id,
164+
"workspace_id": record.workspace_id,
165+
"query": record.query,
166+
"tier": record.tier,
167+
"status": status,
168+
"total_ms": record.total_ms,
169+
# When the answer was calculated — None while still processing.
170+
"computed_at": record.completed_at,
171+
"results": [
172+
{
173+
"id": r.id,
174+
"source": r.source,
175+
"kind": r.kind,
176+
"title": r.title,
177+
"url": r.url,
178+
"relevance": r.relevance,
179+
}
180+
for r in results
181+
],
182+
}
183+
if answer is not None:
184+
out["answer"] = {
185+
"tldr": answer.tldr,
186+
"bullets": [
187+
{"text": b.text, "cites": list(b.cites)} for b in answer.bullets
188+
],
189+
"confidence": answer.confidence,
190+
"sources_count": answer.sources_count,
191+
}
192+
if payload is not None and payload.related_questions:
193+
out["related_questions"] = list(payload.related_questions)
194+
if payload is not None and payload.error:
195+
out["error"] = payload.error
196+
return out
197+
198+
199+
__all__ = ["RunStoreSearchLauncher"]

apps/mewbo_mcp/src/mewbo_mcp/tools.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,7 +1202,11 @@ def _shape(record: dict[str, Any], terminal: bool) -> dict[str, Any]:
12021202
rec_status = str(rec.get("status") or "running")
12031203
payload = _as_dict(rec.get("payload"))
12041204
payload.setdefault("run_id", run_id)
1205-
return self._shape_run(payload, rec_status, ws_name, detail)
1205+
shaped = self._shape_run(payload, rec_status, ws_name, detail)
1206+
# When the answer was calculated (None while still running) — so a
1207+
# caller re-fetching a run knows how fresh the answer is.
1208+
shaped["computed_at"] = rec.get("completed_at")
1209+
return shaped
12061210

12071211
return await poll_or_handle(
12081212
run_id,
@@ -1224,7 +1228,10 @@ async def get_run(self, *, run_id: str, detail: str = "answer") -> dict[str, Any
12241228
self._check_detail(detail)
12251229
record = await self._load_record(run_id)
12261230
status = str(record.get("status") or "running")
1227-
return self._shape_run(_as_dict(record.get("payload")), status, None, detail)
1231+
shaped = self._shape_run(_as_dict(record.get("payload")), status, None, detail)
1232+
# When the answer was calculated (None while still running).
1233+
shaped["computed_at"] = record.get("completed_at")
1234+
return shaped
12281235

12291236
# -- internals: behavior over the atomic state ------------------------
12301237

configs/app.schema.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -968,20 +968,20 @@
968968
"description": "Per-tier model mapping \u2014 the tier picks the brain, not just the budget.\n\nA tier maps to the LLM that drives the whole run (orchestrator session AND\nits probe sub-agents, which inherit the session model). An empty string\nfalls back to ``llm.default_model``. An explicit per-request ``model``\noverride (where the endpoint offers one) always wins over the tier map.",
969969
"properties": {
970970
"fast": {
971-
"default": "openai/gpt-5.4-nano",
972-
"description": "Model for `fast` tier runs (cheap, low-latency).",
971+
"default": "openai/gpt-oss-120b",
972+
"description": "Model for `fast` tier runs (the tier still sets the low-latency budget).",
973973
"title": "Fast",
974974
"type": "string"
975975
},
976976
"auto": {
977-
"default": "openai/claude-sonnet-4-6",
978-
"description": "Model for `auto` tier runs (balanced default).",
977+
"default": "openai/gpt-oss-120b",
978+
"description": "Model for `auto` tier runs (the tier still sets the balanced budget).",
979979
"title": "Auto",
980980
"type": "string"
981981
},
982982
"deep": {
983-
"default": "openai/gpt-5.5",
984-
"description": "Model for `deep` tier runs (exhaustive research).",
983+
"default": "openai/gpt-oss-120b",
984+
"description": "Model for `deep` tier runs (the tier still sets the exhaustive budget).",
985985
"title": "Deep",
986986
"type": "string"
987987
}

docs/features-search.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,9 @@ Every search runs at one of three tiers, selectable per query. The tier is the r
195195

196196
| Tier | Sub-query decomposition | Probe fan-out | Default model | Best for |
197197
|---|---|---|---|---|
198-
| **Fast** | 1 | 2 | `openai/gpt-5.4-nano` | Quick lookups; known-answer retrieval |
199-
| **Auto** (default) | 2–3 | 3 | `openai/claude-sonnet-4-6` | General multi-source questions |
200-
| **Deep** | 3–5 | 5 | `openai/gpt-5.5` | Exhaustive research; cross-source synthesis |
198+
| **Fast** | 1 | 2 | `openai/gpt-oss-120b` | Quick lookups; known-answer retrieval |
199+
| **Auto** (default) | 2–3 | 3 | `openai/gpt-oss-120b` | General multi-source questions |
200+
| **Deep** | 3–5 | 5 | `openai/gpt-oss-120b` | Exhaustive research; cross-source synthesis |
201201

202202
The model mapping lives at `scg.traversal.tier_models` (keys `fast`, `auto`, `deep`) and is editable in Settings like any other config key. Probe agents inherit the session model, so one tier choice moves the whole run, coordinator and probes alike. A blank mapping or an unrecognised tier falls back to `llm.default_model`, never an error. Where a request offers an explicit `model` override, it wins over the tier map.
203203

packages/mewbo_core/src/mewbo_core/config.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1747,16 +1747,16 @@ class ScgTierModelsConfig(BaseModel):
17471747
model_config = ConfigDict(extra="forbid", json_schema_extra={"title": "Tier models"})
17481748

17491749
fast: str = Field(
1750-
"openai/gpt-5.4-nano",
1751-
description="Model for `fast` tier runs (cheap, low-latency).",
1750+
"openai/gpt-oss-120b",
1751+
description="Model for `fast` tier runs (the tier still sets the low-latency budget).",
17521752
)
17531753
auto: str = Field(
1754-
"openai/claude-sonnet-4-6",
1755-
description="Model for `auto` tier runs (balanced default).",
1754+
"openai/gpt-oss-120b",
1755+
description="Model for `auto` tier runs (the tier still sets the balanced budget).",
17561756
)
17571757
deep: str = Field(
1758-
"openai/gpt-5.5",
1759-
description="Model for `deep` tier runs (exhaustive research).",
1758+
"openai/gpt-oss-120b",
1759+
description="Model for `deep` tier runs (the tier still sets the exhaustive budget).",
17601760
)
17611761

17621762

packages/mewbo_graph/CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,15 @@ plugins import **down**. Don't reintroduce the reach-ups:
7575
structure write already happened, the phase is purely cosmetic. This DI is
7676
the asymmetry vs the wiki `emit_phase`, which writes its *own* (relocated)
7777
store directly.
78+
- **`SearchLauncher`** (`scg/search_launcher.py`): the SAME inversion for the
79+
self-facing `agentic_search` SessionTool. A task-spawned engine agent that
80+
RUNS a search needs the full run lifecycle (its own `scg-search` session, the
81+
run store) — all up-layer in the API. So the API registers a concrete
82+
launcher (`RunStoreSearchLauncher`, bound to the run store + session runtime,
83+
reusing `SearchRun.start`) and the tool drives through it. Async-by-handle:
84+
`start()` returns an idempotent `run_id` immediately (a search runs for
85+
minutes); `fetch(run_id)` reads the cited-answer snapshot + `computed_at`. No
86+
launcher registered → the tool degrades to a structured "unavailable" error.
7887
- **`register_builtin_root`** (in `mewbo_core.plugins`): importing this package
7988
**pushes** its `plugins/` root to the core loader
8089
(`mewbo_graph.register_builtin_plugins`, fired on import). Core never imports

packages/mewbo_graph/src/mewbo_graph/plugins/scg/.claude-plugin/plugin.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@
4545
"module": "mewbo_graph.plugins.scg.results",
4646
"class": "ScgResultsTool"
4747
},
48+
{
49+
"tool_id": "agentic_search",
50+
"module": "mewbo_graph.plugins.scg.search",
51+
"class": "AgenticSearchTool"
52+
},
4853
{
4954
"tool_id": "mint_entity",
5055
"module": "mewbo_graph.plugins.wiki.mint_entity",

packages/mewbo_graph/src/mewbo_graph/plugins/scg/CLAUDE.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,27 @@
33
# scg built-in plugin — SCG map + search tools
44

55
Scope: `packages/mewbo_graph/src/mewbo_graph/plugins/scg/`. The
6-
deterministic SCG logic does **not** live here — these eight SessionTools
6+
deterministic SCG logic does **not** live here — these SessionTools
77
(`scg_introspect_source`, `scg_build_structure`, `scg_link_entities`,
8-
`scg_finalize_map`, `scg_route`, `scg_observe`, `scg_memory`, `scg_results`)
9-
are **thin wrappers** over the SCG core, which now lives **down** in the same
10-
library at `mewbo_graph.scg` (same package, imported DOWN — no longer a one-way
11-
boundary UP into an app). `scg_results` (#95/#102) is the thinnest of all —
8+
`scg_finalize_map`, `scg_route`, `scg_observe`, `scg_memory`, `scg_results`,
9+
`agentic_search`) are **thin wrappers** over the SCG core, which now lives
10+
**down** in the same library at `mewbo_graph.scg` (same package, imported DOWN —
11+
no longer a one-way boundary UP into an app).
12+
13+
**`agentic_search` — the high-level self-facing search verb (vs the low-level
14+
graph tools).** Where `scg_route`/`scg_observe` let a task agent inspect
15+
reachability directly, `agentic_search` runs a WHOLE `scg-search` session and
16+
hands back a cited answer. It is **async-by-handle**: `query` starts a run and
17+
returns a `run_id` + `status:"processing"` IMMEDIATELY (a search runs for
18+
minutes — never block the caller's loop); re-call with `run_id` to fetch the
19+
cited answer + `computed_at`; an identical recent query is idempotently reused.
20+
The tool owns NO orchestration (the no-parallel-loop invariant holds) — it drives
21+
the run through the down-only `mewbo_graph.scg.search_launcher.SearchLauncher`
22+
seam the API registers (`RunStoreSearchLauncher`, reusing `SearchRun.start` + the
23+
run store), degrading to a structured "unavailable" error when none is wired.
24+
The id is `search`-classified in core `_infer_operation` → default-allowed, so it
25+
surfaces to any ordinary task agent the `scg` capability grants (#84) — the same
26+
async run/poll shape the external MCP `search`/`get_search_run` tools expose. `scg_results` (#95/#102) is the thinnest of all —
1227
**transcript-as-transport**: it only VALIDATES the search-result entries
1328
(≤50, `extra="forbid"`, relevance/confidence 0..1) and returns `{ok, count}`;
1429
it writes nothing (no store, no sink — the api projects the validated

0 commit comments

Comments
 (0)