Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 63 additions & 18 deletions packages/prime/src/prime_cli/lab_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from dataclasses import dataclass
from importlib import metadata
from pathlib import Path
from typing import Any
from typing import Any, Literal
from urllib.error import HTTPError, URLError
from urllib.request import urlopen

Expand Down Expand Up @@ -67,6 +67,17 @@
_REPO_TREE_CACHE: dict[tuple[str, str, int], tuple[RepoTreeEntry, ...]] = {}

Emit = Callable[[str], None]
LabSyncProgressKind = Literal[
"started",
"lab_assets_prepared",
"agent_assets_prepared",
"agent_assets_skipped",
"templates_refreshed",
"guidance_refreshed",
"completed",
"failed",
]
LabSyncProgressEmit = Callable[["LabSyncProgressEvent"], None]
Runner = Callable[[Sequence[str], Path, Emit], int]


Expand Down Expand Up @@ -113,6 +124,14 @@ class LabSyncResult:
workspace: Path


@dataclass(frozen=True)
class LabSyncProgressEvent:
"""One typed Lab sync progress milestone for renderers."""

kind: LabSyncProgressKind
workspace: Path


@dataclass(frozen=True)
class LabDoctorOptions:
"""Options for checking a Lab workspace."""
Expand Down Expand Up @@ -316,14 +335,21 @@ def run_lab_sync_service(
*,
workspace: Path,
emit: Emit | None = None,
emit_progress: LabSyncProgressEmit | None = None,
) -> LabSyncResult:
"""Refresh Lab skills and local agent guidance."""

workspace = workspace.expanduser().resolve()
emit = emit or (lambda _text: None)
try:
_run_lab_sync_steps(options, workspace=workspace, emit=emit)
_run_lab_sync_steps(
options,
workspace=workspace,
emit=emit,
emit_progress=emit_progress,
)
except Exception as exc:
_emit_sync_progress(emit_progress, "failed", workspace)
emit(f"Sync failed: {exc}\n")
return LabSyncResult(exit_code=1, workspace=workspace)
return LabSyncResult(exit_code=0, workspace=workspace)
Expand Down Expand Up @@ -381,37 +407,54 @@ def _run_lab_sync_steps(
*,
workspace: Path,
emit: Emit,
emit_progress: LabSyncProgressEmit | None = None,
) -> None:
workspace.mkdir(parents=True, exist_ok=True)
(workspace / "configs").mkdir(exist_ok=True)
(workspace / "environments").mkdir(exist_ok=True)
emit(f"Syncing Lab assets in {workspace}\n")
_emit_sync_progress(emit_progress, "started", workspace)
agents = _resolve_sync_agents(workspace, options.agents, no_agent=options.no_agent)
guidance_agents = agents
if not guidance_agents and options.no_agent:
guidance_agents = _workspace_agents_from_metadata(workspace)

managed_skill_names = _sync_prime_skills(emit)
_prepare_workspace_skill_dir(workspace, managed_skill_names, emit)
_emit_sync_progress(emit_progress, "lab_assets_prepared", workspace)
if agents:
_prepare_agent_skill_dirs(workspace, agents, managed_skill_names, emit)
_report_missing_agent_requirements(agents, emit)
_prepare_agent_native_surfaces(workspace, agents, emit)
_sync_lab_metadata(workspace, agents, setup_source="prime lab sync")
_emit_sync_progress(emit_progress, "agent_assets_prepared", workspace)
else:
reason = (
"--no-agent"
if options.no_agent
else "no configured agent; pass --agent to configure one"
)
emit(f"Skipped coding-agent skill roots ({reason})\n")
_emit_sync_progress(emit_progress, "agent_assets_skipped", workspace)
_sync_config_templates(workspace, emit)
_emit_sync_progress(emit_progress, "templates_refreshed", workspace)

if not options.skip_docs:
_sync_workspace_guidance(workspace, guidance_agents, emit, force=True)
_write_lab_docs_index(workspace, guidance_agents)
_emit_sync_progress(emit_progress, "guidance_refreshed", workspace)

emit("Lab sync completed\n")
_emit_sync_progress(emit_progress, "completed", workspace)


def _emit_sync_progress(
emit_progress: LabSyncProgressEmit | None,
kind: LabSyncProgressKind,
workspace: Path,
) -> None:
if emit_progress is not None:
emit_progress(LabSyncProgressEvent(kind=kind, workspace=workspace))


