Skip to content

Commit 6212d0b

Browse files
tbitcsoz-agent
andcommitted
feat: add --json-events mode to specsmith run, remove serve stub
- Remove dead \specsmith serve\ stub command (was placeholder for issue #14) - Add --json-events flag to \specsmith run\: emits JSONL events over stdout for IDE clients (VS Code extension bridge) - Event types: ready, llm_chunk, tool_started, tool_finished, tokens, turn_done, error, system - In json_events mode: stdin is used for user messages, stdout is pure JSON, streaming disabled, prompt string suppressed - AgentRunner gains json_events param and _emit_event() method Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent eef3e2e commit 6212d0b

2 files changed

Lines changed: 79 additions & 40 deletions

File tree

src/specsmith/agent/runner.py

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -184,13 +184,15 @@ def __init__(
184184
max_tool_iterations: int = 10,
185185
optimize: bool = False,
186186
optimization_config: OptimizationConfig | None = None,
187+
json_events: bool = False,
187188
) -> None:
188189
self.project_dir = str(Path(project_dir).resolve())
189190
self._provider_name = provider_name
190191
self._model = model
191192
self._tier = tier
192193
self._stream = stream
193194
self._max_iterations = max_tool_iterations
195+
self._json_events = json_events
194196

195197
self._provider: Any = None
196198
self._state = SessionState(project_dir=self.project_dir)
@@ -291,14 +293,26 @@ def _agent_turn(self, user_input: str, silent: bool = False) -> str:
291293
except Exception as e: # noqa: BLE001
292294
error_msg = f"[Provider error] {e}"
293295
if not silent:
294-
self._print(error_msg)
296+
if self._json_events:
297+
self._emit_event(type="error", message=error_msg)
298+
else:
299+
self._print(error_msg)
295300
return error_msg
296301

297302
# Update credit tracking
298303
self._state.total_input_tokens += response.input_tokens
299304
self._state.total_output_tokens += response.output_tokens
300305
self._state.total_cost_usd += response.estimated_cost_usd
301306

307+
# Emit token update event
308+
if self._json_events and not silent:
309+
self._emit_event(
310+
type="tokens",
311+
in_tokens=self._state.total_input_tokens,
312+
out_tokens=self._state.total_output_tokens,
313+
cost_usd=self._state.total_cost_usd,
314+
)
315+
302316
if response.content:
303317
final_response = response.content
304318

@@ -330,6 +344,14 @@ def _agent_turn(self, user_input: str, silent: bool = False) -> str:
330344
)
331345
)
332346

347+
# Emit turn-done event
348+
if self._json_events and not silent:
349+
self._emit_event(
350+
type="turn_done",
351+
total_tokens=self._state.session_tokens,
352+
cost_usd=self._state.total_cost_usd,
353+
)
354+
333355
return final_response
334356

335357
def _call_provider(self, messages: list[Message], silent: bool = False) -> CompletionResponse:
@@ -362,7 +384,7 @@ def _call_provider(self, messages: list[Message], silent: bool = False) -> Compl
362384
messages = hint.messages
363385
tools = hint.tools
364386

365-
use_stream = self._stream and not silent and not tools
387+
use_stream = self._stream and not silent and not tools and not self._json_events
366388
if use_stream:
367389
accumulated = ""
368390
for token in provider.stream(messages, tools=None):
@@ -375,7 +397,10 @@ def _call_provider(self, messages: list[Message], silent: bool = False) -> Compl
375397
else:
376398
response = cast(CompletionResponse, provider.complete(messages, tools=tools))
377399
if not silent and response.content:
378-
self._print(response.content)
400+
if self._json_events:
401+
self._emit_event(type="llm_chunk", text=response.content)
402+
else:
403+
self._print(response.content)
379404

