Skip to content

Commit 477bac1

Browse files
authored
Feature/openhands (#316)
* WebSettings * WebChanges * Add openhands sandbox tool * feat: implement tool activity plugin to stream nested sub-agent tool calls to the web UI * feat: implement usage metrics tracking with session-level cost reporting and planner configuration controls * docs: add tool gating documentation and comprehensive usage/cost metrics guide, and update task tracker logic to enforce linear execution order and dependency resolution. * feat: implement AgentOutputPlugin to surface delegated agent answers in chat and refine task tracker visibility rules * refactor: serialize hypothesis verification by adding status-based queuing and a postponed backlog mechanism to prevent parallel execution. * refactor: add optional roster support to critic prompts and change downloads page * test: exclude HITL tools from prompt validation and update orchestrator-specific instruction checks * feat: integrate CoderSandbox UI with dynamic status updates and update default root agent to PlanningPipelineAgent * feat: enable configurable parallel hypothesis verification via new maxActiveHypotheses setting * feat: add orchestrator_planner mode to allow OrchestratorAgent to use planning tools directly without PlannerAgent * feat(web): add LiteLLM selective proxy, session import/export, and configurable coder modes - Implemented LiteLLMProxy in `CoScientist/utils/selective_proxy.py` to route LLM and MCP traffic through forward proxy without closing shared httpx client sessions. - Added session export and import functionality (`.cossession.zip`) in `CoScientist/web/session_bundle.py` with snapshot management. - Refactored `coder_local_tools_enabled` to `coder_mode` ("local" | "openhands") across settings, assembly bindings, and unit tests. - Updated Orchestrator prompt instructions to enforce sequential CoderAgent execution and avoid full source file dumps. - Made HITL auto-approve timeout configurable (`hitl_auto_approve_timeout`). * Merge remote-tracking branch 'origin/main' into feature/openhands * feat: implement SequentialAgent pipeline for research lifecycle and rename Context Init to Research Frame. * drop duplicate * refactor: replace CONTEXT_INIT__ENABLED with RESEARCH_FRAME environment variable for configuration management * refactor: standardize ADK agent tracing by caching run_root and updating Opik integration configuration logic.
1 parent 965b68c commit 477bac1

69 files changed

Lines changed: 12528 additions & 740 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ ChemCoScientist/tests/integration/parse_results
188188
ChemCoScientist/tests/integration/data/.last_activity
189189
*db.json
190190

191-
roadmap.txt
191+
session_snapshots/
192192
workspace/
193193
# Execution-graph run snapshots
194194
graph_runs/

CoScientist/a2a/server.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,16 @@ def _attach_opik_tracer(agent: BaseAgent, app_name: str) -> None:
5353
from opik.integrations.adk import OpikTracer, track_adk_agent_recursive
5454

5555
from CoScientist.config import get_settings
56+
from CoScientist.logging.opik_tracer import get_multi_agent_tracer
5657

5758
settings = get_settings()
59+
# Ensure tracer env / proxy setup is initialized
60+
get_multi_agent_tracer()
61+
project_name = settings.opik.opik_project_name or "adk-coscientist"
5862
tracer = OpikTracer(
5963
name=f"a2a-{app_name}",
6064
metadata=_redact(settings.model_dump()),
61-
project_name="adk-coscientist",
65+
project_name=project_name,
6266
)
6367
track_adk_agent_recursive(agent, tracer)
6468
except Exception as exc: # never let tracing break the server
@@ -119,6 +123,7 @@ def make_a2a_app(
119123
"""
120124
_attach_opik_tracer(agent, app_name)
121125
from CoScientist.logging.event_logger import EventLoggerPlugin
126+
from CoScientist.logging.metrics import UsageMetricsPlugin
122127
from CoScientist.graph.emitter import GraphEmitterPlugin
123128
from CoScientist.agents.truncation_plugin import ToolResultTruncationPlugin
124129

@@ -128,7 +133,12 @@ def make_a2a_app(
128133
session_service=session_service or InMemorySessionService(),
129134
artifact_service=InMemoryArtifactService(),
130135
# truncation MUST be last (ADK early-exits on first non-None after_tool).
131-
plugins=[EventLoggerPlugin(), GraphEmitterPlugin(), ToolResultTruncationPlugin()],
136+
plugins=[
137+
EventLoggerPlugin(),
138+
UsageMetricsPlugin(),
139+
GraphEmitterPlugin(),
140+
ToolResultTruncationPlugin(),
141+
],
132142
)
133143
executor = A2aAgentExecutor(runner=runner)
134144
handler = DefaultRequestHandler(

CoScientist/agent.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from google.adk.apps import App
1313

1414
from CoScientist.logging.event_logger import EventLoggerPlugin
15+
from CoScientist.logging.metrics import UsageMetricsPlugin
1516
from CoScientist.graph.plugin import GraphMemoryPlugin
1617
from CoScientist.graph.research.validator import BackgroundValidatorPlugin
1718
from CoScientist.agents.truncation_plugin import ToolResultTruncationPlugin
@@ -31,6 +32,7 @@
3132
root_agent=root_agent,
3233
plugins=[
3334
EventLoggerPlugin(),
35+
UsageMetricsPlugin(),
3436
GraphMemoryPlugin(),
3537
BackgroundValidatorPlugin(),
3638
ToolResultTruncationPlugin(),

CoScientist/agents/__init__.py

Lines changed: 121 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@
55
system once and re-exports the agent instances under their historical names so
66
existing imports keep working.
77
"""
8+
import copy
9+
import logging
10+
811
from CoScientist.assembly import build_system
9-
from CoScientist.logging import multi_agent_tracer
12+
from CoScientist.assembly.schema import load_config
13+
from CoScientist.logging import get_multi_agent_tracer
1014
from CoScientist.agents.llm_repair import install_json_repair
15+
from opik.integrations.adk import track_adk_agent_recursive
16+
17+
logger = logging.getLogger(__name__)
1118

1219
# Guard the LiteLlm tool-call JSON boundary process-wide BEFORE any runner executes:
1320
# a malformed tool-call payload (qwen truncation / missing comma) must not kill the run.
@@ -32,24 +39,15 @@
3239
root_agent = orchestrator_agent
3340

3441
# Agents that run as pipeline stages (pre/post) around the orchestrator.
35-
pipeline_pre_agents = [_system.agent(n) for n in _system.config.pipeline.pre]
36-
pipeline_post_agents = [_system.agent(n) for n in _system.config.pipeline.post]
42+
pipeline_pre_agents = [_system.agent(n) for n in _system.config.pipeline.pre if _system.config.agent(n).is_enabled()]
43+
pipeline_post_agents = [_system.agent(n) for n in _system.config.pipeline.post if _system.config.agent(n).is_enabled()]
3744

3845
# The RUN root: the whole lifecycle (pre → orchestrator → post/aggregator) is one
3946
# ADK SequentialAgent, driven by a single Runner.run_async so it is ONE invocation
4047
# = ONE trace, with the Result Aggregator as the terminal child (it reads the graph
4148
# the orchestrator populated and writes the report). When no pipeline stages are
4249
# declared, the orchestrator IS the run root (no needless wrapper).
43-
if pipeline_pre_agents or pipeline_post_agents:
44-
from google.adk.agents.sequential_agent import SequentialAgent
45-
46-
run_root = SequentialAgent(
47-
name="ResearchPipeline",
48-
description="Full research lifecycle: orchestrator run then report synthesis.",
49-
sub_agents=[*pipeline_pre_agents, orchestrator_agent, *pipeline_post_agents],
50-
)
51-
else:
52-
run_root = orchestrator_agent
50+
run_root = _system.run_root
5351

5452
planner_agent = _system.agents.get("PlannerAgent")
5553
hypotheses_agent = _system.agents.get("HypothesesAgent")
@@ -66,12 +64,116 @@
6664
tz_agent = _system.agents.get("TZAgent")
6765

6866
# Attach the Opik tracer only when tracing is enabled (see OPIK__ENABLED).
69-
# Tracking the run root covers the whole SequentialAgent (orchestrator + pipeline
70-
# stages) so the entire lifecycle lands in ONE trace.
71-
if multi_agent_tracer is not None:
72-
from opik.integrations.adk import track_adk_agent_recursive
67+
_tracer = get_multi_agent_tracer()
68+
if _tracer is not None:
69+
track_adk_agent_recursive(run_root, _tracer)
70+
71+
72+
def build_for_mode():
73+
"""Build an AgentSystem configured for the current start mode from settings.
74+
75+
Reads ``settings.web.start_mode``:
76+
* ``"planner"`` — PlanningPipelineAgent is root (sequential: PlannerAgent →
77+
OrchestratorAgent).
78+
* ``"orchestrator"`` — OrchestratorAgent is root, with PlannerAgent
79+
added to its subordinates so it can be invoked on demand.
80+
* ``"orchestrator_planner"`` — OrchestratorAgent is root, provided with
81+
create_plan_tool directly, while PlannerAgent is disabled.
82+
83+
Other runtime-tunable parameters (e.g. ``max_searches``) are read from
84+
``settings.web`` by individual components at build time.
85+
86+
Returns:
87+
An :class:`~CoScientist.assembly.assembler.AgentSystem`.
88+
"""
89+
from CoScientist.config import get_settings
90+
start_mode = get_settings().web.start_mode
91+
92+
if start_mode in ("planner"):
93+
raw_config = load_config()
94+
patched = copy.deepcopy(raw_config)
95+
pipeline_agent_name = "PlanningPipelineAgent" if "PlanningPipelineAgent" in patched.agents else "InitAgent"
96+
if pipeline_agent_name in patched.agents:
97+
patched.agents[pipeline_agent_name].root = True
98+
patched.agents[pipeline_agent_name].enabled = True
99+
patched.agents["OrchestratorAgent"].root = False
100+
# In Planner mode the PlannerAgent runs first and its output replaces
101+
# the original user query; inject_original_query restores it so the
102+
# OrchestratorAgent sees the original request.
103+
orch_cb = patched.agents["OrchestratorAgent"].callbacks.before_model
104+
if "inject_original_query" not in orch_cb:
105+
orch_cb.append("inject_original_query")
106+
system = build_system(config=patched)
107+
else:
108+
logger.warning(
109+
"start_mode is set to %r but 'PlanningPipelineAgent' is not present in "
110+
"the system config; falling back to default build_system()",
111+
start_mode,
112+
)
113+
system = build_system()
114+
_tracer = get_multi_agent_tracer()
115+
if _tracer is not None:
116+
track_adk_agent_recursive(system.run_root, _tracer)
117+
return system
118+
119+
if start_mode in ("orchestrator_planner", "orchestrator_plan"):
120+
raw_config = load_config()
121+
patched = copy.deepcopy(raw_config)
122+
123+
# Make OrchestratorAgent the root.
124+
patched.agents["OrchestratorAgent"].root = True
125+
for name in ("PlanningPipelineAgent", "InitAgent"):
126+
if name in patched.agents:
127+
patched.agents[name].root = False
128+
patched.agents[name].enabled = False
129+
130+
# Disable PlannerAgent and remove from Orchestrator's subordinates.
131+
if "PlannerAgent" in patched.agents:
132+
patched.agents["PlannerAgent"].root = False
133+
patched.agents["PlannerAgent"].enabled = False
134+
135+
orch_subs = patched.agents["OrchestratorAgent"].subordinates
136+
if "PlannerAgent" in orch_subs:
137+
orch_subs.remove("PlannerAgent")
138+
139+
# Give OrchestratorAgent the tool for creating/registering plans directly.
140+
orch_tools = patched.agents["OrchestratorAgent"].tools
141+
if "create_plan_tool" not in orch_tools:
142+
orch_tools.append("create_plan_tool")
143+
144+
system = build_system(config=patched)
145+
_tracer = get_multi_agent_tracer()
146+
if _tracer is not None:
147+
track_adk_agent_recursive(system.run_root, _tracer)
148+
return system
149+
150+
if start_mode != "orchestrator":
151+
raise ValueError(
152+
f"Unknown start_mode {start_mode!r}; expected 'planner', 'orchestrator', or 'orchestrator_planner'"
153+
)
154+
155+
# Load a fresh config and patch it for orchestrator-as-root mode.
156+
raw_config = load_config()
157+
patched = copy.deepcopy(raw_config)
158+
159+
# Make OrchestratorAgent the root.
160+
patched.agents["OrchestratorAgent"].root = True
161+
for name in ("PlanningPipelineAgent", "InitAgent"):
162+
if name in patched.agents:
163+
patched.agents[name].root = False
164+
patched.agents[name].enabled = False
165+
166+
# Add PlannerAgent to OrchestratorAgent's subordinates (if not already).
167+
orch_subs = patched.agents["OrchestratorAgent"].subordinates
168+
if "PlannerAgent" not in orch_subs:
169+
orch_subs.insert(0, "PlannerAgent")
73170

74-
track_adk_agent_recursive(run_root, multi_agent_tracer)
171+
# Re-validate the patched config and build.
172+
system = build_system(config=patched)
173+
_tracer = get_multi_agent_tracer()
174+
if _tracer is not None:
175+
track_adk_agent_recursive(system.run_root, _tracer)
176+
return system
75177

76178
__all__ = [
77179
"agent_system",
@@ -93,4 +195,5 @@
93195
"pipeline_pre_agents",
94196
"pipeline_post_agents",
95197
"tz_agent",
198+
"build_for_mode",
96199
]

CoScientist/agents/callbacks/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<name>` regardless of which submodule defines it.
55
"""
66
from CoScientist.agents.callbacks.critic import (
7+
make_plan_critique,
78
make_post_action_critique,
89
make_pre_action_critique,
910
)
@@ -23,15 +24,19 @@
2324
before_get_task,
2425
before_tool_reranker_model,
2526
capture_mcp_artifacts,
27+
inject_dataset_context,
2628
inject_graph_root,
29+
make_plan_registration_guard,
2730
make_unknown_tool_guard,
31+
inject_original_query,
2832
print_research_agent_tool_call,
2933
redirect_when_no_tools,
3034
)
3135

3236
__all__ = [
3337
"make_pre_action_critique",
3438
"make_post_action_critique",
39+
"make_plan_critique",
3540
"before_model_modifier",
3641
"med_agent_before_model",
3742
"papers_agent_before_model",
@@ -44,7 +49,10 @@
4449
"capture_mcp_artifacts",
4550
"redirect_when_no_tools",
4651
"make_unknown_tool_guard",
52+
"make_plan_registration_guard",
53+
"inject_original_query",
4754
"before_get_task",
4855
"inject_graph_root",
56+
"inject_dataset_context",
4957
"sanitize_json_output",
5058
]

CoScientist/agents/callbacks/critic.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,19 @@
1919
annotates it with a `_critic` directive when the result is
2020
insufficient or wrong, leaving the original payload intact.
2121
22-
Both critics are themselves LLM calls returning strict JSON. They are
22+
A third factory is wired onto the PLANNER instead, independently of the two
23+
above (system.yaml -> ``PlannerAgent.critic``):
24+
25+
* `make_plan_critique(instruction)` -> SessionAgent.plan_critic
26+
Reviews the roadmap the planner just registered, BEFORE it is accepted
27+
(and before the human sees it). Returns feedback text to send back for
28+
one rewrite, or None to accept the plan. The revision budget lives in
29+
the SessionAgent (`critic_max_rounds`, default 1), not here.
30+
31+
All three critics are themselves LLM calls returning strict JSON. They are
2332
factories (not module-level callbacks) because their system prompts embed the
24-
orchestrator's CURRENT roster — the assembler renders the prompt from the same
25-
config that wires the sub-agents and passes it in.
33+
CURRENT roster — the assembler renders the prompt from the same config that
34+
wires the agents and passes it in.
2635
"""
2736

2837
from __future__ import annotations
@@ -64,6 +73,11 @@ class PostVerdict(str, Enum):
6473
WRONG = "wrong"
6574

6675

76+
class PlanVerdict(str, Enum):
77+
APPROVE = "approve"
78+
REVISE = "revise"
79+
80+
6781
# ---------------------------------------------------------------------------
6882
# Trajectory parsing (from session history on the callback context)
6983
# ---------------------------------------------------------------------------
@@ -225,6 +239,10 @@ async def _invoke_critic_llm(system_prompt: str, user_prompt: str) -> Dict[str,
225239
response_format={"type": "json_object"},
226240
temperature=0.0,
227241
)
242+
# The critic bypasses the agent tree, so no model callback prices it —
243+
# but it runs on every orchestrator turn and is not free.
244+
from CoScientist.logging.metrics import record_completion
245+
record_completion(resp, model=_CRITIC_MODEL, agent="Critic")
228246
raw = resp["choices"][0]["message"]["content"]
229247
return json.loads(raw)
230248
except Exception as e:
@@ -479,4 +497,46 @@ async def post_action_critique(
479497

480498
return None
481499

482-
return post_action_critique
500+
return post_action_critique
501+
502+
503+
# ---------------------------------------------------------------------------
504+
# Plan critic (SessionAgent.plan_critic — the PLANNER's own critic)
505+
# ---------------------------------------------------------------------------
506+
def make_plan_critique(instruction: str) -> Callable:
507+
"""Build the planner's plan critic with the given critic prompt.
508+
509+
Not an ADK callback: an after_model/after_agent callback can only rewrite
510+
or replace an output, and a plan critique is worthless unless the PLANNER
511+
itself redoes the roadmap. The returned coroutine is handed to the
512+
SessionAgent, which owns the generate → review → revise loop and caps it
513+
(`critic_max_rounds`, default 1 — the critic gets one say).
514+
515+
Contract: ``await plan_critique(task, plan) -> feedback | None``. A None
516+
(approve, empty feedback, or a failed LLM call) accepts the plan as-is.
517+
"""
518+
519+
@track(name="plan_critique")
520+
async def plan_critique(task: str, plan: str) -> Optional[str]:
521+
user_prompt = (
522+
f"ORIGINAL TASK:\n{task or '(not available)'}\n\n"
523+
f"PROPOSED PLAN (as registered, in execution order):\n{_truncate(plan, 6000)}\n\n"
524+
"Decide whether to approve this plan or send it back for its one "
525+
"revision. Respond as strict JSON."
526+
)
527+
528+
print(f"plan critic invoked with such prompt: {user_prompt}")
529+
530+
payload = await _invoke_critic_llm(instruction, user_prompt)
531+
verdict_raw = (payload.get("verdict") or "approve").lower().strip()
532+
feedback = (payload.get("feedback") or "").strip()
533+
534+
print(f"plan critic returned: {payload}")
535+
536+
# Anything but an explicit, substantiated "revise" accepts the plan:
537+
# a revision round the critic cannot justify only costs a rewrite.
538+
if verdict_raw != PlanVerdict.REVISE.value or not feedback:
539+
return None
540+
return feedback
541+
542+
return plan_critique

0 commit comments

Comments
 (0)