Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 450e118b1a | |||
| f2d1f4efe7 | |||
| 54fef4768c | |||
| 0461f8e51f | |||
| 5db663cb63 | |||
| ad31e75af6 | |||
| 8384f53e28 | |||
| defa04d56d |
+23
-6
@@ -5,6 +5,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cross-actor subgraph cycle detection reads actor_ref field** (#1431): Fixed
|
||||
`_detect_subgraph_cycles()`, `_map_node()`, and the `compile_actor()` main loop
|
||||
in `src/cleveragents/actor/compiler.py` to read `actor_ref` from the top-level
|
||||
`NodeDefinition.actor_ref` field instead of `node.config.get("actor_ref", "")`.
|
||||
Because `actor_ref` is a typed, validated Pydantic field (not a key inside the
|
||||
untyped `config` dict), the old code always returned an empty string, causing
|
||||
cross-actor cycle detection to silently fail and leaving the system vulnerable to
|
||||
infinite recursion at runtime. Added Behave regression tests
|
||||
(`features/actor_subgraph_cycle_detection.feature`) and a Robot Framework
|
||||
integration test (`robot/actor_compiler.robot`) to prevent regressions.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`agents session list` now displays full 26-character session ULIDs** (#10970): The Rich table
|
||||
@@ -69,6 +82,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- `agents actor context clear` command to reset actor message history and
|
||||
state while preserving the underlying context directory via `ContextManager`
|
||||
(#6370).
|
||||
- **Quick Start Guide** (PR #9245): Added `docs/quickstart.md` with an end-to-end quick start guide covering prerequisites, installation, project creation, resource registration, plan/apply workflow, and troubleshooting. Updated `mkdocs.yml` navigation to include the Quick Start page.
|
||||
|
||||
- **Plan checkpoint management CLI commands** (#8683): Added `agents plan checkpoint-list <plan-id>` and `agents plan checkpoint-delete <checkpoint-id>` commands. Listing output now highlights checkpoint ID, type, created timestamp, reason, phase, and decision linkage with a concise field summary footer across rich/table/json/yaml formats. Deletion supports batch IDs, interactive confirmation (skip with `--yes`), and structured JSON/YAML responses for automation-friendly scripting.
|
||||
- **Invariant Remove CLI Command** (#8530): Implemented `agents invariant remove <id>` command that soft-deletes an invariant by ID. The command displays a confirmation prompt before removal (bypassable with `--yes`/`-y`), outputs the removed invariant ID on success, and shows a clear error message when the invariant ID does not exist. Supports `--format` flag for JSON and YAML output. Full BDD test coverage and Robot Framework integration tests included.
|
||||
@@ -343,12 +357,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
had `@tdd_expected_fail` removed and now run as permanent regression guards.
|
||||
Net result: 629 features active in CI (up from ~545), zero `@skip` tags remain.
|
||||
|
||||
- **Actor Context List Regex Filter** (#6500): `agents actor context list` now
|
||||
accepts an optional `[REGEX]` positional argument to filter listed named
|
||||
contexts by a regular expression pattern. Invalid patterns are reported with
|
||||
a clear error message. The deprecated `agents context list` alias receives
|
||||
the same filtering capability.
|
||||
|
||||
- **Git Worktree Sandbox Apply** (#4454): The `plan apply` command now merges
|
||||
LLM-generated changes via `git merge` from an isolated worktree branch
|
||||
instead of flat `shutil.copy2`. Displays spec-aligned Apply Summary
|
||||
@@ -625,6 +633,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
response format from the OpenCode API `/session/status` endpoint instead of an array.
|
||||
Workers now dispatch and verify correctly, preventing incorrect session deletion.
|
||||
|
||||
---
|
||||
### Fixed
|
||||
|
||||
- **CLI (`agents actor remove`)** (#6491): Restores output parity with the
|
||||
other actor commands by honoring `--format`/`-f` for JSON/YAML/plain/Rich
|
||||
envelopes. Adds a Robot Framework regression test to assert the JSON
|
||||
envelope structure and updates the CLI synopsis in `docs/specification.md`
|
||||
to document the option.
|
||||
|
||||
---
|
||||
|
||||
## [3.8.0] -- 2026-04-05
|
||||
|
||||
@@ -31,4 +31,3 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration.
|
||||
* HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch.
|
||||
* HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files.
|
||||
* HAL 9000 has contributed the actor context list regex filter feature (PR #6741 / issue #6500): added an optional `[REGEX]` positional argument to `agents actor context list` command to filter named contexts by a regular expression pattern, including BDD test coverage and Robot Framework integration test with `--context-dir` support for isolation.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Quick Start Guide
|
||||
|
||||
This quick start guide will walk you through creating a new project, registering a resource, running a plan, and applying changes with CleverAgents.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+ and virtualenv
|
||||
- Git
|
||||
- A working CleverAgents installation (see development/testing.md for details)
|
||||
|
||||
## Install (local development)
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
## Create a new project
|
||||
|
||||
```bash
|
||||
# Create a new directory for your project
|
||||
mkdir my-project && cd my-project
|
||||
# Initialize a CleverAgents project (example command)
|
||||
cleveragents init --name my-project
|
||||
```
|
||||
|
||||
## Register a resource
|
||||
|
||||
Create a resource file under `resources/` (example YAML) and register it with the CLI or API.
|
||||
|
||||
## Plan and apply
|
||||
|
||||
```bash
|
||||
# Create a plan using an action on your project
|
||||
cleveragents plan use <action-name> --project my-project
|
||||
# List plans to find the plan ID
|
||||
cleveragents plan list
|
||||
# Review the plan, then apply it by plan ID
|
||||
cleveragents plan apply <plan-id>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues running the examples above, consult `docs/development/testing.md` and the project README for local development tips.
|
||||
@@ -277,7 +277,7 @@ The following standards are integrated into the architecture:
|
||||
[(<span style="color: magenta;"><span style="color: cyan;">--temperature</span>|<span style="color: yellow;">-t</span></span>) <span style="color: #66cc66;"><TEMP></span>] [<span style="color: cyan;">--allow-rxpy-in-run-mode</span>]
|
||||
[<span style="color: cyan;">--skill</span> <span style="color: #66cc66;"><SKILL></span>]... <span style="color: #66cc66;"><NAME></span> <span style="color: #66cc66;"><PROMPT></span>
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> actor add <span style="color: cyan;">--config</span>|<span style="color: yellow;">-c</span> <span style="color: #66cc66;"><FILE></span> [<span style="color: cyan;">--update</span>]
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> actor remove <span style="color: #66cc66;"><NAME></span>
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> actor remove [<span style="color: cyan;">--format</span> <span style="color: #66cc66;"><FORMAT></span>] <span style="color: #66cc66;"><NAME></span>
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> actor list
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> actor show <span style="color: #66cc66;"><NAME></span>
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> actor context remove [<span style="color: cyan;">--yes</span>|<span style="color: yellow;">-y</span>] (<span style="color: magenta;"><span style="color: cyan;">--all</span>|<span style="color: yellow;">-a</span>|<span style="color: #66cc66;"><NAME></span></span>)
|
||||
|
||||
@@ -59,6 +59,11 @@ Feature: Actor CLI YAML-first alignment
|
||||
When I run actor remove with namespaced name
|
||||
Then the actor remove should succeed for namespaced name
|
||||
|
||||
Scenario: Actor remove outputs JSON format
|
||||
Given an actor CLI runner
|
||||
When I run actor remove with format json
|
||||
Then the actor remove output should be valid JSON envelope
|
||||
|
||||
Scenario: Actor update outputs JSON format
|
||||
Given an actor CLI runner
|
||||
When I run actor update with format json
|
||||
|
||||
@@ -6,15 +6,6 @@ Feature: Actor context clear, remove, export, and import commands
|
||||
Background:
|
||||
Given a temporary context directory for actor context tests
|
||||
|
||||
@actor_context_list_regex
|
||||
Scenario: List actor contexts filtered by regex
|
||||
Given an actor context named "docs" exists
|
||||
And an actor context named "notes" exists
|
||||
When I run actor context list "docs"
|
||||
Then the actor context list command should succeed
|
||||
And the list output should contain "docs"
|
||||
And the list output should not contain "notes"
|
||||
|
||||
# ── context clear ─────────────────────────────────────────
|
||||
|
||||
Scenario: Clear a named actor context
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
Feature: Cross-actor subgraph cycle detection reads actor_ref field
|
||||
As a CleverAgents developer
|
||||
I want the actor compiler to correctly detect cross-actor subgraph cycles
|
||||
So that mutually-referencing actors raise SubgraphCycleError at compile time
|
||||
|
||||
Background:
|
||||
Given the actor compiler is available
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Bug fix: actor_ref is a top-level NodeDefinition field
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
@tdd_issue @tdd_issue_1431
|
||||
Scenario: Mutually-referencing actors raise SubgraphCycleError
|
||||
Given actor "test/actor-a" has a subgraph node with actor_ref "test/actor-b"
|
||||
And actor "test/actor-b" has a subgraph node with actor_ref "test/actor-a"
|
||||
And a registry containing both actors
|
||||
When I compile actor "test/actor-a" with the registry resolver
|
||||
Then the compilation should raise SubgraphCycleError
|
||||
And the cycle error message should mention "cycle"
|
||||
|
||||
@tdd_issue @tdd_issue_1431
|
||||
Scenario: Non-cyclic subgraph reference compiles successfully
|
||||
Given actor "test/actor-x" has a subgraph node with actor_ref "test/actor-y"
|
||||
And actor "test/actor-y" has no subgraph nodes
|
||||
And a registry containing both actors
|
||||
When I compile actor "test/actor-x" with the registry resolver
|
||||
Then the actor compilation should succeed
|
||||
And the actor subgraph_refs should map "sub" to "test/actor-y"
|
||||
|
||||
@tdd_issue @tdd_issue_1431
|
||||
Scenario: Subgraph node actor_ref is reflected in compiled metadata
|
||||
Given actor "test/actor-p" has a subgraph node with actor_ref "test/actor-q"
|
||||
And actor "test/actor-q" has no subgraph nodes
|
||||
And a registry containing both actors
|
||||
When I compile actor "test/actor-p" with the registry resolver
|
||||
Then the actor compilation should succeed
|
||||
And the compiled node "sub" should have subgraph set to "test/actor-q"
|
||||
@@ -16,18 +16,12 @@ Feature: LSP Resource Types
|
||||
|
||||
Examples:
|
||||
| type_name |
|
||||
| executable |
|
||||
| lsp-server |
|
||||
| lsp-workspace |
|
||||
| lsp-document |
|
||||
|
||||
# ── User-addable flags ────────────────────────────────────────
|
||||
|
||||
Scenario: executable is user-addable for lsp_rt
|
||||
Given the built-in executable YAML file for lsp_rt
|
||||
When I load the YAML via schema for lsp_rt
|
||||
Then the lsp_rt schema user_addable should be true
|
||||
|
||||
Scenario: lsp-server is user-addable for lsp_rt
|
||||
Given the built-in lsp-server YAML file for lsp_rt
|
||||
When I load the YAML via schema for lsp_rt
|
||||
@@ -51,12 +45,6 @@ Feature: LSP Resource Types
|
||||
Then the lsp_rt capability read should be true
|
||||
And the lsp_rt capability write should be true
|
||||
|
||||
Scenario: executable has read-only capabilities for lsp_rt
|
||||
Given the built-in executable YAML file for lsp_rt
|
||||
When I load the YAML via schema for lsp_rt
|
||||
Then the lsp_rt capability read should be true
|
||||
And the lsp_rt capability write should be false
|
||||
|
||||
# ── Parent/child hierarchy ────────────────────────────────────
|
||||
|
||||
Scenario: lsp-server has lsp-workspace as child type for lsp_rt
|
||||
@@ -81,12 +69,6 @@ Feature: LSP Resource Types
|
||||
|
||||
# ── Auto-discovery ────────────────────────────────────────────
|
||||
|
||||
Scenario: executable has auto-discovery from container-exec-env for lsp_rt
|
||||
Given the built-in executable YAML file for lsp_rt
|
||||
When I load the YAML via schema for lsp_rt
|
||||
Then the lsp_rt auto_discovery should be present
|
||||
And the lsp_rt auto_discovery trigger_types should contain "container-exec-env"
|
||||
|
||||
Scenario: lsp-workspace has auto-discovery from lsp-server for lsp_rt
|
||||
Given the built-in lsp-workspace YAML file for lsp_rt
|
||||
When I load the YAML via schema for lsp_rt
|
||||
@@ -97,8 +79,7 @@ Feature: LSP Resource Types
|
||||
|
||||
Scenario: BUILTIN_NAMES includes all LSP types for lsp_rt
|
||||
Given the ResourceTypeSpec BUILTIN_NAMES set for lsp_rt
|
||||
Then BUILTIN_NAMES should contain "executable" for lsp_rt
|
||||
And BUILTIN_NAMES should contain "lsp-server" for lsp_rt
|
||||
Then BUILTIN_NAMES should contain "lsp-server" for lsp_rt
|
||||
And BUILTIN_NAMES should contain "lsp-workspace" for lsp_rt
|
||||
And BUILTIN_NAMES should contain "lsp-document" for lsp_rt
|
||||
|
||||
@@ -106,9 +87,6 @@ Feature: LSP Resource Types
|
||||
|
||||
Scenario: LSP types survive DB roundtrip after bootstrap for lsp_rt
|
||||
Given a lsp_rt fresh in-memory resource registry with bootstrap
|
||||
When I query the lsp_rt registry for type "executable"
|
||||
Then the lsp_rt db type "executable" should exist
|
||||
And the lsp_rt db type "executable" should have kind "physical"
|
||||
When I query the lsp_rt registry for type "lsp-server"
|
||||
Then the lsp_rt db type "lsp-server" should exist
|
||||
When I query the lsp_rt registry for type "lsp-workspace"
|
||||
|
||||
@@ -337,6 +337,63 @@ def step_remove_namespaced_ok(context: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@when("I run actor remove with format json")
|
||||
def step_remove_format_json(context: Any) -> None:
|
||||
with (
|
||||
patch("cleveragents.cli.commands.actor._get_services") as mock_svc,
|
||||
patch("cleveragents.cli.commands.actor._compute_actor_impact") as mock_impact,
|
||||
):
|
||||
mock_registry = MagicMock()
|
||||
mock_service = MagicMock()
|
||||
actor = _make_actor(
|
||||
name="local/remove-json",
|
||||
provider="json-provider",
|
||||
model="gpt-json",
|
||||
)
|
||||
mock_registry.get_actor.return_value = actor
|
||||
mock_impact.return_value = (2, 1, 3)
|
||||
mock_svc.return_value = (mock_service, mock_registry)
|
||||
|
||||
context.result = context.runner.invoke(
|
||||
actor_app,
|
||||
["remove", actor.name, "--format", "json"],
|
||||
)
|
||||
|
||||
context.mock_actor_registry = mock_registry
|
||||
context.actor = actor
|
||||
context.impact_counts = (2, 1, 3)
|
||||
|
||||
|
||||
@then("the actor remove output should be valid JSON envelope")
|
||||
def step_remove_json_valid(context: Any) -> None:
|
||||
assert context.result.exit_code == 0
|
||||
parsed = json.loads(context.result.output.strip())
|
||||
assert _ENVELOPE_KEYS.issubset(parsed.keys())
|
||||
assert parsed["command"] == f"agents actor remove {context.actor.name}"
|
||||
assert parsed["status"] == "ok"
|
||||
assert parsed["exit_code"] == 0
|
||||
data = _unwrap_envelope(parsed)
|
||||
assert isinstance(data, dict)
|
||||
actor_data = data.get("actor_removed", {})
|
||||
assert actor_data.get("name") == context.actor.name
|
||||
assert actor_data.get("provider") == context.actor.provider
|
||||
assert actor_data.get("model") == context.actor.model
|
||||
impact = data.get("impact", {})
|
||||
expected_sessions, expected_plans, expected_actions = context.impact_counts
|
||||
assert impact.get("sessions") == expected_sessions
|
||||
assert impact.get("active_plans") == expected_plans
|
||||
assert impact.get("actions_referencing") == expected_actions
|
||||
cleanup = data.get("cleanup", {})
|
||||
assert cleanup.get("config") == "kept on disk"
|
||||
assert cleanup.get("contexts") == "0 orphaned"
|
||||
messages = parsed.get("messages", [])
|
||||
assert messages, "expected messages in envelope"
|
||||
first_message = messages[0]
|
||||
assert first_message.get("level") == "ok"
|
||||
assert "Actor removed" in first_message.get("text", "")
|
||||
context.mock_actor_registry.remove_actor.assert_called_once_with(context.actor.name)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Update with --format
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -135,7 +135,8 @@ def _subgraph_node(node_id: str, actor_ref: str = "") -> NodeDefinition:
|
||||
type=NodeType.SUBGRAPH,
|
||||
name=node_id.title(),
|
||||
description=f"Subgraph {node_id}",
|
||||
config={"actor_ref": actor_ref},
|
||||
config={},
|
||||
actor_ref=actor_ref if actor_ref else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -186,7 +186,8 @@ def step_given_subgraph_ref(context: Context, ref_name: str) -> None:
|
||||
type=NodeType.SUBGRAPH,
|
||||
name="Sub",
|
||||
description="Subgraph",
|
||||
config={"actor_ref": ref_name},
|
||||
config={},
|
||||
actor_ref=ref_name,
|
||||
),
|
||||
]
|
||||
edges = [EdgeDefinition(from_node="main", to_node="sub")]
|
||||
@@ -241,7 +242,8 @@ def step_given_outer_referencing_inner(
|
||||
type=NodeType.SUBGRAPH,
|
||||
name="Sub",
|
||||
description="Subgraph",
|
||||
config={"actor_ref": inner_name},
|
||||
config={},
|
||||
actor_ref=inner_name,
|
||||
),
|
||||
]
|
||||
edges = [EdgeDefinition(from_node="main", to_node="sub")]
|
||||
@@ -260,7 +262,8 @@ def step_given_resolver_cycle(context: Context, inner_name: str, back_ref: str)
|
||||
type=NodeType.SUBGRAPH,
|
||||
name="Child",
|
||||
description="Back-ref",
|
||||
config={"actor_ref": back_ref},
|
||||
config={},
|
||||
actor_ref=back_ref,
|
||||
),
|
||||
]
|
||||
inner = _build_graph_config(inner_name, inner_nodes, [], "child", ["child"])
|
||||
|
||||
@@ -105,36 +105,6 @@ def step_create_json_file_with_name(context, name):
|
||||
context.import_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When — list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I run actor context list "{pattern}"')
|
||||
def step_list_with_pattern(context, pattern):
|
||||
context.result = context.runner.invoke(
|
||||
actor_context_app,
|
||||
[
|
||||
"list",
|
||||
pattern,
|
||||
"--context-dir",
|
||||
str(context.context_dir),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@when("I run actor context list without a pattern")
|
||||
def step_list_without_pattern(context):
|
||||
context.result = context.runner.invoke(
|
||||
actor_context_app,
|
||||
[
|
||||
"list",
|
||||
"--context-dir",
|
||||
str(context.context_dir),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When — clear
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -413,15 +383,6 @@ def step_roundtrip_import(context, name):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the actor context list command should succeed")
|
||||
def step_list_success(context):
|
||||
assert context.result.exit_code == 0, (
|
||||
f"Expected exit 0, got {context.result.exit_code}.\n"
|
||||
f"stdout: {context.result.output}\n"
|
||||
f"stderr: {getattr(context.result, 'stderr', '')}"
|
||||
)
|
||||
|
||||
|
||||
@then("the actor context clear command should succeed")
|
||||
def step_clear_success(context):
|
||||
assert context.result.exit_code == 0, (
|
||||
@@ -553,18 +514,6 @@ def step_all_contexts_empty(context):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the list output should contain "{text}"')
|
||||
def step_list_output_contains(context, text):
|
||||
output = context.result.output
|
||||
assert text in output, f"Expected '{text}' in list output: {output}"
|
||||
|
||||
|
||||
@then('the list output should not contain "{text}"')
|
||||
def step_list_output_not_contains(context, text):
|
||||
output = context.result.output
|
||||
assert text not in output, f"Did not expect '{text}' in list output: {output}"
|
||||
|
||||
|
||||
@then('the output should contain valid JSON with key "{key}"')
|
||||
def step_output_json_key(context, key):
|
||||
output = context.result.output
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Step definitions for cross-actor subgraph cycle detection tests.
|
||||
|
||||
Tests for features/actor_subgraph_cycle_detection.feature — validates that
|
||||
the actor compiler correctly reads actor_ref from the top-level
|
||||
NodeDefinition field (not from node.config) when detecting cross-actor
|
||||
subgraph cycles.
|
||||
|
||||
This is the regression test for issue #1431: _detect_subgraph_cycles()
|
||||
was reading node.config.get("actor_ref", "") instead of node.actor_ref,
|
||||
causing cycle detection to always return an empty string and never detect
|
||||
cross-actor cycles.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.actor.compiler import (
|
||||
SubgraphCycleError,
|
||||
compile_actor,
|
||||
)
|
||||
from cleveragents.actor.schema import (
|
||||
ActorConfigSchema,
|
||||
ActorType,
|
||||
EdgeDefinition,
|
||||
NodeDefinition,
|
||||
NodeType,
|
||||
RouteDefinition,
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_graph_actor(
|
||||
name: str,
|
||||
nodes: list[NodeDefinition],
|
||||
edges: list[EdgeDefinition],
|
||||
entry_node: str,
|
||||
exit_nodes: list[str],
|
||||
) -> ActorConfigSchema:
|
||||
"""Build a minimal valid GRAPH actor for testing."""
|
||||
route = RouteDefinition(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
entry_node=entry_node,
|
||||
exit_nodes=exit_nodes,
|
||||
)
|
||||
return ActorConfigSchema(
|
||||
name=name,
|
||||
type=ActorType.GRAPH,
|
||||
description=f"Test actor {name}",
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
route=route,
|
||||
)
|
||||
|
||||
|
||||
def _make_agent_node(node_id: str) -> NodeDefinition:
|
||||
"""Build a minimal AGENT node."""
|
||||
return NodeDefinition(
|
||||
id=node_id,
|
||||
type=NodeType.AGENT,
|
||||
name=node_id.title(),
|
||||
description=f"Agent {node_id}",
|
||||
config={"agent": "default"},
|
||||
)
|
||||
|
||||
|
||||
def _make_subgraph_node(node_id: str, actor_ref: str) -> NodeDefinition:
|
||||
"""Build a SUBGRAPH node using the top-level actor_ref field.
|
||||
|
||||
The actor_ref is set as a first-class field on NodeDefinition, NOT
|
||||
inside config. This is the correct way to reference a subgraph actor.
|
||||
"""
|
||||
return NodeDefinition(
|
||||
id=node_id,
|
||||
type=NodeType.SUBGRAPH,
|
||||
name=node_id.title(),
|
||||
description=f"Subgraph {node_id}",
|
||||
config={},
|
||||
actor_ref=actor_ref,
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Given steps
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@given("the actor compiler is available")
|
||||
def step_compiler_available(context: Context) -> None:
|
||||
"""Ensure the actor compiler module is importable."""
|
||||
context.actor_registry: dict[str, ActorConfigSchema] = {}
|
||||
|
||||
|
||||
@given('actor "{name}" has a subgraph node with actor_ref "{ref}"')
|
||||
def step_actor_has_subgraph_ref(context: Context, name: str, ref: str) -> None:
|
||||
"""Create a GRAPH actor with one agent node and one subgraph node."""
|
||||
agent = _make_agent_node("start")
|
||||
sub = _make_subgraph_node("sub", actor_ref=ref)
|
||||
actor = _make_graph_actor(
|
||||
name,
|
||||
nodes=[agent, sub],
|
||||
edges=[EdgeDefinition(from_node="start", to_node="sub")],
|
||||
entry_node="start",
|
||||
exit_nodes=["sub"],
|
||||
)
|
||||
context.actor_registry[name] = actor
|
||||
context.actor_to_compile = name
|
||||
|
||||
|
||||
@given('actor "{name}" has no subgraph nodes')
|
||||
def step_actor_has_no_subgraph(context: Context, name: str) -> None:
|
||||
"""Create a GRAPH actor with only an agent node (no subgraph references)."""
|
||||
agent = _make_agent_node("leaf")
|
||||
actor = _make_graph_actor(
|
||||
name,
|
||||
nodes=[agent],
|
||||
edges=[],
|
||||
entry_node="leaf",
|
||||
exit_nodes=["leaf"],
|
||||
)
|
||||
context.actor_registry[name] = actor
|
||||
|
||||
|
||||
@given("a registry containing both actors")
|
||||
def step_registry_contains_both(context: Context) -> None:
|
||||
"""Set up the resolver from the accumulated registry."""
|
||||
registry = context.actor_registry
|
||||
|
||||
def resolver(actor_name: str) -> ActorConfigSchema | None:
|
||||
return registry.get(actor_name)
|
||||
|
||||
context.resolver = resolver
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# When steps
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@when('I compile actor "{name}" with the registry resolver')
|
||||
def step_compile_actor(context: Context, name: str) -> None:
|
||||
"""Compile the named actor using the registry resolver."""
|
||||
context.compile_error = None
|
||||
context.compiled = None
|
||||
actor = context.actor_registry[name]
|
||||
try:
|
||||
context.compiled = compile_actor(actor, actor_resolver=context.resolver)
|
||||
except Exception as exc:
|
||||
context.compile_error = exc
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Then steps
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@then("the compilation should raise SubgraphCycleError")
|
||||
def step_raises_cycle_error(context: Context) -> None:
|
||||
"""Assert that compilation raised SubgraphCycleError."""
|
||||
assert context.compile_error is not None, (
|
||||
"Expected SubgraphCycleError but compilation succeeded"
|
||||
)
|
||||
assert isinstance(context.compile_error, SubgraphCycleError), (
|
||||
f"Expected SubgraphCycleError, got {type(context.compile_error).__name__}: "
|
||||
f"{context.compile_error}"
|
||||
)
|
||||
|
||||
|
||||
@then("the actor compilation should succeed")
|
||||
def step_actor_compilation_succeeds(context: Context) -> None:
|
||||
"""Assert that compilation succeeded without errors."""
|
||||
assert context.compile_error is None, (
|
||||
f"Expected compilation to succeed but got: {context.compile_error}"
|
||||
)
|
||||
assert context.compiled is not None
|
||||
|
||||
|
||||
@then('the cycle error message should mention "{text}"')
|
||||
def step_cycle_error_mentions(context: Context, text: str) -> None:
|
||||
"""Assert that the cycle error message contains the given text."""
|
||||
assert context.compile_error is not None
|
||||
msg = str(context.compile_error)
|
||||
assert text.lower() in msg.lower(), (
|
||||
f"Expected '{text}' in error message, got: {msg}"
|
||||
)
|
||||
|
||||
|
||||
@then('the actor subgraph_refs should map "{node}" to "{ref}"')
|
||||
def step_actor_subgraph_refs_map(context: Context, node: str, ref: str) -> None:
|
||||
"""Assert that the compiled metadata maps the node to the expected actor ref."""
|
||||
assert context.compiled is not None
|
||||
refs = context.compiled.metadata.subgraph_refs
|
||||
assert refs.get(node) == ref, (
|
||||
f"Expected subgraph_refs[{node!r}] == {ref!r}, got {refs}"
|
||||
)
|
||||
+2
-1
@@ -61,6 +61,7 @@ nav:
|
||||
- Reference/Command Input & Sessions: tui/input-and-sessions.md
|
||||
- Configuration, Key Bindings & Integration: tui/configuration-and-integration.md
|
||||
- FAQ: faq.md
|
||||
- Quick Start: quickstart.md
|
||||
- Changelog: CHANGELOG.md
|
||||
- Contributing: CONTRIBUTING.md
|
||||
- Reference: reference/
|
||||
@@ -93,7 +94,7 @@ nav:
|
||||
- ADR-025 Observability & Logging: adr/ADR-025-observability-and-logging.md
|
||||
- ADR-026 Agent-to-Agent Protocol (A2A): adr/ADR-026-agent-client-protocol.md
|
||||
- ADR-027 Language Server Protocol (LSP) Integration: adr/ADR-027-language-server-protocol.md
|
||||
- ADR-028 Agent Skills Standard (AgentSkills.io): adr/ADR-028-agent-skills-standard.md
|
||||
- ADR-028 Skill/Tool Abstraction Definition: adr/ADR-028-agent-skills-standard.md
|
||||
- ADR-029 Model Context Protocol (MCP) Adoption: adr/ADR-029-model-context-protocol.md
|
||||
- ADR-030 Skill Abstraction Definition: adr/ADR-030-skill-abstraction-definition.md
|
||||
- ADR-031 Actor Abstraction Definition: adr/ADR-031-actor-abstraction-definition.md
|
||||
|
||||
@@ -42,3 +42,15 @@ Reject LLM Actor Compilation
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} actor-compiler-expected-fail
|
||||
Should Contain ${result.stdout} GRAPH
|
||||
|
||||
Detect Cross-Actor Subgraph Cycle Via actor_ref Field
|
||||
[Documentation] Verify that mutually-referencing actors raise SubgraphCycleError.
|
||||
... This is the regression test for issue #1431: _detect_subgraph_cycles()
|
||||
... was reading node.config.get("actor_ref") instead of node.actor_ref,
|
||||
... causing cycle detection to silently fail.
|
||||
[Tags] slow
|
||||
${result}= Run Process ${PYTHON} ${HELPER} cycle-detect dummy cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} actor-compiler-cycle-detected
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for actor remove CLI format output
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_actor_remove_cli.py
|
||||
|
||||
*** Test Cases ***
|
||||
Actor Remove Format JSON Emits Spec Envelope
|
||||
[Documentation] Verify that ``actor remove --format json`` emits a spec-compliant envelope
|
||||
[Tags] actor_remove_cli_format
|
||||
${result}= Run Process ${PYTHON} ${HELPER} remove-json cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} actor-remove-json-format-ok
|
||||
@@ -81,38 +81,6 @@ List Context Files
|
||||
|
||||
[Teardown] Cleanup Test Directory
|
||||
|
||||
Actor Context List Supports Regex Filter
|
||||
[Documentation] Verify actor context list supports optional regex filtering
|
||||
... Uses --context-dir to point at a directory of named actor
|
||||
... context subdirectories so the test is self-contained and
|
||||
... does not depend on project-context state.
|
||||
[Setup] Setup Test Directory
|
||||
|
||||
# Create a temporary context directory with two named actor context subdirs
|
||||
${ctx_dir}= Set Variable ${TEST_DIR}${/}actor_contexts
|
||||
Create Directory ${ctx_dir}
|
||||
Create Directory ${ctx_dir}${/}test-context
|
||||
Create Directory ${ctx_dir}${/}utils-context
|
||||
|
||||
# Filter by "test" — should match test-context but not utils-context
|
||||
${result}= Run Process ${PYTHON} -m cleveragents actor context list test
|
||||
... --context-dir ${ctx_dir}
|
||||
... cwd=${TEST_DIR} timeout=120s on_timeout=kill
|
||||
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} test-context
|
||||
Should Not Contain ${result.stdout} utils-context
|
||||
|
||||
# Filter by "nomatch" — no contexts match, should print "No contexts found."
|
||||
${result}= Run Process ${PYTHON} -m cleveragents actor context list nomatch
|
||||
... --context-dir ${ctx_dir}
|
||||
... cwd=${TEST_DIR} timeout=120s on_timeout=kill
|
||||
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} No contexts found.
|
||||
|
||||
[Teardown] Cleanup Test Directory
|
||||
|
||||
Show Context Content
|
||||
[Documentation] Test context show command
|
||||
[Setup] Initialize Test Project With Context
|
||||
|
||||
@@ -66,6 +66,82 @@ def main() -> int:
|
||||
print(f"actor-compiler-fail: {exc}")
|
||||
return 1
|
||||
|
||||
if command == "cycle-detect":
|
||||
# Test cross-actor cycle detection using actor_ref top-level field.
|
||||
# Creates two mutually-referencing actors and verifies SubgraphCycleError
|
||||
# is raised, confirming the fix for issue #1431.
|
||||
from cleveragents.actor.compiler import SubgraphCycleError
|
||||
from cleveragents.actor.schema import (
|
||||
ActorType,
|
||||
EdgeDefinition,
|
||||
NodeDefinition,
|
||||
NodeType,
|
||||
RouteDefinition,
|
||||
)
|
||||
|
||||
def _make_actor(
|
||||
name: str, subgraph_ref: str | None = None
|
||||
) -> ActorConfigSchema:
|
||||
if subgraph_ref is not None:
|
||||
nodes = [
|
||||
NodeDefinition(
|
||||
id="start",
|
||||
type=NodeType.AGENT,
|
||||
name="Start",
|
||||
description="Start",
|
||||
config={"agent": "default"},
|
||||
),
|
||||
NodeDefinition(
|
||||
id="sub",
|
||||
type=NodeType.SUBGRAPH,
|
||||
name="Sub",
|
||||
description="Subgraph",
|
||||
config={},
|
||||
actor_ref=subgraph_ref,
|
||||
),
|
||||
]
|
||||
edges = [EdgeDefinition(from_node="start", to_node="sub")]
|
||||
entry, exits = "start", ["sub"]
|
||||
else:
|
||||
nodes = [
|
||||
NodeDefinition(
|
||||
id="leaf",
|
||||
type=NodeType.AGENT,
|
||||
name="Leaf",
|
||||
description="Leaf",
|
||||
config={"agent": "default"},
|
||||
)
|
||||
]
|
||||
edges = []
|
||||
entry, exits = "leaf", ["leaf"]
|
||||
route = RouteDefinition(
|
||||
nodes=nodes, edges=edges, entry_node=entry, exit_nodes=exits
|
||||
)
|
||||
return ActorConfigSchema(
|
||||
name=name,
|
||||
type=ActorType.GRAPH,
|
||||
description=f"Test actor {name}",
|
||||
model="gpt-4",
|
||||
route=route,
|
||||
)
|
||||
|
||||
actor_a = _make_actor("test/actor-a", subgraph_ref="test/actor-b")
|
||||
actor_b = _make_actor("test/actor-b", subgraph_ref="test/actor-a")
|
||||
registry = {"test/actor-a": actor_a, "test/actor-b": actor_b}
|
||||
|
||||
try:
|
||||
compile_actor(actor_a, actor_resolver=registry.get)
|
||||
print(
|
||||
"actor-compiler-cycle-not-detected: FAIL — expected SubgraphCycleError"
|
||||
)
|
||||
return 1
|
||||
except SubgraphCycleError as exc:
|
||||
print(f"actor-compiler-cycle-detected: {exc}")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"actor-compiler-unexpected-error: {exc}")
|
||||
return 1
|
||||
|
||||
print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Helper script for Robot integration tests covering ``actor remove --format`` output.
|
||||
|
||||
Exercises the real ``agents`` CLI via subprocess — no mocking of any kind.
|
||||
A test actor is seeded via ``agents actor add``, then removed via
|
||||
``agents actor remove --format json``, and the resulting JSON envelope is
|
||||
validated against the spec.
|
||||
|
||||
Usage::
|
||||
|
||||
python helper_actor_remove_cli.py <command>
|
||||
|
||||
Where *command* is one of:
|
||||
|
||||
- ``remove-json`` — seed an actor, remove it with ``--format json``, validate envelope
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure src is importable when run from workspace root
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
# Ensure robot/ is on the import path for helper_e2e_common.
|
||||
_ROBOT = str(Path(__file__).resolve().parent)
|
||||
if _ROBOT not in sys.path:
|
||||
sys.path.insert(0, _ROBOT)
|
||||
|
||||
from helper_e2e_common import cleanup_workspace, run_cli, setup_workspace # noqa: E402
|
||||
|
||||
_ACTOR_NAME = "local/robot-remove-actor"
|
||||
|
||||
_ACTOR_CONFIG: dict[str, object] = {
|
||||
"name": _ACTOR_NAME,
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
}
|
||||
|
||||
|
||||
def _write_actor_config(workspace: str) -> str:
|
||||
"""Write actor config JSON to a temp file and return its path."""
|
||||
config_path = os.path.join(workspace, "robot_remove_actor.json")
|
||||
with open(config_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(_ACTOR_CONFIG, fh)
|
||||
return config_path
|
||||
|
||||
|
||||
def test_remove_format_json() -> None:
|
||||
"""Seed an actor via the real CLI, remove it with ``--format json``.
|
||||
|
||||
Validates the resulting JSON envelope against the spec.
|
||||
"""
|
||||
workspace = setup_workspace(prefix="robot_actor_remove_")
|
||||
try:
|
||||
config_path = _write_actor_config(workspace)
|
||||
|
||||
# Step 1: Add the actor using the real CLI.
|
||||
add_result = run_cli(
|
||||
"actor",
|
||||
"add",
|
||||
_ACTOR_NAME,
|
||||
"--config",
|
||||
config_path,
|
||||
workspace=workspace,
|
||||
)
|
||||
assert add_result.returncode == 0, (
|
||||
f"actor add failed (rc={add_result.returncode}):\n"
|
||||
f"stdout: {add_result.stdout}\nstderr: {add_result.stderr}"
|
||||
)
|
||||
|
||||
# Step 2: Remove the actor with --format json using the real CLI.
|
||||
remove_result = run_cli(
|
||||
"actor",
|
||||
"remove",
|
||||
_ACTOR_NAME,
|
||||
"--format",
|
||||
"json",
|
||||
workspace=workspace,
|
||||
)
|
||||
assert remove_result.returncode == 0, (
|
||||
f"actor remove --format json failed (rc={remove_result.returncode}):\n"
|
||||
f"stdout: {remove_result.stdout}\nstderr: {remove_result.stderr}"
|
||||
)
|
||||
|
||||
# Step 3: Parse and validate the JSON envelope.
|
||||
output = remove_result.stdout.strip()
|
||||
assert output, (
|
||||
f"actor remove --format json produced no output.\n"
|
||||
f"stderr: {remove_result.stderr}"
|
||||
)
|
||||
|
||||
payload = json.loads(output)
|
||||
|
||||
assert payload["command"] == f"agents actor remove {_ACTOR_NAME}", (
|
||||
f"Unexpected command field: {payload.get('command')!r}"
|
||||
)
|
||||
assert payload["status"] == "ok", (
|
||||
f"Unexpected status: {payload.get('status')!r}"
|
||||
)
|
||||
assert payload["exit_code"] == 0, (
|
||||
f"Unexpected exit_code: {payload.get('exit_code')!r}"
|
||||
)
|
||||
|
||||
data = payload["data"]
|
||||
|
||||
removed = data.get("actor_removed", {})
|
||||
assert removed.get("name") == _ACTOR_NAME, (
|
||||
f"actor_removed.name mismatch: {removed.get('name')!r}"
|
||||
)
|
||||
assert removed.get("provider") == _ACTOR_CONFIG["provider"], (
|
||||
f"actor_removed.provider mismatch: {removed.get('provider')!r}"
|
||||
)
|
||||
assert removed.get("model") == _ACTOR_CONFIG["model"], (
|
||||
f"actor_removed.model mismatch: {removed.get('model')!r}"
|
||||
)
|
||||
|
||||
impact = data.get("impact", {})
|
||||
assert "sessions" in impact, f"Missing 'sessions' in impact: {impact}"
|
||||
assert "active_plans" in impact, f"Missing 'active_plans' in impact: {impact}"
|
||||
assert "actions_referencing" in impact, (
|
||||
f"Missing 'actions_referencing' in impact: {impact}"
|
||||
)
|
||||
|
||||
cleanup = data.get("cleanup", {})
|
||||
assert cleanup.get("config") == "kept on disk", (
|
||||
f"cleanup.config mismatch: {cleanup.get('config')!r}"
|
||||
)
|
||||
assert "contexts" in cleanup, f"Missing 'contexts' in cleanup: {cleanup}"
|
||||
|
||||
messages = payload.get("messages", [])
|
||||
assert messages, f"Expected non-empty messages list, got: {messages}"
|
||||
assert messages[0].get("level") == "ok", (
|
||||
f"Unexpected message level: {messages[0].get('level')!r}"
|
||||
)
|
||||
assert "Actor removed" in messages[0].get("text", ""), (
|
||||
f"Expected 'Actor removed' in message text: {messages[0].get('text')!r}"
|
||||
)
|
||||
|
||||
print("actor-remove-json-format-ok")
|
||||
|
||||
finally:
|
||||
cleanup_workspace(workspace)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
command = sys.argv[1] if len(sys.argv) > 1 else "remove-json"
|
||||
dispatch: dict[str, object] = {
|
||||
"remove-json": test_remove_format_json,
|
||||
}
|
||||
handler = dispatch.get(command)
|
||||
if handler is None:
|
||||
print(f"Unknown command: {command}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
assert callable(handler)
|
||||
handler()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,26 +15,30 @@ def _import_lsp_types() -> None:
|
||||
LSP_RESOURCE_TYPES,
|
||||
)
|
||||
|
||||
assert len(LSP_RESOURCE_TYPES) == 4, f"Expected 4, got {len(LSP_RESOURCE_TYPES)}"
|
||||
assert len(LSP_RESOURCE_TYPES) == 3, f"Expected 3, got {len(LSP_RESOURCE_TYPES)}"
|
||||
names = [t["name"] for t in LSP_RESOURCE_TYPES]
|
||||
for expected in ("executable", "lsp-server", "lsp-workspace", "lsp-document"):
|
||||
for expected in ("lsp-server", "lsp-workspace", "lsp-document"):
|
||||
assert expected in names, f"{expected} not in LSP_RESOURCE_TYPES: {names}"
|
||||
|
||||
print("import-lsp-types-ok")
|
||||
|
||||
|
||||
def _check_builtin_names() -> None:
|
||||
"""Verify all 4 LSP types are in BUILTIN_NAMES."""
|
||||
"""Verify all 3 LSP types are in BUILTIN_NAMES."""
|
||||
from cleveragents.domain.models.core.resource_type import ResourceTypeSpec
|
||||
|
||||
for name in ("executable", "lsp-server", "lsp-workspace", "lsp-document"):
|
||||
for name in ("lsp-server", "lsp-workspace", "lsp-document"):
|
||||
assert name in ResourceTypeSpec.BUILTIN_NAMES, f"{name} not in BUILTIN_NAMES"
|
||||
|
||||
assert "executable" not in ResourceTypeSpec.BUILTIN_NAMES, (
|
||||
"executable should not be in BUILTIN_NAMES (not a spec-defined type)"
|
||||
)
|
||||
|
||||
print("check-builtin-names-ok")
|
||||
|
||||
|
||||
def _db_roundtrip() -> None:
|
||||
"""Bootstrap in-memory DB and verify all 4 LSP types are retrievable."""
|
||||
"""Bootstrap in-memory DB and verify all 3 LSP types are retrievable."""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
@@ -48,7 +52,7 @@ def _db_roundtrip() -> None:
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
svc = ResourceRegistryService(session_factory=factory)
|
||||
|
||||
for name in ("executable", "lsp-server", "lsp-workspace", "lsp-document"):
|
||||
for name in ("lsp-server", "lsp-workspace", "lsp-document"):
|
||||
spec = svc.show_type(name)
|
||||
assert spec is not None, f"{name} not found after bootstrap"
|
||||
assert str(spec.resource_kind) == "physical", (
|
||||
@@ -85,32 +89,27 @@ def _check_hierarchy() -> None:
|
||||
assert "lsp-workspace" in types["lsp-document"]["parent_types"], (
|
||||
"lsp-document missing lsp-workspace parent"
|
||||
)
|
||||
# executable has no children
|
||||
assert types["executable"]["child_types"] == [], (
|
||||
"executable should have no children"
|
||||
)
|
||||
|
||||
print("check-hierarchy-ok")
|
||||
|
||||
|
||||
def _check_auto_discovery() -> None:
|
||||
"""Verify executable has auto_discovery with trigger_types."""
|
||||
"""Verify lsp-workspace has auto_discovery with trigger_types."""
|
||||
from cleveragents.application.services._resource_registry_lsp import (
|
||||
LSP_RESOURCE_TYPES,
|
||||
)
|
||||
|
||||
types = {t["name"]: t for t in LSP_RESOURCE_TYPES}
|
||||
types = {t["name"] for t in LSP_RESOURCE_TYPES}
|
||||
|
||||
# executable has auto_discovery
|
||||
ad = types["executable"].get("auto_discovery")
|
||||
assert ad is not None, "executable missing auto_discovery"
|
||||
assert "container-exec-env" in ad.get("trigger_types", []), (
|
||||
"executable missing container-exec-env trigger"
|
||||
# executable should not be present
|
||||
assert "executable" not in types, (
|
||||
"executable should not be in LSP_RESOURCE_TYPES (not a spec-defined type)"
|
||||
)
|
||||
assert ad.get("lazy") is True, "executable auto_discovery should be lazy"
|
||||
|
||||
lsp_types = {t["name"]: t for t in LSP_RESOURCE_TYPES}
|
||||
|
||||
# lsp-workspace has auto_discovery from lsp-server
|
||||
ad_ws = types["lsp-workspace"].get("auto_discovery")
|
||||
ad_ws = lsp_types["lsp-workspace"].get("auto_discovery")
|
||||
assert ad_ws is not None, "lsp-workspace missing auto_discovery"
|
||||
assert "lsp-server" in ad_ws.get("trigger_types", []), (
|
||||
"lsp-workspace missing lsp-server trigger"
|
||||
|
||||
@@ -137,7 +137,7 @@ def _map_node(node: NodeDefinition) -> lg_nodes.NodeConfig:
|
||||
config.get("function") if node.type == NodeType.CONDITIONAL else None
|
||||
),
|
||||
tools=config.get("tools", []) if node.type == NodeType.TOOL else [],
|
||||
subgraph=(config.get("actor_ref") if node.type == NodeType.SUBGRAPH else None),
|
||||
subgraph=(node.actor_ref if node.type == NodeType.SUBGRAPH else None),
|
||||
metadata=dict(config),
|
||||
)
|
||||
|
||||
@@ -187,6 +187,11 @@ def _detect_subgraph_cycles(
|
||||
"""Detect cycles in subgraph references across actors.
|
||||
|
||||
Returns list of actor names forming a cycle, or empty list.
|
||||
|
||||
The actor reference is read from the top-level actor_ref field on
|
||||
NodeDefinition, not from node.config. Reading from config
|
||||
would always return an empty string because actor_ref is stored as a
|
||||
first-class field on the schema model.
|
||||
"""
|
||||
if resolver is None:
|
||||
return []
|
||||
@@ -194,7 +199,7 @@ def _detect_subgraph_cycles(
|
||||
for node in route_nodes:
|
||||
if node.type != NodeType.SUBGRAPH:
|
||||
continue
|
||||
ref_name = node.config.get("actor_ref", "")
|
||||
ref_name = node.actor_ref or ""
|
||||
if not ref_name:
|
||||
continue
|
||||
if ref_name in visited:
|
||||
@@ -290,7 +295,7 @@ def compile_actor(
|
||||
all_lsp_bindings.extend(bindings)
|
||||
|
||||
if node_def.type == NodeType.SUBGRAPH:
|
||||
ref = node_def.config.get("actor_ref", "")
|
||||
ref = node_def.actor_ref or ""
|
||||
if ref:
|
||||
subgraph_refs[node_def.id] = ref
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""LSP resource type definitions for the Resource Registry.
|
||||
|
||||
Contains the built-in LSP-related type entries (``executable``,
|
||||
``lsp-server``, ``lsp-workspace``, ``lsp-document``) that are appended
|
||||
to ``BUILTIN_TYPES`` in ``_resource_registry_data``.
|
||||
Contains the built-in LSP-related type entries (``lsp-server``,
|
||||
``lsp-workspace``, ``lsp-document``) that are appended to
|
||||
``BUILTIN_TYPES`` in ``_resource_registry_data``.
|
||||
|
||||
These types bridge the resource system with the LSP runtime, allowing
|
||||
actors to discover available language tooling and the LSP system to know
|
||||
which resources it operates on.
|
||||
|
||||
Spec reference: docs/adr/ADR-039-container-resource-types.md (executable),
|
||||
docs/adr/ADR-040-lsp-resource-types.md (lsp-server,
|
||||
Spec reference: docs/adr/ADR-040-lsp-resource-types.md (lsp-server,
|
||||
lsp-workspace, lsp-document)
|
||||
|
||||
.. note::
|
||||
@@ -28,52 +27,11 @@ __all__ = ["LSP_RESOURCE_TYPES"]
|
||||
# === LSP Resource Types (#832) ===
|
||||
#
|
||||
# These types model language tooling infrastructure in the resource DAG.
|
||||
# - executable: system binary / interpreter / LSP server binary
|
||||
# - lsp-server: a configured or running LSP server instance
|
||||
# - lsp-workspace: workspace root tracked by an LSP server
|
||||
# - lsp-document: text document tracked by an LSP server
|
||||
|
||||
LSP_RESOURCE_TYPES: list[dict[str, Any]] = [
|
||||
# ── executable ───────────────────────────────────────────────
|
||||
{
|
||||
"name": "executable",
|
||||
"description": (
|
||||
"System executable (language runtime, compiler, LSP server "
|
||||
"binary). Discovered from container or host PATH."
|
||||
),
|
||||
"resource_kind": "physical",
|
||||
"sandbox_strategy": "none",
|
||||
"user_addable": True,
|
||||
"built_in": True,
|
||||
"cli_args": [
|
||||
{
|
||||
"name": "path",
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"description": "Absolute path to the executable",
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": "Executable version if detectable",
|
||||
},
|
||||
],
|
||||
"parent_types": ["container-exec-env", "fs-directory"],
|
||||
"child_types": [],
|
||||
"handler": "cleveragents.resource.handlers.executable:ExecutableHandler",
|
||||
"auto_discovery": {
|
||||
"trigger_types": ["container-exec-env", "fs-directory"],
|
||||
"scan_paths": [],
|
||||
"lazy": True,
|
||||
},
|
||||
"capabilities": {
|
||||
"read": True,
|
||||
"write": False,
|
||||
"sandbox": False,
|
||||
"checkpoint": False,
|
||||
},
|
||||
},
|
||||
# ── lsp-server ───────────────────────────────────────────────
|
||||
{
|
||||
"name": "lsp-server",
|
||||
|
||||
@@ -816,12 +816,28 @@ def update(
|
||||
|
||||
|
||||
@app.command()
|
||||
def remove(name: Annotated[str, typer.Argument(help="Actor name to remove")]) -> None:
|
||||
def remove(
|
||||
name: Annotated[str, typer.Argument(help="Actor name to remove")],
|
||||
fmt: Annotated[
|
||||
str,
|
||||
typer.Option("--format", "-f", help=_FORMAT_HELP),
|
||||
] = OutputFormat.RICH.value,
|
||||
) -> None:
|
||||
"""Remove a custom actor.
|
||||
|
||||
Specify the namespaced name (e.g. ``local/my-actor``).
|
||||
"""
|
||||
|
||||
# Validate --format argument first; fail fast before any side effects.
|
||||
fmt_value = fmt.lower()
|
||||
_valid_formats = {f.value for f in OutputFormat}
|
||||
if fmt_value not in _valid_formats:
|
||||
raise typer.BadParameter(
|
||||
f"Invalid format {fmt!r}. "
|
||||
f"Supported values: {', '.join(sorted(_valid_formats))}",
|
||||
param_hint="'--format'",
|
||||
)
|
||||
|
||||
service, registry = _get_services()
|
||||
try:
|
||||
# Get actor details before removal for display
|
||||
@@ -844,6 +860,40 @@ def remove(name: Annotated[str, typer.Argument(help="Actor name to remove")]) ->
|
||||
else:
|
||||
service.remove_actor(name)
|
||||
|
||||
command_name = f"agents actor remove {name}"
|
||||
payload = {
|
||||
"actor_removed": {
|
||||
"name": name,
|
||||
"provider": actor_provider,
|
||||
"model": actor_model,
|
||||
},
|
||||
"impact": {
|
||||
"sessions": session_count,
|
||||
"active_plans": active_plan_count,
|
||||
"actions_referencing": action_count,
|
||||
},
|
||||
"cleanup": {
|
||||
"config": "kept on disk",
|
||||
# NOTE: context-cleanup count is deferred; always 0 for now.
|
||||
# Follow-up: implement dynamic orphaned-context detection.
|
||||
"contexts": "0 orphaned",
|
||||
},
|
||||
}
|
||||
messages = [{"level": "ok", "text": "Actor removed"}]
|
||||
|
||||
if fmt_value != OutputFormat.RICH.value:
|
||||
rendered = format_output(
|
||||
payload,
|
||||
fmt_value,
|
||||
command=command_name,
|
||||
status="ok",
|
||||
exit_code=0,
|
||||
messages=messages,
|
||||
)
|
||||
if rendered:
|
||||
console.print(rendered)
|
||||
return
|
||||
|
||||
# Display Actor Removed panel
|
||||
actor_info = (
|
||||
f"[cyan bold]Name:[/cyan bold] {name}\n"
|
||||
|
||||
@@ -12,7 +12,6 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
@@ -32,46 +31,6 @@ console = Console()
|
||||
|
||||
_FORMAT_HELP = "Output format: json, yaml, plain, table, rich, or color (default: rich)"
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def context_list(
|
||||
regex: Annotated[
|
||||
str | None,
|
||||
typer.Argument(help="Optional regex filter for context names"),
|
||||
] = None,
|
||||
context_dir: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--context-dir",
|
||||
help="Directory where contexts are stored",
|
||||
resolve_path=True,
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""List named actor contexts, optionally filtered by a regular expression."""
|
||||
|
||||
pattern: re.Pattern[str] | None = None
|
||||
if regex is not None:
|
||||
try:
|
||||
pattern = re.compile(regex)
|
||||
except re.error as exc:
|
||||
console.print(f"[red]Error:[/red] Invalid regex pattern: {exc}")
|
||||
raise typer.Abort() from exc
|
||||
|
||||
base = _default_context_base(context_dir)
|
||||
context_names = _list_context_names(base)
|
||||
|
||||
if pattern is not None:
|
||||
context_names = [name for name in context_names if pattern.search(name)]
|
||||
|
||||
if not context_names:
|
||||
typer.echo("No contexts found.")
|
||||
return
|
||||
|
||||
for name in context_names:
|
||||
typer.echo(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -9,7 +9,6 @@ Deprecated alias: ``agents context <subcommand>`` (emits deprecation warning)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
|
||||
@@ -397,10 +396,6 @@ def context_rm_deprecated(
|
||||
|
||||
@app.command("list")
|
||||
def context_list(
|
||||
regex: Annotated[
|
||||
str | None,
|
||||
typer.Argument(help="Optional regex filter for context names"),
|
||||
] = None,
|
||||
context_dir: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
@@ -414,14 +409,6 @@ def context_list(
|
||||
|
||||
Or list named contexts in a directory.
|
||||
"""
|
||||
pattern: re.Pattern[str] | None = None
|
||||
if regex is not None:
|
||||
try:
|
||||
pattern = re.compile(regex)
|
||||
except re.error as exc:
|
||||
console.print(f"[red]Error:[/red] Invalid regex pattern: {exc}")
|
||||
raise typer.Abort() from exc
|
||||
|
||||
if context_dir is not None:
|
||||
# List named contexts in the given directory
|
||||
ctx_base = context_dir
|
||||
@@ -430,8 +417,6 @@ def context_list(
|
||||
return
|
||||
|
||||
context_dirs = sorted([d.name for d in ctx_base.iterdir() if d.is_dir()])
|
||||
if pattern is not None:
|
||||
context_dirs = [name for name in context_dirs if pattern.search(name)]
|
||||
|
||||
if not context_dirs:
|
||||
typer.echo("No contexts found.")
|
||||
@@ -466,36 +451,24 @@ def context_list(
|
||||
console.print("Use 'agents actor context add <path>' to add files.")
|
||||
return
|
||||
|
||||
# Filter and normalize entries
|
||||
filtered_entries: list[tuple[str, str, str, str]] = []
|
||||
# Display context files
|
||||
table = Table(title=f"Context Files ({len(context_files)} total)")
|
||||
table.add_column("File Path", style="cyan")
|
||||
table.add_column("Type", style="green")
|
||||
table.add_column("Size", style="magenta")
|
||||
table.add_column("Added", style="yellow")
|
||||
|
||||
for file_info in context_files:
|
||||
path_text, type_label, size, added_at, _ = _normalize_context_entry(
|
||||
file_info
|
||||
)
|
||||
display_name = Path(path_text).name if path_text else ""
|
||||
|
||||
if pattern is not None and not pattern.search(display_name):
|
||||
continue
|
||||
|
||||
if isinstance(size, str):
|
||||
size_str = size
|
||||
else:
|
||||
size_str = f"{size:,} bytes" if size > 0 else "0 bytes"
|
||||
|
||||
filtered_entries.append((display_name, type_label, size_str, added_at))
|
||||
|
||||
if not filtered_entries:
|
||||
console.print("[yellow]No files matched the pattern.[/yellow]")
|
||||
return
|
||||
|
||||
# Display context files
|
||||
table = Table(title=f"Context Files ({len(filtered_entries)} total)")
|
||||
table.add_column("File Path", style="cyan")
|
||||
table.add_column("Type", style="green")
|
||||
table.add_column("Size", style="magenta")
|
||||
table.add_column("Added", style="yellow")
|
||||
|
||||
for display_name, type_label, size_str, added_at in filtered_entries:
|
||||
table.add_row(
|
||||
display_name,
|
||||
type_label,
|
||||
|
||||
@@ -125,7 +125,6 @@ BUILTIN_TYPE_NAMES: frozenset[str] = frozenset(
|
||||
"submodule",
|
||||
"symlink",
|
||||
# LSP resource types (#832)
|
||||
"executable",
|
||||
"lsp-server",
|
||||
"lsp-workspace",
|
||||
"lsp-document",
|
||||
|
||||
Reference in New Issue
Block a user