380405
# ── Optimization post-call ───────────────────────────────────────────
381406
if self._optimizer is not None and hint is not None:
@@ -405,7 +430,10 @@ def _execute_tool_calls(
405430
inputs = tc.get("input", {})
406431

407432
if not silent:
408-
self._print(f"\n[Tool: {name}]")
433+
if self._json_events:
434+
self._emit_event(type="tool_started", name=name, args=inputs)
435+
else:
436+
self._print(f"\n[Tool: {name}]")
409437

410438
# Fire pre_tool hooks
411439
pre_ctx = HookContext(
@@ -451,9 +479,12 @@ def _execute_tool_calls(
451479
elapsed = time.time() * 1000 - start_ms
452480

453481
if not silent:
454-
# Truncate long outputs for display
455-
display = output[:500] + "..." if len(output) > 500 else output
456-
self._print(f" → {display}")
482+
if self._json_events:
483+
self._emit_event(type="tool_finished", name=name, result=output, is_error=error)
484+
else:
485+
# Truncate long outputs for display
486+
display = output[:500] + "..." if len(output) > 500 else output
487+
self._print(f" → {display}")
457488

458489
tr = ToolResult(
459490
tool_name=name,
@@ -554,13 +585,23 @@ def _on_session_end(self) -> None:
554585
def _print_banner(self) -> None:
555586
provider = getattr(self._provider, "provider_name", "?")
556587
model = getattr(self._provider, "model", "?")
557-
self._print(
558-
f"\n[specsmith agent — AEE-integrated]"
559-
f"\n Project: {self.project_dir}"
560-
f"\n Provider: {provider}/{model}"
561-
f"\n Tools: {len(self._tools)} | Skills: {len(self._skills)}"
562-
f"\n Type /help for commands, exit to quit\n"
563-
)
588+
if self._json_events:
589+
self._emit_event(
590+
type="ready",
591+
provider=provider,
592+
model=model,
593+
project_dir=self.project_dir,
594+
tools=len(self._tools),
595+
skills=len(self._skills),
596+
)
597+
else:
598+
self._print(
599+
f"\n[specsmith agent — AEE-integrated]"
600+
f"\n Project: {self.project_dir}"
601+
f"\n Provider: {provider}/{model}"
602+
f"\n Tools: {len(self._tools)} | Skills: {len(self._skills)}"
603+
f"\n Type /help for commands, exit to quit\n"
604+
)
564605

565606
def _print_status(self) -> None:
566607
self._print(
@@ -594,10 +635,22 @@ def _print_help(self) -> None:
594635
def _prompt(self) -> str:
595636
"""Read a line of input from stdin."""
596637
try:
597-
return input("\n> ")
638+
# In json_events mode suppress the prompt string so stdout stays pure JSON
639+
return input("" if self._json_events else "\n> ")
598640
except EOFError:
599641
raise
600642

601643
def _print(self, text: str = "", end: str = "\n", flush: bool = False) -> None:
602-
"""Print to stdout."""
603-
print(text, end=end, flush=flush)
644+
"""Print to stdout; in json_events mode emit a system event instead."""
645+
if self._json_events:
646+
if text.strip():
647+
self._emit_event(type="system", message=text.strip())
648+
else:
649+
print(text, end=end, flush=flush)
650+
651+
def _emit_event(self, **kwargs: Any) -> None:
652+
"""Emit a newline-delimited JSON event to stdout (json_events mode only)."""
653+
import json
654+
import sys
655+
656+
print(json.dumps(kwargs), flush=True, file=sys.stdout) # noqa: T201

src/specsmith/cli.py

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1809,29 +1809,6 @@ def plugin_list() -> None:
18091809
console.print(f"\n {len(plugins)} plugin(s)")
18101810

18111811

1812-
# ---------------------------------------------------------------------------
1813-
# Serve (API for React dashboard)
1814-
# ---------------------------------------------------------------------------
1815-
1816-
1817-
@main.command()
1818-
@click.option("--port", default=8910, help="Port to serve on.")
1819-
def serve(port: int) -> None:
1820-
"""Start local API server for the web dashboard."""
1821-
console.print(f"[bold]Starting specsmith API server on port {port}...[/bold]")
1822-
console.print("[yellow]Not yet implemented. See issue #14.[/yellow]")
1823-
console.print(
1824-
"\nPlanned endpoints:\n"
1825-
" GET /api/projects \u2014 list governed projects\n"
1826-
" POST /api/projects/init \u2014 scaffold new project\n"
1827-
" POST /api/projects/import \u2014 import existing project\n"
1828-
" GET /api/projects/:id/audit \u2014 run audit\n"
1829-
" GET /api/projects/:id/export \u2014 generate report\n"
1830-
" GET /api/types \u2014 list all 30 project types\n"
1831-
" GET /api/tools/:type \u2014 tool registry for a type"
1832-
)
1833-
1834-
18351812
# ---------------------------------------------------------------------------
18361813
# Process execution and abort
18371814
# ---------------------------------------------------------------------------
@@ -1955,6 +1932,13 @@ def abort_cmd(pid: int | None, abort_all_flag: bool, project_dir: str) -> None:
19551932
default=False,
19561933
help="Enable token optimization (caching, routing, context trim, tool filtering).",
19571934
)
1935+
@click.option(
1936+
"--json-events",
1937+
"json_events",
1938+
is_flag=True,
1939+
default=False,
1940+
help="Emit structured JSONL events to stdout (used by IDE clients like the VS Code extension).",
1941+
)
19581942
def run_cmd(
19591943
project_dir: str,
19601944
task: str,
@@ -1963,6 +1947,7 @@ def run_cmd(
19631947
tier: str,
19641948
no_stream: bool,
19651949
optimize: bool,
1950+
json_events: bool,
19661951
) -> None:
19671952
"""Start the AEE-integrated agentic client REPL.
19681953
@@ -1994,6 +1979,7 @@ def run_cmd(
19941979
tier=tier_map[tier],
19951980
stream=not no_stream,
19961981
optimize=optimize,
1982+
json_events=json_events,
19971983
)
19981984
if task:
19991985
result = runner.run_task(task)

0 commit comments

Comments
 (0)