def _sync_workspace_guidance(
Expand Down Expand Up @@ -854,14 +897,14 @@ def _managed_skill_manifest_check() -> LabDoctorCheck:
skills = manifest.get("skills")
if isinstance(skills, dict) and skills:
return LabDoctorCheck(
name="Global Lab skill cache",
name="Global Lab asset cache",
status="PASS",
message=f"{len(skills)} managed skill(s) installed.",
message=f"{len(skills)} managed asset(s) installed.",
)
return LabDoctorCheck(
name="Global Lab skill cache",
name="Global Lab asset cache",
status="WARN",
message=f"Missing {_global_prime_skills_dir() / PRIME_SKILLS_MANIFEST}",
message="Missing local Lab asset cache.",
remediation="Run prime lab sync.",
)

Expand All @@ -886,9 +929,9 @@ def _workspace_managed_skills_check(workspace: Path) -> LabDoctorCheck:
skill_names = _managed_skill_names_from_manifest()
if not skill_names:
return LabDoctorCheck(
name="Workspace Lab skills",
name="Workspace Lab assets",
status="WARN",
message="No managed Lab skills are installed.",
message="No managed Lab assets are installed.",
remediation="Run prime lab sync.",
)
skills_dir = workspace / WORKSPACE_SKILLS_DIR
Expand All @@ -899,14 +942,14 @@ def _workspace_managed_skills_check(workspace: Path) -> LabDoctorCheck:
]
if not missing:
return LabDoctorCheck(
name="Workspace Lab skills",
name="Workspace Lab assets",
status="PASS",
message=f"{len(skill_names)} managed skill link(s) are present.",
message=f"{len(skill_names)} managed asset link(s) are present.",
)
return LabDoctorCheck(
name="Workspace Lab skills",
name="Workspace Lab assets",
status="WARN",
message="Missing " + ", ".join(missing[:5]),
message="Missing managed Lab assets: " + ", ".join(missing[:5]),
remediation="Run prime lab sync.",
)

Expand All @@ -915,9 +958,9 @@ def _agent_managed_skills_check(label: str, agent_skill_dir: Path, agent: str) -
skill_names = _managed_skill_names_from_manifest()
if not skill_names:
return LabDoctorCheck(
name=f"{label} skills",
name=f"{label} assets",
status="WARN",
message="No managed Lab skills are installed.",
message="No managed Lab assets are installed.",
remediation="Run prime lab sync.",
)
missing = [
Expand All @@ -928,14 +971,14 @@ def _agent_managed_skills_check(label: str, agent_skill_dir: Path, agent: str) -
]
if not missing:
return LabDoctorCheck(
name=f"{label} skills",
name=f"{label} assets",
status="PASS",
message=f"{len(skill_names)} managed skill link(s) are present.",
message=f"{len(skill_names)} managed asset link(s) are present.",
)
return LabDoctorCheck(
name=f"{label} skills",
name=f"{label} assets",
status="WARN",
message="Missing " + ", ".join(missing[:5]),
message="Missing managed Lab assets: " + ", ".join(missing[:5]),
remediation=f"Run prime lab sync --agent {agent}.",
)

Expand Down Expand Up @@ -1692,6 +1735,8 @@ def _print_lab_doctor_result(result: LabDoctorResult, console: Console) -> None:
"LabSetupOptions",
"LabSetupResult",
"LabSyncOptions",
"LabSyncProgressEvent",
"LabSyncProgressKind",
"LabSyncResult",
"SUPPORTED_AGENTS",
"parse_lab_doctor_args",
Expand Down
7 changes: 6 additions & 1 deletion packages/prime/src/prime_lab_app/agent_cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
build_agent_widget_model,
widget_payload,
)
from .agent_widget_titles import config_picker_summary
from .config_screen import ConfigBuildResult
from .launch_runner import ConfigLaunchRunner, extract_training_log_follow_command
from .palette import PRIMARY, STATUS_ERROR, STATUS_WARNING, SUCCESS
Expand Down Expand Up @@ -502,10 +503,14 @@ def _widget_card_heading(model: AgentWidgetModel) -> Group:
def _widget_card_body(model: AgentWidgetModel) -> Group:
payload = widget_payload(model.action)
description = str(payload.get("description") or "").strip()
kind = str(payload.get("kind") or "").strip()
config_kind = str(payload.get("config_kind") or "").strip()
table = Table.grid(padding=(0, 2))
table.add_column(style="bold dim", no_wrap=True)
table.add_column()
if config_path := str(payload.get("config_path") or "").strip():
if kind in {"config_editor", "run_launcher"} and config_kind:
table.add_row("Pickers", config_picker_summary(config_kind))
elif config_path := str(payload.get("config_path") or "").strip():
table.add_row("Path", _short_path(config_path))
if description:
table.add_row("Summary", description)
Expand Down
64 changes: 40 additions & 24 deletions packages/prime/src/prime_lab_app/agent_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -726,27 +726,27 @@ def _handle_session_update(self, params: dict[str, Any]) -> None:
self._append_streaming_assistant_text(event.text)
return
if event.kind in {"tool_call", "tool_update"}:
self._record_acp_tool_event(event.title, event.status, event.text)
self._record_acp_tool_event(event.title, event.tool_kind, event.status, event.text)
return

