From d3b182e10e5dfd32a878ba7db31c69dd1c6cf322 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Mon, 2 Mar 2026 06:25:46 +0000 Subject: [PATCH] feat(acms): add context request protocol models Add CRP domain models for the Advanced Context Management System: - ContextRequest: Focus-driven context retrieval with breadth, depth, strategy, temporal_scope, and skeleton_ratio parameters - ContextFragment: Retrieved context with UKO URI, provenance, relevance score, token count, and detail level metadata - ContextBudget: Token budget management with reservation support - DetailLevel: Five-tier enum (skeleton through full) - DetailLevelMap: Name-to-integer resolution registry with inheritance Add builtin/context skill with stubbed tools (request_context, query_history, get_context_budget) wired to future ACMS pipeline. ISSUES CLOSED: #190 --- benchmarks/crp_model_bench.py | 137 ++++ docs/reference/crp.md | 181 +++++ features/crp_models.feature | 213 ++++++ features/steps/crp_models_steps.py | 623 ++++++++++++++++++ robot/crp_models.robot | 73 ++ robot/helper_crp_models.py | 156 +++++ .../domain/models/acms/__init__.py | 34 + src/cleveragents/domain/models/acms/crp.py | 445 +++++++++++++ src/cleveragents/skills/builtins/__init__.py | 15 +- .../skills/builtins/context_ops.py | 216 ++++++ 10 files changed, 2087 insertions(+), 6 deletions(-) create mode 100644 benchmarks/crp_model_bench.py create mode 100644 docs/reference/crp.md create mode 100644 features/crp_models.feature create mode 100644 features/steps/crp_models_steps.py create mode 100644 robot/crp_models.robot create mode 100644 robot/helper_crp_models.py create mode 100644 src/cleveragents/domain/models/acms/__init__.py create mode 100644 src/cleveragents/domain/models/acms/crp.py create mode 100644 src/cleveragents/skills/builtins/context_ops.py diff --git a/benchmarks/crp_model_bench.py b/benchmarks/crp_model_bench.py new file mode 100644 index 000000000..e91fadb28 --- /dev/null +++ b/benchmarks/crp_model_bench.py @@ -0,0 +1,137 @@ +"""ASV benchmarks for CRP domain model validation throughput. + +Measures the performance of: +- ContextRequest creation and validation +- ContextFragment creation and validation +- ContextBudget creation and validation +- DetailLevelMap resolution (integer and named) +- AssembledContext creation +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +# Ensure the local *source* tree is importable even when ASV has an +# older build of the package installed. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +# Force-reload the top-level package so Python picks up the source tree +# version instead of the potentially stale installed copy. +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.domain.models.acms.crp import ( # noqa: E402 + AssembledContext, + ContextBudget, + ContextFragment, + ContextRequest, + DetailLevelMap, + FragmentProvenance, +) + + +class TimeCRPModelCreation: + """Benchmark CRP model creation throughput.""" + + timeout = 60 + + def setup(self) -> None: + """Prepare reusable inputs.""" + self.provenance = FragmentProvenance( + resource_uri="uko-py:module/auth", + location="lines 1-50", + strategy="simple-keyword", + ) + self.dlm = DetailLevelMap( + domain="uko-code:", + max_depth=9, + levels={ + "MODULE_LISTING": 0, + "MODULE_GRAPH": 1, + "MEMBER_LISTING": 2, + "MEMBER_SUMMARY": 3, + "SIGNATURES": 4, + "SIGNATURES_WITH_DOCS": 5, + "STRUCTURAL_OUTLINE": 6, + "KEY_LOGIC": 7, + "NEAR_COMPLETE": 8, + "FULL_SOURCE": 9, + }, + ) + + def time_context_request_defaults(self) -> None: + """Create ContextRequest with defaults.""" + for _ in range(1000): + ContextRequest(purpose="benchmark") + + def time_context_request_full(self) -> None: + """Create ContextRequest with all fields populated.""" + for _ in range(1000): + ContextRequest( + query="auth flow", + entities=["AuthManager"], + uko_types=["uko-py:Class"], + focus=["uko-py:class/AuthManager"], + breadth=3, + depth=4, + depth_gradient=True, + max_tokens=8000, + preferred_strategies=["arce", "semantic-embedding"], + required_backends=["vector", "graph"], + priority=0.8, + purpose="Understand auth flow", + ) + + def time_context_fragment(self) -> None: + """Create ContextFragment with provenance.""" + for _ in range(1000): + ContextFragment( + uko_node="uko-py:class/AuthManager", + content="class AuthManager: ...", + detail_depth=9, + token_count=800, + relevance_score=0.95, + provenance=self.provenance, + ) + + def time_context_budget(self) -> None: + """Create ContextBudget and access available_tokens.""" + for _ in range(1000): + b = ContextBudget(max_tokens=8000, reserved_tokens=1000) + _ = b.available_tokens + + def time_detail_level_map_resolve_int(self) -> None: + """Resolve integer depths via DetailLevelMap.""" + for i in range(1000): + self.dlm.resolve(i % 15) + + def time_detail_level_map_resolve_named(self) -> None: + """Resolve named levels via DetailLevelMap.""" + names: list[str] = list(self.dlm.levels.keys()) + for i in range(1000): + self.dlm.resolve(names[i % len(names)]) + + def time_assembled_context(self) -> None: + """Create AssembledContext.""" + for _ in range(1000): + AssembledContext( + total_tokens=1500, + budget_used=0.75, + context_hash="abc123", + strategies_used=["simple-keyword", "arce"], + ) + + def time_fragment_provenance(self) -> None: + """Create FragmentProvenance.""" + for _ in range(1000): + FragmentProvenance( + resource_uri="uko-py:module/auth", + location="lines 1-50", + strategy="arce", + ) diff --git a/docs/reference/crp.md b/docs/reference/crp.md new file mode 100644 index 000000000..d767b0b5a --- /dev/null +++ b/docs/reference/crp.md @@ -0,0 +1,181 @@ +# Context Request Protocol (CRP) Models + +The CRP provides the structured vocabulary through which actors (via +skills) declare what information they need, at what level of detail, +and with what scope, while they are reasoning. + +## Core Data Types + +### DetailLevelMap + +Maps named detail levels to integer depths for a UKO domain. +Inheritance allows child maps to include all entries from their parent +map and insert additional levels at any integer position. + +| Field | Type | Description | +|-------------|-------------------------|-------------------------------------------------| +| `domain` | `str` | UKO namespace (e.g., `"uko-code:"`, `"uko-py:"`)| +| `parent` | `DetailLevelMap \| None`| Parent map to inherit from | +| `levels` | `dict[str, int]` | Named level -> integer depth mapping | +| `max_depth` | `int` | Maximum meaningful depth for this domain (>= 0) | + +**Methods:** + +- `resolve(depth: int | str) -> int` -- Resolve a named level or integer to + an integer depth. Integer depths are clamped to `max_depth`. +- `register(name: str, value: int) -> None` -- Register a custom detail level. +- `effective_levels() -> dict[str, int]` -- Return the full merged level map + including inherited entries. + +### ContextRequest + +A structured request for context, issued by an actor or skill via the +CRP. Each request triggers a context assembly cycle in the ACMS +pipeline. + +| Field | Type | Default | Description | +|------------------------|------------------|----------------------|---------------------------------------------| +| `query` | `str \| None` | `None` | Natural language query | +| `entities` | `list[str]` | `[]` | Named entities to focus on | +| `uko_types` | `list[str]` | `[]` | UKO types to filter | +| `focus` | `list[str]` | `[]` | URIs or identifiers to focus on | +| `breadth` | `int` | `2` | Dependency hops outward (>= 0) | +| `depth` | `int \| str` | `3` | Detail depth -- integer or named level | +| `depth_gradient` | `bool` | `True` | Items closer to focus get more detail | +| `temporal` | `TemporalScope` | `CURRENT` | Temporal scope for retrieval | +| `max_tokens` | `int \| None` | `None` | Maximum token budget (>= 0) | +| `preferred_strategies` | `list[str]` | `[]` | Preferred context strategies | +| `required_backends` | `list[str]` | `[]` | Required data backends | +| `priority` | `float` | `0.5` | 0.0 = background, 1.0 = critical | +| `purpose` | `str` | `""` | Why this context is needed | + +### ContextFragment + +The atomic unit of context returned by strategies and consumed by the +Context Assembly Pipeline. + +| Field | Type | Description | +|-------------------|----------------------|-------------------------------------| +| `uko_node` | `str` | UKO URI of the source node | +| `content` | `str` | Rendered text content | +| `detail_depth` | `int` | Resolved integer depth (>= 0) | +| `token_count` | `int` | Token count of content (>= 0) | +| `relevance_score` | `float` | 0.0 -- 1.0 relevance to request | +| `provenance` | `FragmentProvenance` | Trace back to resource + location | +| `metadata` | `dict[str, Any]` | Strategy-specific metadata | + +### FragmentProvenance + +Links a fragment back to the originating resource and location. + +| Field | Type | Default | Description | +|----------------|-------|---------|--------------------------------------| +| `resource_uri` | `str` | -- | URI of the originating resource | +| `location` | `str` | `""` | Location within the resource | +| `strategy` | `str` | `""` | Strategy that produced this fragment | + +### ContextBudget + +Token budget management with reservation support. + +| Field | Type | Default | Description | +|-------------------|-------|---------|----------------------------------------| +| `max_tokens` | `int` | -- | Maximum total token budget (>= 0) | +| `reserved_tokens` | `int` | `0` | Reserved tokens (<= `max_tokens`) | + +**Properties:** + +- `available_tokens: int` -- `max_tokens - reserved_tokens` + +### AssembledContext + +The fused, budget-respecting context payload delivered to an actor. + +| Field | Type | Description | +|-------------------|------------------------|--------------------------------------| +| `fragments` | `list[ContextFragment]`| Ordered context fragments | +| `total_tokens` | `int` | Total token count (>= 0) | +| `budget_used` | `float` | Budget fraction consumed (0.0--1.0) | +| `strategies_used` | `list[str]` | Contributing strategies | +| `context_hash` | `str` | Cryptographic hash for snapshot | +| `preamble` | `str \| None` | Optional structure summary | +| `provenance_map` | `dict[str, Any]` | Fragment -> resource/location map | + +## Detail Level Table (Universal Layer 0) + +| Depth | Question Answered | What Gets Included | +|-------|--------------------------------|-----------------------------------------| +| 0 | *What exists?* | Name/identifier of the information unit | +| 1 | *How is it organized?* | Names of immediate children | +| 2 | *What are the key relationships?* | Children + dependency/reference edges| +| 3 | *What is each thing's purpose?* | + Short descriptions/summaries | +| 4 | *What is the structural shape?* | + Type info, size/count metadata | +| 5+ | *How does it work?* | Progressively more content | +| max | *Everything.* | Complete content -- nothing omitted | + +## Example Usage + +```python +from cleveragents.domain.models.acms.crp import ( + ContextBudget, + ContextFragment, + ContextRequest, + DetailLevelMap, + FragmentProvenance, +) + +# Create a detail level map for source code +code_map = DetailLevelMap( + domain="uko-code:", + max_depth=9, + levels={ + "MODULE_LISTING": 0, + "SIGNATURES": 4, + "FULL_SOURCE": 9, + }, +) + +# Resolve a named level +depth = code_map.resolve("SIGNATURES") # -> 4 + +# Create a context request +request = ContextRequest( + query="How does authentication work?", + focus=["uko-py:class/AuthManager"], + breadth=2, + depth="SIGNATURES", + purpose="Understand the auth flow for refactoring", +) + +# Create a budget +budget = ContextBudget(max_tokens=8000, reserved_tokens=1000) +print(budget.available_tokens) # 7000 + +# Create a fragment +fragment = ContextFragment( + uko_node="uko-py:class/AuthManager", + content="class AuthManager: ...", + detail_depth=4, + token_count=120, + relevance_score=0.95, + provenance=FragmentProvenance( + resource_uri="uko-py:module/auth", + location="lines 1-50", + strategy="breadth-depth-navigator", + ), +) +``` + +## Built-in Context Skill + +The `builtin/context` skill is automatically injected into every +actor's skill set. It provides three tools: + +| Tool | Description | +|-------------------------|-----------------------------------------------| +| `request_context` | Request specific context during reasoning | +| `query_history` | Query historical context about past decisions | +| `get_context_budget` | Check remaining context token budget | + +These tools are currently stubbed (`NotImplementedError`) pending the +full ACMS Context Assembly Pipeline implementation. diff --git a/features/crp_models.feature b/features/crp_models.feature new file mode 100644 index 000000000..e6a11d78d --- /dev/null +++ b/features/crp_models.feature @@ -0,0 +1,213 @@ +Feature: CRP (Context Request Protocol) Domain Models + As a developer + I want CRP domain models for the Advanced Context Management System + So that actors can issue structured context requests during reasoning + + # ---- DetailLevelMap ---- + + Scenario: Create a DetailLevelMap with valid levels + Given a DetailLevelMap for domain "uko-code:" with max depth 9 + And the map has level "MODULE_LISTING" at depth 0 + And the map has level "FULL_SOURCE" at depth 9 + Then the map should resolve "MODULE_LISTING" to 0 + And the map should resolve "FULL_SOURCE" to 9 + + Scenario: DetailLevelMap clamps integer depth to max_depth + Given a DetailLevelMap for domain "uko-code:" with max depth 9 + Then the map should resolve integer 15 to 9 + And the map should resolve integer 0 to 0 + And the map should resolve integer 5 to 5 + + Scenario: DetailLevelMap resolves from parent map + Given a parent DetailLevelMap for domain "uko-code:" with max depth 9 + And the parent map has level "MODULE_LISTING" at depth 0 + And a child DetailLevelMap for domain "uko-oo:" with max depth 11 + And the child map has level "CLASS_HIERARCHY" at depth 3 + Then the child map should resolve "MODULE_LISTING" to 0 + And the child map should resolve "CLASS_HIERARCHY" to 3 + + Scenario: DetailLevelMap raises on unknown named level + Given a DetailLevelMap for domain "uko-code:" with max depth 9 + Then resolving "NONEXISTENT" should raise ValueError + + Scenario: DetailLevelMap register adds custom level + Given a DetailLevelMap for domain "uko-code:" with max depth 9 + When I register level "CUSTOM_LEVEL" at depth 7 + Then the map should resolve "CUSTOM_LEVEL" to 7 + + Scenario: DetailLevelMap rejects negative depth in levels + Then creating a DetailLevelMap with level "BAD" at depth -1 should raise ValueError + + Scenario: DetailLevelMap register rejects negative depth + Given a DetailLevelMap for domain "uko-code:" with max depth 9 + Then registering level "BAD" at depth -1 should raise ValueError + + Scenario: DetailLevelMap effective_levels merges parent and child + Given a parent DetailLevelMap for domain "uko-code:" with max depth 9 + And the parent map has level "MODULE_LISTING" at depth 0 + And the parent map has level "FULL_SOURCE" at depth 9 + And a child DetailLevelMap for domain "uko-oo:" with max depth 11 + And the child map has level "CLASS_HIERARCHY" at depth 3 + Then the child effective_levels should contain "MODULE_LISTING" at 0 + And the child effective_levels should contain "FULL_SOURCE" at 9 + And the child effective_levels should contain "CLASS_HIERARCHY" at 3 + + Scenario: DetailLevelMap created with pre-populated levels dict + Given a DetailLevelMap for domain "uko-code:" with max depth 9 and levels "SIGNATURES" at 4 + Then the map should resolve "SIGNATURES" to 4 + + # ---- FragmentProvenance ---- + + Scenario: Create a valid FragmentProvenance + Given a FragmentProvenance with resource URI "uko-py:module/auth" + Then the provenance resource_uri should be "uko-py:module/auth" + And the provenance location should be empty + And the provenance strategy should be empty + + Scenario: FragmentProvenance rejects empty resource_uri + Then creating a FragmentProvenance with empty resource_uri should raise ValueError + + # ---- ContextBudget ---- + + Scenario: Create a valid ContextBudget + Given a ContextBudget with max_tokens 8000 and reserved_tokens 1000 + Then the budget available_tokens should be 7000 + And the budget max_tokens should be 8000 + And the budget reserved_tokens should be 1000 + + Scenario: ContextBudget with zero reservation + Given a ContextBudget with max_tokens 4000 and reserved_tokens 0 + Then the budget available_tokens should be 4000 + + Scenario: ContextBudget rejects negative max_tokens + Then creating a ContextBudget with max_tokens -1 should raise ValueError + + Scenario: ContextBudget rejects negative reserved_tokens + Then creating a ContextBudget with max_tokens 100 and reserved_tokens -5 should raise ValueError + + Scenario: ContextBudget rejects reserved exceeding max + Then creating a ContextBudget with max_tokens 100 and reserved_tokens 200 should raise ValueError + + # ---- ContextFragment ---- + + Scenario: Create a valid ContextFragment + Given a ContextFragment with uko_node "uko-py:class/AuthManager" and content "class AuthManager: ..." + And the fragment has detail_depth 9 and token_count 800 + And the fragment has relevance_score 0.95 + Then the fragment uko_node should be "uko-py:class/AuthManager" + And the fragment token_count should be 800 + And the fragment relevance_score should be 0.95 + And the fragment detail_depth should be 9 + + Scenario: ContextFragment rejects negative token_count + Then creating a ContextFragment with token_count -1 should raise ValueError + + Scenario: ContextFragment rejects relevance_score above 1.0 + Then creating a ContextFragment with relevance_score 1.5 should raise ValueError + + Scenario: ContextFragment rejects relevance_score below 0.0 + Then creating a ContextFragment with relevance_score -0.1 should raise ValueError + + Scenario: ContextFragment rejects negative detail_depth + Then creating a ContextFragment with detail_depth -1 should raise ValueError + + Scenario: ContextFragment rejects empty uko_node + Then creating a ContextFragment with empty uko_node should raise ValueError + + # ---- ContextRequest ---- + + Scenario: Create a ContextRequest with defaults + Given a ContextRequest with purpose "Understand the auth module" + Then the request purpose should be "Understand the auth module" + And the request breadth should be 2 + And the request depth should be 3 + And the request depth_gradient should be true + And the request priority should be 0.5 + And the request temporal should be "current" + And the request query should be None + And the request max_tokens should be None + + Scenario: Create a ContextRequest with all fields + Given a full ContextRequest with query "auth flow" and focus "uko-py:class/AuthManager" + And the request has breadth 3 and depth "SIGNATURES" + And the request has priority 0.8 and purpose "Review auth" + Then the request query should be "auth flow" + And the request breadth should be 3 + And the request depth should be "SIGNATURES" + And the request priority should be 0.8 + + Scenario: ContextRequest rejects negative breadth + Then creating a ContextRequest with breadth -1 should raise ValueError + + Scenario: ContextRequest rejects negative integer depth + Then creating a ContextRequest with depth -1 should raise ValueError + + Scenario: ContextRequest rejects priority above 1.0 + Then creating a ContextRequest with priority 1.5 should raise ValueError + + Scenario: ContextRequest rejects priority below 0.0 + Then creating a ContextRequest with priority -0.1 should raise ValueError + + Scenario: ContextRequest accepts string depth + Given a ContextRequest with depth "FULL_SOURCE" + Then the request depth should be "FULL_SOURCE" + + Scenario: ContextRequest accepts zero breadth + Given a ContextRequest with breadth 0 + Then the request breadth should be 0 + + Scenario: ContextRequest accepts max_tokens + Given a ContextRequest with max_tokens 4096 + Then the request max_tokens should be 4096 + + Scenario: ContextRequest rejects negative max_tokens + Then creating a ContextRequest with max_tokens -100 should raise ValueError + + # ---- AssembledContext ---- + + Scenario: Create a valid AssembledContext + Given an AssembledContext with total_tokens 1500 and budget_used 0.75 + And the assembled context has hash "abc123def" + Then the assembled total_tokens should be 1500 + And the assembled budget_used should be 0.75 + And the assembled context_hash should be "abc123def" + + Scenario: AssembledContext rejects budget_used above 1.0 + Then creating an AssembledContext with budget_used 1.5 should raise ValueError + + Scenario: AssembledContext rejects negative total_tokens + Then creating an AssembledContext with total_tokens -1 should raise ValueError + + # ---- Context Skill Tool Stubs ---- + + Scenario: request_context tool raises NotImplementedError + When I call the request_context tool handler + Then it should raise NotImplementedError with message containing "ACMS pipeline" + + Scenario: query_history tool raises NotImplementedError + When I call the query_history tool handler + Then it should raise NotImplementedError with message containing "ACMS pipeline" + + Scenario: get_context_budget tool raises NotImplementedError + When I call the get_context_budget tool handler + Then it should raise NotImplementedError with message containing "ACMS pipeline" + + Scenario: Context skill tools are registered in the tool registry + Given a CRP tool registry with context tools registered + Then the CRP registry should contain tool "builtin/request-context" + And the CRP registry should contain tool "builtin/query-history" + And the CRP registry should contain tool "builtin/get-context-budget" + + Scenario: Context skill tools have read-only capability + Given a CRP tool registry with context tools registered + Then CRP tool "builtin/request-context" should have read_only capability + And CRP tool "builtin/query-history" should have read_only capability + And CRP tool "builtin/get-context-budget" should have read_only capability + + Scenario: request_context tool requires purpose in schema + Given a CRP tool registry with context tools registered + Then CRP tool "builtin/request-context" should require "purpose" in input schema + + Scenario: query_history tool requires query in schema + Given a CRP tool registry with context tools registered + Then CRP tool "builtin/query-history" should require "query" in input schema diff --git a/features/steps/crp_models_steps.py b/features/steps/crp_models_steps.py new file mode 100644 index 000000000..1636d7326 --- /dev/null +++ b/features/steps/crp_models_steps.py @@ -0,0 +1,623 @@ +"""Step definitions for the CRP Domain Models feature.""" + +from __future__ import annotations + +from typing import Any + +from behave import given, then, when +from pydantic import ValidationError + +from cleveragents.domain.models.acms.crp import ( + AssembledContext, + ContextBudget, + ContextFragment, + ContextRequest, + DetailLevelMap, + FragmentProvenance, +) +from cleveragents.domain.models.core.project import TemporalScope +from cleveragents.skills.builtins.context_ops import ( + _handle_get_context_budget, + _handle_query_history, + _handle_request_context, + register_skill_context_tools, +) +from cleveragents.tool.registry import ToolRegistry + +__all__: list[str] = [] + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _make_provenance( + resource_uri: str = "uko-py:module/test", +) -> FragmentProvenance: + """Create a simple provenance for test fragments.""" + return FragmentProvenance(resource_uri=resource_uri) + + +# --------------------------------------------------------------------------- +# DetailLevelMap +# --------------------------------------------------------------------------- + + +@given('a DetailLevelMap for domain "{domain}" with max depth {max_depth:d}') +def step_given_detail_level_map(context: Any, domain: str, max_depth: int) -> None: + context.detail_level_map = DetailLevelMap(domain=domain, max_depth=max_depth) + + +@given('the map has level "{name}" at depth {depth:d}') +def step_given_map_has_level(context: Any, name: str, depth: int) -> None: + context.detail_level_map.register(name, depth) + + +@then('the map should resolve "{name}" to {expected:d}') +def step_then_map_resolve_name(context: Any, name: str, expected: int) -> None: + actual: int = context.detail_level_map.resolve(name) + assert actual == expected, f"Expected {expected}, got {actual}" + + +@then("the map should resolve integer {value:d} to {expected:d}") +def step_then_map_resolve_int(context: Any, value: int, expected: int) -> None: + actual: int = context.detail_level_map.resolve(value) + assert actual == expected, f"Expected {expected}, got {actual}" + + +@given('a parent DetailLevelMap for domain "{domain}" with max depth {max_depth:d}') +def step_given_parent_map(context: Any, domain: str, max_depth: int) -> None: + context.parent_detail_level_map = DetailLevelMap(domain=domain, max_depth=max_depth) + + +@given('the parent map has level "{name}" at depth {depth:d}') +def step_given_parent_level(context: Any, name: str, depth: int) -> None: + context.parent_detail_level_map.register(name, depth) + + +@given('a child DetailLevelMap for domain "{domain}" with max depth {max_depth:d}') +def step_given_child_map(context: Any, domain: str, max_depth: int) -> None: + context.child_detail_level_map = DetailLevelMap( + domain=domain, + max_depth=max_depth, + parent=context.parent_detail_level_map, + ) + + +@given('the child map has level "{name}" at depth {depth:d}') +def step_given_child_level(context: Any, name: str, depth: int) -> None: + context.child_detail_level_map.register(name, depth) + + +@then('the child map should resolve "{name}" to {expected:d}') +def step_then_child_resolve(context: Any, name: str, expected: int) -> None: + actual: int = context.child_detail_level_map.resolve(name) + assert actual == expected, f"Expected {expected}, got {actual}" + + +@then('resolving "{name}" should raise ValueError') +def step_then_resolve_raises(context: Any, name: str) -> None: + try: + context.detail_level_map.resolve(name) + raise AssertionError(f"Expected ValueError for '{name}'") + except ValueError: + pass + + +@when('I register level "{name}" at depth {depth:d}') +def step_when_register_level(context: Any, name: str, depth: int) -> None: + context.detail_level_map.register(name, depth) + + +@then( + 'creating a DetailLevelMap with level "{name}" at depth {depth:d} ' + "should raise ValueError" +) +def step_then_bad_level_raises(context: Any, name: str, depth: int) -> None: + try: + DetailLevelMap(domain="test:", max_depth=9, levels={name: depth}) + raise AssertionError("Expected ValueError") + except (ValueError, ValidationError): + pass + + +@then('registering level "{name}" at depth {depth:d} should raise ValueError') +def step_then_register_bad_raises(context: Any, name: str, depth: int) -> None: + try: + context.detail_level_map.register(name, depth) + raise AssertionError("Expected ValueError") + except ValueError: + pass + + +@given( + 'a DetailLevelMap for domain "{domain}" with max depth {max_depth:d} ' + 'and levels "{name}" at {depth:d}' +) +def step_given_detail_level_map_with_levels( + context: Any, domain: str, max_depth: int, name: str, depth: int +) -> None: + context.detail_level_map = DetailLevelMap( + domain=domain, max_depth=max_depth, levels={name: depth} + ) + + +@then('the child effective_levels should contain "{name}" at {expected:d}') +def step_then_effective_levels(context: Any, name: str, expected: int) -> None: + levels: dict[str, int] = context.child_detail_level_map.effective_levels() + assert name in levels, f"'{name}' not in effective_levels" + assert levels[name] == expected, ( + f"Expected {expected} for '{name}', got {levels[name]}" + ) + + +# --------------------------------------------------------------------------- +# FragmentProvenance +# --------------------------------------------------------------------------- + + +@given('a FragmentProvenance with resource URI "{uri}"') +def step_given_provenance(context: Any, uri: str) -> None: + context.provenance = FragmentProvenance(resource_uri=uri) + + +@then('the provenance resource_uri should be "{expected}"') +def step_then_provenance_uri(context: Any, expected: str) -> None: + assert context.provenance.resource_uri == expected + + +@then("the provenance location should be empty") +def step_then_provenance_location_empty(context: Any) -> None: + assert context.provenance.location == "" + + +@then("the provenance strategy should be empty") +def step_then_provenance_strategy_empty(context: Any) -> None: + assert context.provenance.strategy == "" + + +@then("creating a FragmentProvenance with empty resource_uri should raise ValueError") +def step_then_provenance_empty_uri(context: Any) -> None: + try: + FragmentProvenance(resource_uri="") + raise AssertionError("Expected ValueError") + except (ValueError, ValidationError): + pass + + +# --------------------------------------------------------------------------- +# ContextBudget +# --------------------------------------------------------------------------- + + +@given("a ContextBudget with max_tokens {max_tok:d} and reserved_tokens {res:d}") +def step_given_budget(context: Any, max_tok: int, res: int) -> None: + context.budget = ContextBudget(max_tokens=max_tok, reserved_tokens=res) + + +@then("the budget available_tokens should be {expected:d}") +def step_then_budget_available(context: Any, expected: int) -> None: + assert context.budget.available_tokens == expected + + +@then("the budget max_tokens should be {expected:d}") +def step_then_budget_max(context: Any, expected: int) -> None: + assert context.budget.max_tokens == expected + + +@then("the budget reserved_tokens should be {expected:d}") +def step_then_budget_reserved(context: Any, expected: int) -> None: + assert context.budget.reserved_tokens == expected + + +@then("creating a ContextBudget with max_tokens {max_tok:d} should raise ValueError") +def step_then_budget_bad_max(context: Any, max_tok: int) -> None: + try: + ContextBudget(max_tokens=max_tok) + raise AssertionError("Expected ValueError") + except (ValueError, ValidationError): + pass + + +@then( + "creating a ContextBudget with max_tokens {max_tok:d} " + "and reserved_tokens {res:d} should raise ValueError" +) +def step_then_budget_bad_reserved(context: Any, max_tok: int, res: int) -> None: + try: + ContextBudget(max_tokens=max_tok, reserved_tokens=res) + raise AssertionError("Expected ValueError") + except (ValueError, ValidationError): + pass + + +# --------------------------------------------------------------------------- +# ContextFragment +# --------------------------------------------------------------------------- + + +@given('a ContextFragment with uko_node "{node}" and content "{content}"') +def step_given_fragment(context: Any, node: str, content: str) -> None: + context.fragment_node = node + context.fragment_content = content + context.fragment_detail_depth = 0 + context.fragment_token_count = 0 + context.fragment_relevance = 0.5 + + +@given("the fragment has detail_depth {depth:d} and token_count {tokens:d}") +def step_given_fragment_depth_tokens(context: Any, depth: int, tokens: int) -> None: + context.fragment_detail_depth = depth + context.fragment_token_count = tokens + + +@given("the fragment has relevance_score {score:g}") +def step_given_fragment_relevance(context: Any, score: float) -> None: + context.fragment_relevance = score + context.fragment = ContextFragment( + uko_node=context.fragment_node, + content=context.fragment_content, + detail_depth=context.fragment_detail_depth, + token_count=context.fragment_token_count, + relevance_score=context.fragment_relevance, + provenance=_make_provenance(), + ) + + +@then('the fragment uko_node should be "{expected}"') +def step_then_fragment_node(context: Any, expected: str) -> None: + assert context.fragment.uko_node == expected + + +@then("the fragment token_count should be {expected:d}") +def step_then_fragment_tokens(context: Any, expected: int) -> None: + assert context.fragment.token_count == expected + + +@then("the fragment relevance_score should be {expected:g}") +def step_then_fragment_relevance(context: Any, expected: float) -> None: + assert context.fragment.relevance_score == expected + + +@then("the fragment detail_depth should be {expected:d}") +def step_then_fragment_depth(context: Any, expected: int) -> None: + assert context.fragment.detail_depth == expected + + +@then("creating a ContextFragment with token_count {count:d} should raise ValueError") +def step_then_fragment_bad_tokens(context: Any, count: int) -> None: + try: + ContextFragment( + uko_node="uko-py:test", + content="test", + detail_depth=0, + token_count=count, + relevance_score=0.5, + provenance=_make_provenance(), + ) + raise AssertionError("Expected ValueError") + except (ValueError, ValidationError): + pass + + +@then( + "creating a ContextFragment with relevance_score {score:g} should raise ValueError" +) +def step_then_fragment_bad_relevance(context: Any, score: float) -> None: + try: + ContextFragment( + uko_node="uko-py:test", + content="test", + detail_depth=0, + token_count=10, + relevance_score=score, + provenance=_make_provenance(), + ) + raise AssertionError("Expected ValueError") + except (ValueError, ValidationError): + pass + + +@then("creating a ContextFragment with detail_depth {depth:d} should raise ValueError") +def step_then_fragment_bad_depth(context: Any, depth: int) -> None: + try: + ContextFragment( + uko_node="uko-py:test", + content="test", + detail_depth=depth, + token_count=10, + relevance_score=0.5, + provenance=_make_provenance(), + ) + raise AssertionError("Expected ValueError") + except (ValueError, ValidationError): + pass + + +@then("creating a ContextFragment with empty uko_node should raise ValueError") +def step_then_fragment_empty_node(context: Any) -> None: + try: + ContextFragment( + uko_node="", + content="test", + detail_depth=0, + token_count=10, + relevance_score=0.5, + provenance=_make_provenance(), + ) + raise AssertionError("Expected ValueError") + except (ValueError, ValidationError): + pass + + +# --------------------------------------------------------------------------- +# ContextRequest +# --------------------------------------------------------------------------- + + +@given('a ContextRequest with purpose "{purpose}"') +def step_given_request(context: Any, purpose: str) -> None: + context.ctx_request = ContextRequest(purpose=purpose) + + +@given('a full ContextRequest with query "{query}" and focus "{focus}"') +def step_given_full_request(context: Any, query: str, focus: str) -> None: + context.ctx_request = ContextRequest( + query=query, + focus=[focus], + ) + + +@given('the request has breadth {breadth:d} and depth "{depth}"') +def step_given_request_breadth_depth_str( + context: Any, breadth: int, depth: str +) -> None: + context.ctx_request = context.ctx_request.model_copy( + update={"breadth": breadth, "depth": depth} + ) + + +@given('the request has priority {priority:g} and purpose "{purpose}"') +def step_given_request_priority_purpose( + context: Any, priority: float, purpose: str +) -> None: + context.ctx_request = context.ctx_request.model_copy( + update={"priority": priority, "purpose": purpose} + ) + + +@given('a ContextRequest with depth "{depth}"') +def step_given_request_depth_str(context: Any, depth: str) -> None: + context.ctx_request = ContextRequest(depth=depth) + + +@given("a ContextRequest with breadth {breadth:d}") +def step_given_request_breadth(context: Any, breadth: int) -> None: + context.ctx_request = ContextRequest(breadth=breadth) + + +@given("a ContextRequest with max_tokens {max_tok:d}") +def step_given_request_max_tokens(context: Any, max_tok: int) -> None: + context.ctx_request = ContextRequest(max_tokens=max_tok) + + +@then('the request purpose should be "{expected}"') +def step_then_request_purpose(context: Any, expected: str) -> None: + assert context.ctx_request.purpose == expected + + +@then("the request breadth should be {expected:d}") +def step_then_request_breadth(context: Any, expected: int) -> None: + assert context.ctx_request.breadth == expected + + +@then("the request depth should be {expected:d}") +def step_then_request_depth_int(context: Any, expected: int) -> None: + assert context.ctx_request.depth == expected + + +@then('the request depth should be "{expected}"') +def step_then_request_depth_str(context: Any, expected: str) -> None: + assert context.ctx_request.depth == expected + + +@then("the request depth_gradient should be true") +def step_then_request_gradient_true(context: Any) -> None: + assert context.ctx_request.depth_gradient is True + + +@then("the request priority should be {expected:g}") +def step_then_request_priority(context: Any, expected: float) -> None: + assert context.ctx_request.priority == expected + + +@then('the request temporal should be "{expected}"') +def step_then_request_temporal(context: Any, expected: str) -> None: + assert context.ctx_request.temporal == TemporalScope(expected) + + +@then("the request query should be None") +def step_then_request_query_none(context: Any) -> None: + assert context.ctx_request.query is None + + +@then('the request query should be "{expected}"') +def step_then_request_query(context: Any, expected: str) -> None: + assert context.ctx_request.query == expected + + +@then("the request max_tokens should be None") +def step_then_request_max_tokens_none(context: Any) -> None: + assert context.ctx_request.max_tokens is None + + +@then("the request max_tokens should be {expected:d}") +def step_then_request_max_tokens(context: Any, expected: int) -> None: + assert context.ctx_request.max_tokens == expected + + +@then("creating a ContextRequest with breadth {breadth:d} should raise ValueError") +def step_then_request_bad_breadth(context: Any, breadth: int) -> None: + try: + ContextRequest(breadth=breadth) + raise AssertionError("Expected ValueError") + except (ValueError, ValidationError): + pass + + +@then("creating a ContextRequest with depth {depth:d} should raise ValueError") +def step_then_request_bad_depth(context: Any, depth: int) -> None: + try: + ContextRequest(depth=depth) + raise AssertionError("Expected ValueError") + except (ValueError, ValidationError): + pass + + +@then("creating a ContextRequest with priority {priority:g} should raise ValueError") +def step_then_request_bad_priority(context: Any, priority: float) -> None: + try: + ContextRequest(priority=priority) + raise AssertionError("Expected ValueError") + except (ValueError, ValidationError): + pass + + +@then("creating a ContextRequest with max_tokens {max_tok:d} should raise ValueError") +def step_then_request_bad_max_tokens(context: Any, max_tok: int) -> None: + try: + ContextRequest(max_tokens=max_tok) + raise AssertionError("Expected ValueError") + except (ValueError, ValidationError): + pass + + +# --------------------------------------------------------------------------- +# AssembledContext +# --------------------------------------------------------------------------- + + +@given("an AssembledContext with total_tokens {total:d} and budget_used {used:g}") +def step_given_assembled(context: Any, total: int, used: float) -> None: + context.assembled_total = total + context.assembled_used = used + + +@given('the assembled context has hash "{hash_val}"') +def step_given_assembled_hash(context: Any, hash_val: str) -> None: + context.assembled = AssembledContext( + total_tokens=context.assembled_total, + budget_used=context.assembled_used, + context_hash=hash_val, + ) + + +@then("the assembled total_tokens should be {expected:d}") +def step_then_assembled_total(context: Any, expected: int) -> None: + assert context.assembled.total_tokens == expected + + +@then("the assembled budget_used should be {expected:g}") +def step_then_assembled_used(context: Any, expected: float) -> None: + assert context.assembled.budget_used == expected + + +@then('the assembled context_hash should be "{expected}"') +def step_then_assembled_hash(context: Any, expected: str) -> None: + assert context.assembled.context_hash == expected + + +@then("creating an AssembledContext with budget_used {used:g} should raise ValueError") +def step_then_assembled_bad_used(context: Any, used: float) -> None: + try: + AssembledContext( + total_tokens=100, + budget_used=used, + context_hash="test", + ) + raise AssertionError("Expected ValueError") + except (ValueError, ValidationError): + pass + + +@then( + "creating an AssembledContext with total_tokens {total:d} should raise ValueError" +) +def step_then_assembled_bad_total(context: Any, total: int) -> None: + try: + AssembledContext( + total_tokens=total, + budget_used=0.5, + context_hash="test", + ) + raise AssertionError("Expected ValueError") + except (ValueError, ValidationError): + pass + + +# --------------------------------------------------------------------------- +# Context skill tool stubs +# --------------------------------------------------------------------------- + + +@when("I call the request_context tool handler") +def step_when_call_request_context(context: Any) -> None: + context.tool_error = None + try: + _handle_request_context({"purpose": "test"}) + except NotImplementedError as exc: + context.tool_error = exc + + +@when("I call the query_history tool handler") +def step_when_call_query_history(context: Any) -> None: + context.tool_error = None + try: + _handle_query_history({"query": "test"}) + except NotImplementedError as exc: + context.tool_error = exc + + +@when("I call the get_context_budget tool handler") +def step_when_call_get_budget(context: Any) -> None: + context.tool_error = None + try: + _handle_get_context_budget({}) + except NotImplementedError as exc: + context.tool_error = exc + + +@then('it should raise NotImplementedError with message containing "{text}"') +def step_then_not_impl_error(context: Any, text: str) -> None: + assert context.tool_error is not None, "Expected NotImplementedError" + assert text in str(context.tool_error), ( + f"Expected '{text}' in '{context.tool_error}'" + ) + + +@given("a CRP tool registry with context tools registered") +def step_given_crp_registry_with_context(context: Any) -> None: + context.crp_tool_registry = ToolRegistry() + register_skill_context_tools(context.crp_tool_registry) + + +@then('the CRP registry should contain tool "{name}"') +def step_then_crp_registry_has_tool(context: Any, name: str) -> None: + spec = context.crp_tool_registry.get(name) + assert spec is not None, f"Tool '{name}' not found in CRP registry" + + +@then('CRP tool "{name}" should have read_only capability') +def step_then_crp_tool_read_only(context: Any, name: str) -> None: + spec = context.crp_tool_registry.get(name) + assert spec is not None, f"Tool '{name}' not found" + assert spec.capabilities.read_only is True, f"Tool '{name}' is not read-only" + + +@then('CRP tool "{name}" should require "{field}" in input schema') +def step_then_crp_tool_requires_field(context: Any, name: str, field: str) -> None: + spec = context.crp_tool_registry.get(name) + assert spec is not None, f"Tool '{name}' not found" + required: list[str] = spec.input_schema.get("required", []) + assert field in required, f"Field '{field}' not in required fields: {required}" diff --git a/robot/crp_models.robot b/robot/crp_models.robot new file mode 100644 index 000000000..27c994f32 --- /dev/null +++ b/robot/crp_models.robot @@ -0,0 +1,73 @@ +*** Settings *** +Documentation Smoke tests for CRP domain model creation and validation +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_crp_models.py + +*** Test Cases *** +Create Valid ContextRequest + [Documentation] Create a ContextRequest with defaults and verify success + ${result}= Run Process ${PYTHON} ${HELPER} create-request cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} crp-request-ok + +Create Valid ContextFragment + [Documentation] Create a ContextFragment with valid inputs and verify success + ${result}= Run Process ${PYTHON} ${HELPER} create-fragment cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} crp-fragment-ok + +Create Valid ContextBudget + [Documentation] Create a ContextBudget and verify available_tokens + ${result}= Run Process ${PYTHON} ${HELPER} create-budget cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} crp-budget-ok + +Create Valid DetailLevelMap + [Documentation] Create a DetailLevelMap and resolve a named level + ${result}= Run Process ${PYTHON} ${HELPER} create-map cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} crp-map-ok + +Reject Invalid ContextBudget + [Documentation] Reject a ContextBudget with reserved > max + ${result}= Run Process ${PYTHON} ${HELPER} reject-budget cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} crp-reject-budget-ok + +Reject Invalid ContextFragment + [Documentation] Reject a ContextFragment with relevance_score > 1.0 + ${result}= Run Process ${PYTHON} ${HELPER} reject-fragment cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} crp-reject-fragment-ok + +Context Skill Tools Registered + [Documentation] Verify context skill tools register into the tool registry + ${result}= Run Process ${PYTHON} ${HELPER} register-tools cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} crp-tools-ok + +Create Valid AssembledContext + [Documentation] Create an AssembledContext and verify fields + ${result}= Run Process ${PYTHON} ${HELPER} create-assembled cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} crp-assembled-ok diff --git a/robot/helper_crp_models.py b/robot/helper_crp_models.py new file mode 100644 index 000000000..86dad15fd --- /dev/null +++ b/robot/helper_crp_models.py @@ -0,0 +1,156 @@ +"""Robot Framework helper for CRP domain model validation. + +Provides a CLI-style interface for Robot to invoke model creation +and validation. Exit code 0 = success, 1 = failure. + +Usage: + python robot/helper_crp_models.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Ensure the src directory is on the import path. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.domain.models.acms.crp import ( # noqa: E402 + AssembledContext, + ContextBudget, + ContextFragment, + ContextRequest, + DetailLevelMap, + FragmentProvenance, +) +from cleveragents.skills.builtins.context_ops import ( # noqa: E402 + register_skill_context_tools, +) +from cleveragents.tool.registry import ToolRegistry # noqa: E402 + + +def main() -> int: + """Entry point called by Robot Framework ``Run Process``.""" + if len(sys.argv) < 2: + print("Usage: helper_crp_models.py ") + return 1 + + command: str = sys.argv[1] + + if command == "create-request": + try: + req = ContextRequest(purpose="Test context request") + assert req.breadth == 2 + assert req.depth == 3 + print("crp-request-ok") + return 0 + except Exception as exc: + print(f"crp-request-fail: {exc}") + return 1 + + if command == "create-fragment": + try: + frag = ContextFragment( + uko_node="uko-py:class/TestClass", + content="class TestClass: pass", + detail_depth=4, + token_count=50, + relevance_score=0.85, + provenance=FragmentProvenance(resource_uri="uko-py:module/test"), + ) + assert frag.token_count == 50 + assert frag.relevance_score == 0.85 + print("crp-fragment-ok") + return 0 + except Exception as exc: + print(f"crp-fragment-fail: {exc}") + return 1 + + if command == "create-budget": + try: + budget = ContextBudget(max_tokens=8000, reserved_tokens=1000) + assert budget.available_tokens == 7000 + print("crp-budget-ok") + return 0 + except Exception as exc: + print(f"crp-budget-fail: {exc}") + return 1 + + if command == "create-map": + try: + dlm = DetailLevelMap( + domain="uko-code:", + max_depth=9, + levels={"MODULE_LISTING": 0, "FULL_SOURCE": 9}, + ) + assert dlm.resolve("MODULE_LISTING") == 0 + assert dlm.resolve("FULL_SOURCE") == 9 + assert dlm.resolve(15) == 9 # clamped + print("crp-map-ok") + return 0 + except Exception as exc: + print(f"crp-map-fail: {exc}") + return 1 + + if command == "reject-budget": + try: + ContextBudget(max_tokens=100, reserved_tokens=200) + print("crp-reject-budget-fail: should have raised") + return 1 + except Exception: + print("crp-reject-budget-ok") + return 0 + + if command == "reject-fragment": + try: + ContextFragment( + uko_node="uko-py:test", + content="test", + detail_depth=0, + token_count=10, + relevance_score=1.5, + provenance=FragmentProvenance(resource_uri="uko-py:module/test"), + ) + print("crp-reject-fragment-fail: should have raised") + return 1 + except Exception: + print("crp-reject-fragment-ok") + return 0 + + if command == "register-tools": + try: + registry = ToolRegistry() + register_skill_context_tools(registry) + assert registry.get("builtin/request-context") is not None + assert registry.get("builtin/query-history") is not None + assert registry.get("builtin/get-context-budget") is not None + print("crp-tools-ok") + return 0 + except Exception as exc: + print(f"crp-tools-fail: {exc}") + return 1 + + if command == "create-assembled": + try: + assembled = AssembledContext( + total_tokens=1500, + budget_used=0.75, + context_hash="abc123def", + strategies_used=["simple-keyword"], + ) + assert assembled.total_tokens == 1500 + assert assembled.budget_used == 0.75 + print("crp-assembled-ok") + return 0 + except Exception as exc: + print(f"crp-assembled-fail: {exc}") + return 1 + + print(f"Unknown command: {command}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/cleveragents/domain/models/acms/__init__.py b/src/cleveragents/domain/models/acms/__init__.py new file mode 100644 index 000000000..7887c53fd --- /dev/null +++ b/src/cleveragents/domain/models/acms/__init__.py @@ -0,0 +1,34 @@ +"""ACMS (Advanced Context Management System) domain models. + +Provides the Context Request Protocol (CRP) data types used by actors, +skills, strategies, and the Context Assembly Pipeline: + +- ``DetailLevelMap`` -- Named-level-to-integer resolution with inheritance +- ``ContextRequest`` -- Structured request for context via the CRP +- ``ContextFragment`` -- Atomic unit of retrieved context +- ``FragmentProvenance`` -- Trace-back to source resource and location +- ``ContextBudget`` -- Token budget management with reservation support +- ``AssembledContext`` -- Final budget-respecting context payload + +Based on ``docs/specification.md`` ACMS / CRP sections and ADR-014. +""" + +from __future__ import annotations + +from cleveragents.domain.models.acms.crp import ( + AssembledContext, + ContextBudget, + ContextFragment, + ContextRequest, + DetailLevelMap, + FragmentProvenance, +) + +__all__: list[str] = [ + "AssembledContext", + "ContextBudget", + "ContextFragment", + "ContextRequest", + "DetailLevelMap", + "FragmentProvenance", +] diff --git a/src/cleveragents/domain/models/acms/crp.py b/src/cleveragents/domain/models/acms/crp.py new file mode 100644 index 000000000..8be082926 --- /dev/null +++ b/src/cleveragents/domain/models/acms/crp.py @@ -0,0 +1,445 @@ +"""Context Request Protocol (CRP) domain models for CleverAgents. + +The CRP is the structured vocabulary through which actors (via skills) +declare what information they need, at what level of detail, and with +what scope, while they are reasoning. These models are consumed by the +Context Assembly Pipeline and produced by context strategies. + +## Core Types + +| Type | Role | +|---------------------|-----------------------------------------------------| +| ``DetailLevelMap`` | Named-level-to-integer resolution with inheritance | +| ``ContextRequest`` | Actor-issued request for context | +| ``ContextFragment`` | Atomic unit of context returned by strategies | +| ``FragmentProvenance`` | Trace-back to resource + location | +| ``ContextBudget`` | Token budget management with reservation | +| ``AssembledContext`` | Fused, budget-respecting context payload | + +## DetailDepth + +At the universal level (UKO Layer 0), detail depth is a **non-negative +integer** -- 0 is the most minimal representation and each increment +reveals more. There is no fixed upper bound; the maximum meaningful +depth depends on the domain and the complexity of the information unit. + +Each UKO domain extension registers a ``DetailLevelMap`` -- a table of +named labels mapped to specific integer depths. Maps are inherited: +a child map includes all entries from its parent map, and may insert +additional levels at any integer position. + +Based on ``docs/specification.md`` ACMS / CRP sections and ADR-014. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) + +from cleveragents.domain.models.core.project import TemporalScope + +# --------------------------------------------------------------------------- +# DetailLevelMap +# --------------------------------------------------------------------------- + + +class DetailLevelMap(BaseModel): + """Maps named detail levels to integer depths for a UKO domain. + + Inherited: a child map includes all entries from its parent map, + and may insert additional levels at any integer position. + + Example:: + + code_map = DetailLevelMap( + domain="uko-code:", + parent=None, + levels={"MODULE_LISTING": 0, "FULL_SOURCE": 9}, + max_depth=9, + ) + assert code_map.resolve(4) == 4 + assert code_map.resolve("MODULE_LISTING") == 0 + """ + + domain: str = Field( + ..., + min_length=1, + description="UKO namespace (e.g., 'uko-code:', 'uko-py:')", + ) + parent: DetailLevelMap | None = Field( + default=None, + description="Parent map to inherit from", + ) + levels: dict[str, int] = Field( + default_factory=dict, + description="Named level -> integer depth", + ) + max_depth: int = Field( + ..., + ge=0, + description="Maximum meaningful depth for this domain", + ) + + @field_validator("levels") + @classmethod + def validate_levels( + cls: type[DetailLevelMap], + v: dict[str, int], + ) -> dict[str, int]: + """Ensure all level values are non-negative integers.""" + for name, depth in v.items(): + if depth < 0: + raise ValueError(f"Detail level '{name}' has negative depth {depth}") + return v + + def resolve(self, depth: int | str) -> int: + """Resolve a named level or integer to an integer depth. + + Args: + depth: Either a raw integer (clamped to ``max_depth``) or + a named level string looked up in this map, then in + parent maps. + + Returns: + Resolved integer depth. + + Raises: + ValueError: If *depth* is a string not found in this map + or any ancestor. + """ + if isinstance(depth, int): + return min(depth, self.max_depth) + # Look up named level in this map, then parent maps + if depth in self.levels: + return self.levels[depth] + if self.parent is not None: + return self.parent.resolve(depth) + raise ValueError(f"Unknown detail level: {depth}") + + def register(self, name: str, value: int) -> None: + """Register a custom detail level. + + Args: + name: Named level label. + value: Integer depth to map to. + + Raises: + ValueError: If *value* is negative. + """ + if value < 0: + raise ValueError(f"Detail level '{name}' has negative depth {value}") + self.levels[name] = value + + def effective_levels(self) -> dict[str, int]: + """Return the full merged level map including inherited entries. + + Parent entries are included first; child entries override any + collisions. + """ + merged = self.parent.effective_levels() if self.parent is not None else {} + merged.update(self.levels) + return merged + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + ) + + +# --------------------------------------------------------------------------- +# FragmentProvenance +# --------------------------------------------------------------------------- + + +class FragmentProvenance(BaseModel): + """Provenance trace for a context fragment. + + Links a fragment back to the originating resource and location + within that resource. + """ + + resource_uri: str = Field( + ..., + min_length=1, + description="URI of the originating resource", + ) + location: str = Field( + default="", + description="Location within the resource (e.g., line range, section)", + ) + strategy: str = Field( + default="", + description="Name of the strategy that produced this fragment", + ) + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + ) + + +# --------------------------------------------------------------------------- +# ContextBudget +# --------------------------------------------------------------------------- + + +class ContextBudget(BaseModel): + """Token budget for context assembly. + + Tracks the maximum token budget and any reserved portion, providing + a computed ``available_tokens`` property for the remaining budget. + + Example:: + + budget = ContextBudget(max_tokens=8000, reserved_tokens=1000) + assert budget.available_tokens == 7000 + """ + + max_tokens: int = Field( + ..., + ge=0, + description="Maximum total token budget", + ) + reserved_tokens: int = Field( + default=0, + ge=0, + description="Tokens reserved (e.g., for skeleton context)", + ) + + @model_validator(mode="after") + def _validate_reservation(self) -> ContextBudget: + """Ensure reserved_tokens does not exceed max_tokens.""" + if self.reserved_tokens > self.max_tokens: + raise ValueError("reserved_tokens cannot exceed max_tokens") + return self + + @property + def available_tokens(self) -> int: + """Return the number of tokens available after reservations.""" + return self.max_tokens - self.reserved_tokens + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + ) + + +# --------------------------------------------------------------------------- +# ContextFragment +# --------------------------------------------------------------------------- + + +class ContextFragment(BaseModel): + """A single piece of context assembled by a strategy. + + The atomic unit of context returned by strategies and consumed by + the Context Assembly Pipeline's fusion phase + (FragmentDeduplicator, DetailDepthResolver, FragmentScorer, + BudgetPacker, FragmentOrderer). + """ + + uko_node: str = Field( + ..., + min_length=1, + description="UKO URI of the source node", + ) + content: str = Field( + ..., + description="Rendered text content", + ) + detail_depth: int = Field( + ..., + ge=0, + description="Resolved integer depth of this fragment", + ) + token_count: int = Field( + ..., + ge=0, + description="Token count of content", + ) + relevance_score: float = Field( + ..., + ge=0.0, + le=1.0, + description="0.0-1.0 relevance to the request", + ) + provenance: FragmentProvenance = Field( + ..., + description="Trace back to resource + location", + ) + metadata: dict[str, Any] = Field( + default_factory=dict, + description="Arbitrary strategy-specific metadata", + ) + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + ) + + +# --------------------------------------------------------------------------- +# ContextRequest +# --------------------------------------------------------------------------- + + +class ContextRequest(BaseModel): + """A structured request for context, issued by an actor or skill. + + Actors issue context requests through the ``builtin/context`` skill + which is automatically injected into every actor's skill set. Each + request triggers a context assembly cycle in the ACMS pipeline. + + Fields follow the spec (``docs/specification.md`` > ACMS > CRP > + ContextRequest). + """ + + # === What to find === + query: str | None = Field( + default=None, + description="Natural language query", + ) + entities: list[str] = Field( + default_factory=list, + description="Named entities to focus on", + ) + uko_types: list[str] = Field( + default_factory=list, + description="UKO types to filter", + ) + + # === Scope control === + focus: list[str] = Field( + default_factory=list, + description="URIs or identifiers of specific items to focus on", + ) + breadth: int = Field( + default=2, + ge=0, + description="How many hops outward in the dependency/reference graph", + ) + depth: int | str = Field( + default=3, + description=( + "How much detail to include for each item found. " + "May be a raw integer (0-N) or a named level string " + "(e.g., 'SIGNATURES') resolved via the active DetailLevelMap." + ), + ) + + # === Focus depth gradient === + depth_gradient: bool = Field( + default=True, + description="When True, items closer to the focus get more detail", + ) + + # === Temporal scope === + temporal: TemporalScope = Field( + default=TemporalScope.CURRENT, + description="Temporal scope for context retrieval", + ) + + # === Budget === + max_tokens: int | None = Field( + default=None, + ge=0, + description="Maximum token budget for this request", + ) + + # === Strategy hints === + preferred_strategies: list[str] = Field( + default_factory=list, + description="Preferred context strategies to use", + ) + required_backends: list[str] = Field( + default_factory=list, + description="Required data backends (text, vector, graph)", + ) + + # === Priority and purpose === + priority: float = Field( + default=0.5, + ge=0.0, + le=1.0, + description="0.0 = background, 1.0 = critical", + ) + purpose: str = Field( + default="", + description="Why is this context needed?", + ) + + @field_validator("depth") + @classmethod + def validate_depth( + cls: type[ContextRequest], + v: int | str, + ) -> int | str: + """Ensure integer depths are non-negative.""" + if isinstance(v, int) and v < 0: + raise ValueError("depth must be non-negative when specified as integer") + return v + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + use_enum_values=False, + ) + + +# --------------------------------------------------------------------------- +# AssembledContext +# --------------------------------------------------------------------------- + + +class AssembledContext(BaseModel): + """The fused, budget-respecting context payload. + + Output of a context assembly cycle -- the final payload delivered + to an actor after strategy execution, fragment deduplication, + scoring, budget packing, and ordering. + """ + + fragments: list[ContextFragment] = Field( + default_factory=list, + description="Ordered context fragments", + ) + total_tokens: int = Field( + ..., + ge=0, + description="Total token count", + ) + budget_used: float = Field( + ..., + ge=0.0, + le=1.0, + description="Fraction of budget consumed (0.0-1.0)", + ) + strategies_used: list[str] = Field( + default_factory=list, + description="Which strategies contributed", + ) + context_hash: str = Field( + ..., + min_length=1, + description="Cryptographic hash for snapshot", + ) + preamble: str | None = Field( + default=None, + description="Optional structure summary", + ) + provenance_map: dict[str, Any] = Field( + default_factory=dict, + description="Fragment -> resource/location mapping", + ) + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + ) diff --git a/src/cleveragents/skills/builtins/__init__.py b/src/cleveragents/skills/builtins/__init__.py index 1df6f9cec..60d667e4e 100644 --- a/src/cleveragents/skills/builtins/__init__.py +++ b/src/cleveragents/skills/builtins/__init__.py @@ -1,25 +1,28 @@ """Built-in skill tools for CleverAgents. -Provides file operation and search skill tools that integrate with the -skill framework and tool registry. +Provides file operation, search, and context skill tools that integrate +with the skill framework and tool registry. ## Modules -| Module | Description | -|-----------------|------------------------------------------| -| ``file_ops`` | ReadFile, WriteFile, EditFile, DeleteFile | -| ``search_ops`` | ListDir, Glob, Grep | +| Module | Description | +|-----------------|------------------------------------------------------| +| ``file_ops`` | ReadFile, WriteFile, EditFile, DeleteFile | +| ``search_ops`` | ListDir, Glob, Grep | +| ``context_ops`` | request_context, query_history, get_context_budget | ## Registration ```python from cleveragents.skills.builtins.file_ops import register_skill_file_tools from cleveragents.skills.builtins.search_ops import register_skill_search_tools +from cleveragents.skills.builtins.context_ops import register_skill_context_tools from cleveragents.tool.registry import ToolRegistry registry = ToolRegistry() register_skill_file_tools(registry) register_skill_search_tools(registry) +register_skill_context_tools(registry) ``` """ diff --git a/src/cleveragents/skills/builtins/context_ops.py b/src/cleveragents/skills/builtins/context_ops.py new file mode 100644 index 000000000..87b99e3ef --- /dev/null +++ b/src/cleveragents/skills/builtins/context_ops.py @@ -0,0 +1,216 @@ +"""Built-in context skill tools for CleverAgents (CRP). + +Provides the ``builtin/context`` skill tools that actors use to issue +context requests during reasoning via the Context Request Protocol: + +- **request_context**: Request specific context to be added to the + conversation. Accepts query, focus, breadth, depth, and purpose + parameters. Stubbed to raise ``NotImplementedError`` until the + ACMS pipeline is wired. +- **query_history**: Query historical context about past decisions + and changes. Stubbed to raise ``NotImplementedError``. +- **get_context_budget**: Check remaining context token budget. + Stubbed to raise ``NotImplementedError``. + +## Registration + +```python +from cleveragents.skills.builtins.context_ops import register_skill_context_tools +from cleveragents.tool.registry import ToolRegistry + +registry = ToolRegistry() +register_skill_context_tools(registry) +``` + +Based on ``docs/specification.md`` ACMS > CRP > ``builtin/context`` Skill. +""" + +from __future__ import annotations + +from typing import Any + +from cleveragents.domain.models.core.tool import ToolCapability +from cleveragents.tool.registry import ToolRegistry +from cleveragents.tool.runtime import ToolSpec + +# --------------------------------------------------------------------------- +# Handlers (stubs -- wired to future ACMS pipeline) +# --------------------------------------------------------------------------- + + +def _handle_request_context(inputs: dict[str, Any]) -> dict[str, Any]: + """Request specific context to be added to the conversation. + + Stub implementation -- raises ``NotImplementedError`` until the + ACMS Context Assembly Pipeline is available. + """ + raise NotImplementedError( + "request_context is not yet wired to the ACMS pipeline. " + "This tool will be functional once the Context Assembly Pipeline " + "is implemented." + ) + + +def _handle_query_history(inputs: dict[str, Any]) -> dict[str, Any]: + """Query historical context about past decisions and changes. + + Stub implementation -- raises ``NotImplementedError`` until the + ACMS temporal archaeology strategy is available. + """ + raise NotImplementedError( + "query_history is not yet wired to the ACMS pipeline. " + "This tool will be functional once the temporal archaeology " + "strategy is implemented." + ) + + +def _handle_get_context_budget(inputs: dict[str, Any]) -> dict[str, Any]: + """Check remaining context token budget. + + Stub implementation -- raises ``NotImplementedError`` until the + ACMS budget tracking is available. + """ + raise NotImplementedError( + "get_context_budget is not yet wired to the ACMS pipeline. " + "This tool will be functional once context budget tracking " + "is implemented." + ) + + +# --------------------------------------------------------------------------- +# Tool specs +# --------------------------------------------------------------------------- + +SKILL_CONTEXT_REQUEST_SPEC = ToolSpec( + name="builtin/request-context", + description="Request specific context to be added to the conversation", + input_schema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "What information do you need?", + }, + "focus": { + "type": "array", + "items": {"type": "string"}, + "description": ("Specific files, classes, or functions to focus on"), + }, + "breadth": { + "type": "integer", + "default": 2, + "description": "How many dependency hops outward (0-5)", + }, + "depth": { + "oneOf": [ + {"type": "integer", "minimum": 0}, + {"type": "string"}, + ], + "default": 3, + "description": ( + "Detail depth -- integer (0-N) or named level from " + "the active DetailLevelMap (e.g., 'SIGNATURES', " + "'FULL_SOURCE')" + ), + }, + "purpose": { + "type": "string", + "description": "Why do you need this context?", + }, + }, + "required": ["purpose"], + }, + output_schema={ + "type": "object", + "properties": { + "fragments": { + "type": "array", + "items": {"type": "object"}, + "description": "Assembled context fragments", + }, + "total_tokens": { + "type": "integer", + "description": "Total tokens consumed", + }, + }, + }, + capabilities=ToolCapability(read_only=True), + handler=_handle_request_context, +) + +SKILL_CONTEXT_QUERY_HISTORY_SPEC = ToolSpec( + name="builtin/query-history", + description="Query historical context about past decisions and changes", + input_schema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "What historical information do you need?", + }, + "scope": { + "type": "string", + "enum": ["current_plan", "plan_tree", "all_plans"], + "default": "plan_tree", + "description": "Scope of history to search", + }, + }, + "required": ["query"], + }, + output_schema={ + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": {"type": "object"}, + "description": "Historical context entries", + }, + }, + }, + capabilities=ToolCapability(read_only=True), + handler=_handle_query_history, +) + +SKILL_CONTEXT_BUDGET_SPEC = ToolSpec( + name="builtin/get-context-budget", + description="Check remaining context token budget", + input_schema={ + "type": "object", + "properties": {}, + }, + output_schema={ + "type": "object", + "properties": { + "max_tokens": { + "type": "integer", + "description": "Maximum token budget", + }, + "reserved_tokens": { + "type": "integer", + "description": "Tokens reserved for skeleton context", + }, + "available_tokens": { + "type": "integer", + "description": "Tokens available for use", + }, + "used_tokens": { + "type": "integer", + "description": "Tokens already consumed", + }, + }, + }, + capabilities=ToolCapability(read_only=True), + handler=_handle_get_context_budget, +) + +ALL_SKILL_CONTEXT_TOOLS: list[ToolSpec] = [ + SKILL_CONTEXT_REQUEST_SPEC, + SKILL_CONTEXT_QUERY_HISTORY_SPEC, + SKILL_CONTEXT_BUDGET_SPEC, +] + + +def register_skill_context_tools(registry: ToolRegistry) -> None: + """Register all skill-level context tools into *registry*.""" + for spec in ALL_SKILL_CONTEXT_TOOLS: + registry.register(spec)