def _record_acp_tool_event(self, title: str, status: str, text: str) -> None:
def _record_acp_tool_event(
self,
title: str,
tool_kind: str,
status: str,
text: str,
) -> None:
title = title.strip()
tool_kind = tool_kind.strip()
status = status.strip()
text = text.strip()
if not title and not text:
text = _clean_agent_output_text(text.strip())
if not text:
return
if text and _is_lab_widget_tool_result_text(text):
return
label = title or "Tool"
content = label if not text else f"{label}\n{text}"
content = f"{label}\n{text}"
with self._lock:
if (
not text
and _is_lab_widget_tool_event_title(label)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant truthiness check after early return guard

Low Severity

The if text and condition on line 745 is redundant because lines 743–744 already return early when not text. At this point, text is guaranteed to be truthy, making the truthiness check in if text and _is_lab_widget_tool_result_text(text) unnecessary.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 994af56. Configure here.

and self._messages
and self._messages[-1].status == "widget"
):
return
if (
self._messages
and self._messages[-1].role == "system"
Expand All @@ -757,15 +757,15 @@ def _record_acp_tool_event(self, title: str, status: str, text: str) -> None:
"system",
content,
"tool",
{"title": label, "tool_status": status},
{"title": label, "tool_kind": tool_kind, "tool_status": status},
)
else:
self._messages.append(
AgentChatMessage(
"system",
content,
"tool",
{"title": label, "tool_status": status},
{"title": label, "tool_kind": tool_kind, "tool_status": status},
)
)
self._emit_messages_locked()
Expand Down Expand Up @@ -813,9 +813,12 @@ def _record_dynamic_tool_call(
content: str,
action: dict[str, Any] | None = None,
) -> None:
display_content = _dynamic_tool_chat_content(status, content, action)
if display_content is None:
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Early return skips streaming assistant finalization

Low Severity

When _dynamic_tool_chat_content returns None (i.e., status == "tool"), _record_dynamic_tool_call now returns before reaching _finish_latest_streaming_assistant_locked(fallback=""). Previously, this finalization was always called, ensuring any active streaming assistant message was properly completed (or removed if empty) before appending a system message. Now, if there happens to be an active streaming assistant message when a "tool" status event arrives, it will remain in the "streaming" state until some other event finalizes it, potentially causing a lingering streaming indicator in the UI.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1924dd4. Configure here.

with self._lock:
self._finish_latest_streaming_assistant_locked(fallback="")
self._messages.append(AgentChatMessage("system", content, status, action or {}))
self._messages.append(AgentChatMessage("system", display_content, status, action or {}))
self._emit_messages_locked()

def _record_codex_turn(self, result: dict[str, Any]) -> None:
Expand Down Expand Up @@ -1191,10 +1194,28 @@ def _is_chunk_message(value: Any) -> bool:
def _clean_agent_output_text(text: str) -> str:
if not text:
return ""
cleaned = _ANSI_RE.sub("", text)
if cleaned != text and not cleaned.strip():
return ""
return cleaned
return _ANSI_RE.sub("", text)


def _dynamic_tool_chat_content(
status: str,
content: str,
action: dict[str, Any] | None,
) -> str | None:
if status == "tool":
return None
if status == "widget":
if isinstance(action, dict):
title = str(action.get("title") or "").strip()
if title:
return title
payload = action.get("payload")
if isinstance(payload, dict):
payload_title = str(payload.get("title") or "").strip()
if payload_title:
return payload_title
return "Action ready"
return content


def _is_lab_widget_tool_result_text(text: str) -> bool:
Expand Down Expand Up @@ -1233,11 +1254,6 @@ def _contains_lab_widget_tool_result(value: Any) -> bool:
return False


def _is_lab_widget_tool_event_title(value: str) -> bool:
normalized = value.strip().lower().replace("-", "_")
return normalized.startswith(("prime_lab_", "mcp_prime_lab_"))


def _merge_stream_text(existing: str, delta: str) -> str:
"""Append streamed text while ignoring duplicate final snapshots."""

Expand Down
3 changes: 3 additions & 0 deletions packages/prime/src/prime_lab_app/agent_widget_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,9 @@ def _widget_field_specs(context: dict[str, Any] | None) -> tuple[AgentWidgetFiel
value = str(model_options[0][1])
widget = "select"
options = model_options
if config_kind == "eval" and name == "envs" and value:
widget = "select"
options = ((value, value),)
fields.append(
AgentWidgetFieldSpec(
name=name,
Expand Down
10 changes: 10 additions & 0 deletions packages/prime/src/prime_lab_app/agent_widget_titles.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,13 @@ def clean_widget_title(value: str) -> str:
if lowered.startswith(prefix):
return title[len(prefix) :].strip() or title
return title


def config_picker_summary(config_kind: str) -> str:
if config_kind == "eval":
return "Environment, model, examples, rollouts, tokens, concurrency"
if config_kind == "rl":
return "Environment, model, steps, rollouts, batch, tokens"
if config_kind == "gepa":
return "Environment, model"
return "Environment, model"
2 changes: 1 addition & 1 deletion packages/prime/src/prime_lab_app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2169,7 +2169,7 @@ def _with_workspace_agent_choice(item: LabItem, workspace: Path, agent: str) ->
key=item.key,
section=item.section,
title=item.title,
subtitle=f"{agent} · refresh templates, skills, docs, and local guidance",
subtitle=f"{agent} · refresh Lab assets and local guidance",
status=item.status,
status_style=item.status_style,
metadata=_replace_metadata(
Expand Down
Loading
Loading