nexus-coding-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. nexus/__init__.py +4 -0
  2. nexus/application/__init__.py +2 -0
  3. nexus/application/approval_service.py +130 -0
  4. nexus/application/diff_service.py +177 -0
  5. nexus/application/event_publisher.py +180 -0
  6. nexus/application/execution_context.py +29 -0
  7. nexus/application/execution_ledger.py +126 -0
  8. nexus/application/model_call_context.py +29 -0
  9. nexus/application/observed_model_gateway.py +194 -0
  10. nexus/application/plan_approval_service.py +250 -0
  11. nexus/application/planning.py +668 -0
  12. nexus/application/runtime.py +624 -0
  13. nexus/application/session_service.py +411 -0
  14. nexus/application/structured_output.py +96 -0
  15. nexus/application/telemetry.py +396 -0
  16. nexus/application/telemetry_subscriber.py +216 -0
  17. nexus/application/tool_runtime.py +496 -0
  18. nexus/application/tracing_dispatcher.py +283 -0
  19. nexus/application/validation.py +293 -0
  20. nexus/config/__init__.py +17 -0
  21. nexus/config/loader.py +253 -0
  22. nexus/config/models.py +310 -0
  23. nexus/context/__init__.py +6 -0
  24. nexus/context/budget.py +24 -0
  25. nexus/context/builder.py +92 -0
  26. nexus/context/chunking.py +65 -0
  27. nexus/context/explorer.py +246 -0
  28. nexus/context/integration.py +63 -0
  29. nexus/context/manager.py +328 -0
  30. nexus/context/retrieval.py +261 -0
  31. nexus/domain/__init__.py +2 -0
  32. nexus/domain/agent_decision.py +113 -0
  33. nexus/domain/agent_state.py +49 -0
  34. nexus/domain/approvals.py +64 -0
  35. nexus/domain/context.py +108 -0
  36. nexus/domain/exploration.py +86 -0
  37. nexus/domain/mcp.py +51 -0
  38. nexus/domain/model.py +173 -0
  39. nexus/domain/observability.py +326 -0
  40. nexus/domain/persistence.py +122 -0
  41. nexus/domain/planning.py +329 -0
  42. nexus/domain/ports/__init__.py +61 -0
  43. nexus/domain/ports/agent_decision.py +9 -0
  44. nexus/domain/ports/approval_unit_of_work.py +27 -0
  45. nexus/domain/ports/checkpoint_provider.py +11 -0
  46. nexus/domain/ports/context.py +71 -0
  47. nexus/domain/ports/events.py +21 -0
  48. nexus/domain/ports/graph_runtime.py +30 -0
  49. nexus/domain/ports/mcp.py +23 -0
  50. nexus/domain/ports/model_gateway.py +21 -0
  51. nexus/domain/ports/model_input_budget.py +15 -0
  52. nexus/domain/ports/planning.py +62 -0
  53. nexus/domain/ports/repositories.py +54 -0
  54. nexus/domain/ports/repository_context.py +18 -0
  55. nexus/domain/ports/session_unit_of_work.py +33 -0
  56. nexus/domain/ports/skills.py +41 -0
  57. nexus/domain/ports/tooling.py +39 -0
  58. nexus/domain/ports/tracer.py +13 -0
  59. nexus/domain/ports/validation.py +32 -0
  60. nexus/domain/runtime_events.py +445 -0
  61. nexus/domain/skills.py +129 -0
  62. nexus/domain/tooling.py +163 -0
  63. nexus/domain/validation.py +161 -0
  64. nexus/errors/__init__.py +82 -0
  65. nexus/evaluation/__init__.py +7 -0
  66. nexus/evaluation/assertions.py +4 -0
  67. nexus/evaluation/evaluator.py +263 -0
  68. nexus/evaluation/fixtures.py +36 -0
  69. nexus/evaluation/harness.py +606 -0
  70. nexus/evaluation/loader.py +415 -0
  71. nexus/evaluation/metrics.py +27 -0
  72. nexus/evaluation/models.py +362 -0
  73. nexus/evaluation/report.py +43 -0
  74. nexus/evaluation/runner.py +480 -0
  75. nexus/infrastructure/__init__.py +2 -0
  76. nexus/infrastructure/asyncio_compat.py +13 -0
  77. nexus/infrastructure/bootstrap/__init__.py +17 -0
  78. nexus/infrastructure/bootstrap/composition.py +663 -0
  79. nexus/infrastructure/checkpoint/__init__.py +5 -0
  80. nexus/infrastructure/checkpoint/postgres.py +62 -0
  81. nexus/infrastructure/database.py +39 -0
  82. nexus/infrastructure/embedding.py +61 -0
  83. nexus/infrastructure/graph/__init__.py +6 -0
  84. nexus/infrastructure/graph/day4_runtime.py +966 -0
  85. nexus/infrastructure/graph/langgraph_runtime.py +172 -0
  86. nexus/infrastructure/mcp/__init__.py +5 -0
  87. nexus/infrastructure/mcp/manager.py +380 -0
  88. nexus/infrastructure/model_gateway/__init__.py +6 -0
  89. nexus/infrastructure/model_gateway/openai_compatible.py +345 -0
  90. nexus/infrastructure/observability/__init__.py +7 -0
  91. nexus/infrastructure/observability/console.py +31 -0
  92. nexus/infrastructure/observability/langsmith.py +236 -0
  93. nexus/infrastructure/observability/structured_logger.py +70 -0
  94. nexus/infrastructure/persistence/__init__.py +13 -0
  95. nexus/infrastructure/persistence/approval_unit_of_work.py +74 -0
  96. nexus/infrastructure/persistence/models.py +146 -0
  97. nexus/infrastructure/persistence/repositories.py +326 -0
  98. nexus/infrastructure/persistence/unit_of_work.py +82 -0
  99. nexus/infrastructure/repository_files.py +117 -0
  100. nexus/infrastructure/sandbox/__init__.py +5 -0
  101. nexus/infrastructure/sandbox/local_process.py +353 -0
  102. nexus/infrastructure/semantic.py +276 -0
  103. nexus/infrastructure/semantic_models.py +56 -0
  104. nexus/interfaces/__init__.py +2 -0
  105. nexus/interfaces/cli/__init__.py +2 -0
  106. nexus/interfaces/cli/__main__.py +6 -0
  107. nexus/interfaces/cli/app.py +266 -0
  108. nexus/interfaces/cli/renderer.py +118 -0
  109. nexus/security/__init__.py +14 -0
  110. nexus/security/approval_policies.py +73 -0
  111. nexus/security/command_policy.py +117 -0
  112. nexus/security/executables.py +107 -0
  113. nexus/security/git_commands.py +65 -0
  114. nexus/security/workspace.py +93 -0
  115. nexus/skills/__init__.py +7 -0
  116. nexus/skills/builtin/__init__.py +1 -0
  117. nexus/skills/builtin/debug-python/SKILL.md +26 -0
  118. nexus/skills/builtin/review-repository/SKILL.md +26 -0
  119. nexus/skills/builtin/write-tests/SKILL.md +26 -0
  120. nexus/skills/loader.py +439 -0
  121. nexus/skills/registry.py +146 -0
  122. nexus/skills/selector.py +152 -0
  123. nexus/tools/__init__.py +6 -0
  124. nexus/tools/editing.py +312 -0
  125. nexus/tools/mcp.py +160 -0
  126. nexus/tools/native.py +654 -0
  127. nexus/tools/registry.py +27 -0
  128. nexus_coding_agent-0.1.0.dist-info/METADATA +142 -0
  129. nexus_coding_agent-0.1.0.dist-info/RECORD +131 -0
  130. nexus_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  131. nexus_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,113 @@
1
+ """Structured Day 4 Agent decision and deterministic observation values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from uuid import UUID
8
+
9
+ from nexus.domain.exploration import WorkingContext
10
+ from nexus.domain.planning import Plan, RepairGuidance
11
+ from nexus.domain.tooling import JsonObject
12
+ from nexus.domain.validation import ValidationResult
13
+
14
+
15
+ class AgentDecisionKind(StrEnum):
16
+ TOOL_ACTION = "TOOL_ACTION"
17
+ CONTINUE = "CONTINUE"
18
+ TASK_READY = "TASK_READY"
19
+
20
+
21
+ class AgentRoute(StrEnum):
22
+ TOOL_REQUIRED = "TOOL_REQUIRED"
23
+ CONTINUE = "CONTINUE"
24
+ TASK_READY = "TASK_READY"
25
+
26
+
27
+ class ObserveRoute(StrEnum):
28
+ REPLAN_REQUIRED = "REPLAN_REQUIRED"
29
+ CONTINUE = "CONTINUE"
30
+
31
+
32
+ class ValidationRoute(StrEnum):
33
+ PASSED = "PASSED"
34
+ REPAIR_AVAILABLE = "REPAIR_AVAILABLE"
35
+ FAILED = "FAILED"
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ToolAction:
40
+ tool_name: str
41
+ arguments: JsonObject
42
+
43
+ def __post_init__(self) -> None:
44
+ if not self.tool_name:
45
+ raise ValueError("ToolAction tool_name must not be empty.")
46
+ if not _is_json_value(self.arguments):
47
+ raise ValueError("ToolAction arguments must be JSON-compatible.")
48
+ object.__setattr__(self, "arguments", dict(self.arguments))
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class AgentDecision:
53
+ kind: AgentDecisionKind
54
+ action: ToolAction | None
55
+ summary: str
56
+
57
+ def __post_init__(self) -> None:
58
+ if not self.summary.strip():
59
+ raise ValueError("AgentDecision summary must not be empty.")
60
+ if (self.kind is AgentDecisionKind.TOOL_ACTION) != (self.action is not None):
61
+ raise ValueError("AgentDecision action does not match its kind.")
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class Observation:
66
+ invocation_id: str
67
+ tool_name: str
68
+ success: bool
69
+ evidence_summary: str
70
+ error_code: str | None
71
+ replan_reason: str | None
72
+
73
+ def __post_init__(self) -> None:
74
+ _validate_uuid(self.invocation_id, "invocation_id")
75
+ if not self.tool_name or not self.evidence_summary.strip():
76
+ raise ValueError("Observation Tool identity and evidence must not be empty.")
77
+ if self.error_code == "PLAN_SCOPE_DENIED":
78
+ if not (self.replan_reason or "").strip():
79
+ raise ValueError("PLAN_SCOPE_DENIED requires a material Replan reason.")
80
+ elif self.replan_reason is not None:
81
+ raise ValueError("Day 4 permits no additional semantic Replan trigger.")
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class AgentDecisionRequest:
86
+ task: str
87
+ context: WorkingContext
88
+ plan: Plan
89
+ observations: tuple[Observation, ...]
90
+ validation_result: ValidationResult | None = None
91
+ repair_guidance: RepairGuidance | None = None
92
+
93
+
94
+ def _validate_uuid(value: str, field_name: str) -> None:
95
+ try:
96
+ parsed = UUID(value)
97
+ except (AttributeError, TypeError, ValueError) as exc:
98
+ raise ValueError(f"{field_name} must be a canonical UUID string.") from exc
99
+ if str(parsed) != value:
100
+ raise ValueError(f"{field_name} must be a canonical UUID string.")
101
+
102
+
103
+ def _is_json_value(value: object) -> bool:
104
+ if value is None or isinstance(value, (str, int, float, bool)):
105
+ return True
106
+ if isinstance(value, list):
107
+ return all(_is_json_value(item) for item in value)
108
+ if isinstance(value, dict):
109
+ return all(
110
+ isinstance(key, str) and _is_json_value(item)
111
+ for key, item in value.items()
112
+ )
113
+ return False
@@ -0,0 +1,49 @@
1
+ """Nexus-owned graph state through Day 4."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from nexus.domain.agent_decision import Observation, ToolAction
6
+ from nexus.domain.approvals import ApprovalRequest
7
+ from nexus.domain.exploration import ExplorationResult, WorkingContext
8
+ from nexus.domain.model import ModelMessage, TokenUsageAggregate
9
+ from nexus.domain.planning import (
10
+ ApprovedPlanEvidence,
11
+ ChangedFile,
12
+ Plan,
13
+ RepairGuidance,
14
+ TerminalStatus,
15
+ )
16
+ from nexus.domain.runtime_events import RuntimeStatus
17
+ from nexus.domain.tooling import ToolResult
18
+ from nexus.domain.validation import ValidationResult
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class AgentState:
23
+ """Nexus-owned state passed through the graph boundary."""
24
+
25
+ task: str
26
+ messages: list[ModelMessage]
27
+ run_id: str
28
+ session_id: str | None
29
+ status: RuntimeStatus
30
+ exploration: ExplorationResult | None = None
31
+ context: WorkingContext | None = None
32
+ plan: Plan | None = None
33
+ plan_history: tuple[Plan, ...] = ()
34
+ observations: tuple[Observation, ...] = ()
35
+ tool_results: tuple[ToolResult, ...] = ()
36
+ pending_tool_action: ToolAction | None = None
37
+ latest_tool_result: ToolResult | None = None
38
+ pending_plan_approval: ApprovalRequest | None = None
39
+ approved_plan: ApprovedPlanEvidence | None = None
40
+ repair_guidance: RepairGuidance | None = None
41
+ validation_result: ValidationResult | None = None
42
+ changed_files: tuple[ChangedFile, ...] = ()
43
+ step_count: int = 0
44
+ tool_call_count: int = 0
45
+ llm_call_count: int = 0
46
+ replan_count: int = 0
47
+ repair_count: int = 0
48
+ terminal_status: TerminalStatus | None = None
49
+ token_usage: TokenUsageAggregate = field(default_factory=TokenUsageAggregate)
@@ -0,0 +1,64 @@
1
+ """Nexus-owned Day 3 approval lifecycle entity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from uuid import UUID
8
+
9
+ from nexus.domain.tooling import ApprovalDecision, RiskLevel
10
+
11
+ _ACTORS = {"runtime", "user", "auto_policy", "security_policy"}
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ApprovalRequest:
16
+ approval_id: str
17
+ run_id: str
18
+ session_id: str
19
+ operation: str
20
+ risk_level: RiskLevel
21
+ resource_or_command_summary: str
22
+ decision: ApprovalDecision
23
+ actor: str
24
+ reason: str | None
25
+ created_at: datetime
26
+ decided_at: datetime | None
27
+
28
+ def __post_init__(self) -> None:
29
+ _validate_uuid(self.approval_id, "approval_id")
30
+ _validate_uuid(self.run_id, "run_id")
31
+ _validate_uuid(self.session_id, "session_id")
32
+ _validate_timestamp(self.created_at, "created_at")
33
+ if self.decided_at is not None:
34
+ _validate_timestamp(self.decided_at, "decided_at")
35
+ if not self.operation or not self.resource_or_command_summary:
36
+ raise ValueError("Approval operation, summary, and actor must not be empty.")
37
+ if self.actor not in _ACTORS:
38
+ raise ValueError("Approval actor is not a frozen Day 3 actor.")
39
+ if self.decision is ApprovalDecision.PENDING:
40
+ if self.decided_at is not None or self.actor != "runtime":
41
+ raise ValueError("A pending approval must have runtime actor and no decided_at.")
42
+ elif self.decided_at is None:
43
+ raise ValueError("A terminal approval must have decided_at.")
44
+ elif self.decision is ApprovalDecision.APPROVED and not (
45
+ self.actor == "user"
46
+ or self.actor == "auto_policy" and self.operation == "approve_plan"
47
+ ):
48
+ raise ValueError("Only the user or scoped Plan auto policy may approve.")
49
+ elif self.decision is ApprovalDecision.DENIED and self.actor == "runtime":
50
+ raise ValueError("The runtime actor cannot own a terminal denial.")
51
+
52
+
53
+ def _validate_uuid(value: str, field_name: str) -> None:
54
+ try:
55
+ parsed = UUID(value)
56
+ except ValueError as exc:
57
+ raise ValueError(f"{field_name} must be a UUID string.") from exc
58
+ if str(parsed) != value:
59
+ raise ValueError(f"{field_name} must use canonical UUID text.")
60
+
61
+
62
+ def _validate_timestamp(value: datetime, field_name: str) -> None:
63
+ if value.tzinfo is None or value.utcoffset() is None:
64
+ raise ValueError(f"{field_name} must be timezone-aware.")
@@ -0,0 +1,108 @@
1
+ """Frozen Day 5 retrieval, indexing and embedding values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from nexus.domain.exploration import ExplorationResult
11
+
12
+
13
+ class RetrievalSource(StrEnum):
14
+ LEXICAL = "LEXICAL"
15
+ SEMANTIC = "SEMANTIC"
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class CodeChunk:
20
+ file_path: str
21
+ language: str
22
+ symbol: str | None
23
+ start_line: int
24
+ end_line: int
25
+ content: str
26
+ content_hash: str
27
+ file_hash: str
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ContextCandidate:
32
+ chunk: CodeChunk
33
+ lexical_rank: int | None
34
+ semantic_rank: int | None
35
+ semantic_score: float | None
36
+ rrf_score: float
37
+ sources: tuple[RetrievalSource, ...]
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class ContextBudget:
42
+ max_retrieved_chunks: int
43
+ max_exploration_seed_chunks: int
44
+ max_code_context_tokens: int
45
+ max_recent_observations: int
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class RetrievalQuery:
50
+ repository_id: str
51
+ workspace_path: str
52
+ text: str
53
+ limit: int
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class RetrievalResult:
58
+ candidates: tuple[ContextCandidate, ...]
59
+ semantic_used: bool
60
+ semantic_status: str | None
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class ContextRequest:
65
+ task: str
66
+ repository_id: str
67
+ workspace_path: str
68
+ exploration: ExplorationResult
69
+ run_id: str
70
+ session_id: str
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class EmbeddingConfig:
75
+ provider: str
76
+ model: str
77
+ dimension: int
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class IndexRequest:
82
+ repository_id: str
83
+ workspace_path: str
84
+ rebuild: bool
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class IndexResult:
89
+ repository_id: str
90
+ scanned_files: int
91
+ indexed_files: int
92
+ unchanged_files: int
93
+ removed_files: int
94
+ skipped_files: int
95
+ chunk_count: int
96
+ rebuilt: bool
97
+
98
+
99
+ class IndexCompatibilityStatus(StrEnum):
100
+ COMPATIBLE = "COMPATIBLE"
101
+ INDEX_NOT_FOUND = "INDEX_NOT_FOUND"
102
+ INDEX_INCOMPATIBLE = "INDEX_INCOMPATIBLE"
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class IndexCompatibility:
107
+ status: IndexCompatibilityStatus
108
+ reason: str | None
@@ -0,0 +1,86 @@
1
+ """Day 4 selective exploration and bounded context values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from nexus.domain.context import ContextCandidate
9
+ from nexus.domain.persistence import SessionTurn
10
+ from nexus.domain.skills import SelectedSkill, SkillSelectionResult
11
+
12
+ if TYPE_CHECKING:
13
+ from nexus.domain.agent_decision import Observation
14
+
15
+ from nexus.domain.tooling import ToolResult
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class ExplorationRequest:
20
+ task: str
21
+ run_id: str
22
+ session_id: str
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class RepositoryInstruction:
27
+ path: str
28
+ scope_path: str
29
+ depth: int
30
+ content: str
31
+ truncated: bool
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class RepositoryFileEvidence:
36
+ path: str
37
+ category: str
38
+ discovery_reason: str
39
+ summary: str
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class ExplorationResult:
44
+ instructions: tuple[RepositoryInstruction, ...]
45
+ manifests: tuple[RepositoryFileEvidence, ...]
46
+ top_level_paths: tuple[str, ...]
47
+ relevant_files: tuple[RepositoryFileEvidence, ...]
48
+ initial_git_status: ToolResult
49
+ tool_results: tuple[ToolResult, ...]
50
+ truncated: bool
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class SelectedFileContext:
55
+ path: str
56
+ content: str
57
+ applicable_instruction_paths: tuple[str, ...]
58
+ discovery_reason: str
59
+ truncated: bool
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class WorkingContext:
64
+ task: str
65
+ repository_instructions: tuple[RepositoryInstruction, ...]
66
+ manifest_summaries: tuple[RepositoryFileEvidence, ...]
67
+ top_level_paths: tuple[str, ...]
68
+ selected_files: tuple[SelectedFileContext, ...]
69
+ truncated: bool
70
+ retrieved_candidates: tuple[ContextCandidate, ...] = ()
71
+ compacted_conversation: str | None = None
72
+ semantic_retrieval_used: bool = False
73
+ semantic_retrieval_status: str | None = None
74
+ recent_observations: tuple[Observation, ...] = ()
75
+ recent_conversation_turns: tuple[SessionTurn, ...] = ()
76
+ compacted_observations: str | None = None
77
+ selected_skills: tuple[SelectedSkill, ...] = ()
78
+ skill_selection_result: SkillSelectionResult | None = None
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class ContextBuildRequest:
83
+ task: str
84
+ exploration: ExplorationResult
85
+ run_id: str
86
+ session_id: str
nexus/domain/mcp.py ADDED
@@ -0,0 +1,51 @@
1
+ """Nexus-owned, SDK-neutral MCP metadata values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from nexus.domain.tooling import JsonObject
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class MCPToolDescriptor:
12
+ server_id: str
13
+ remote_name: str
14
+ registry_name: str
15
+ description: str | None
16
+ input_schema: JsonObject
17
+
18
+ def __post_init__(self) -> None:
19
+ if not self.server_id or not self.remote_name or not self.registry_name:
20
+ raise ValueError("MCP Tool identity must not be empty.")
21
+ if self.description is not None and not isinstance(self.description, str):
22
+ raise ValueError("MCP Tool description must be text or None.")
23
+ if not _is_json_value(self.input_schema):
24
+ raise ValueError("MCP input schema must be a JSON object.")
25
+ object.__setattr__(self, "input_schema", dict(self.input_schema))
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class MCPConnectionInfo:
30
+ server_id: str
31
+ connected: bool
32
+ server_name: str | None
33
+ server_version: str | None
34
+ negotiated_protocol_version: str | None
35
+
36
+ def __post_init__(self) -> None:
37
+ if not self.server_id:
38
+ raise ValueError("MCP server_id must not be empty.")
39
+
40
+
41
+ def _is_json_value(value: object) -> bool:
42
+ if value is None or isinstance(value, (str, int, float, bool)):
43
+ return True
44
+ if isinstance(value, list):
45
+ return all(_is_json_value(item) for item in value)
46
+ if isinstance(value, dict):
47
+ return all(
48
+ isinstance(key, str) and _is_json_value(item)
49
+ for key, item in value.items()
50
+ )
51
+ return False
nexus/domain/model.py ADDED
@@ -0,0 +1,173 @@
1
+ """Provider-neutral model request, response, and usage values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from typing import Literal
8
+
9
+ ModelRole = Literal["system", "user", "assistant"]
10
+
11
+
12
+ class UsageAvailability(StrEnum):
13
+ REPORTED = "REPORTED"
14
+ PARTIAL = "PARTIAL"
15
+ UNAVAILABLE = "UNAVAILABLE"
16
+
17
+
18
+ class ModelCallPhase(StrEnum):
19
+ DIRECT_RESPONSE = "DIRECT_RESPONSE"
20
+ PLAN = "PLAN"
21
+ REPLAN = "REPLAN"
22
+ REPAIR = "REPAIR"
23
+ AGENT_STEP = "AGENT_STEP"
24
+ SKILL_SELECTION = "SKILL_SELECTION"
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class TokenUsage:
29
+ input_tokens: int | None
30
+ output_tokens: int | None
31
+ total_tokens: int | None
32
+ availability: UsageAvailability
33
+
34
+ def __post_init__(self) -> None:
35
+ values = (self.input_tokens, self.output_tokens, self.total_tokens)
36
+ if any(value is not None and value < 0 for value in values):
37
+ raise ValueError("Token usage values must not be negative.")
38
+ known = sum(value is not None for value in values)
39
+ if self.availability is UsageAvailability.UNAVAILABLE and known != 0:
40
+ raise ValueError("UNAVAILABLE token usage cannot contain reported values.")
41
+ if self.availability is UsageAvailability.REPORTED and known != 3:
42
+ raise ValueError("REPORTED token usage requires a complete usage triple.")
43
+ if self.availability is UsageAvailability.PARTIAL and known not in {1, 2}:
44
+ raise ValueError("PARTIAL token usage requires one or two reported values.")
45
+ if known == 3:
46
+ assert self.input_tokens is not None
47
+ assert self.output_tokens is not None
48
+ if self.total_tokens != self.input_tokens + self.output_tokens:
49
+ raise ValueError("Token total must equal input plus output tokens.")
50
+
51
+ @classmethod
52
+ def unavailable(cls) -> TokenUsage:
53
+ return cls(None, None, None, UsageAvailability.UNAVAILABLE)
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class TokenUsageAggregate:
58
+ input_tokens_reported: int = 0
59
+ output_tokens_reported: int = 0
60
+ total_tokens_reported: int = 0
61
+ calls_with_reported_usage: int = 0
62
+ calls_with_partial_usage: int = 0
63
+ calls_with_unavailable_usage: int = 0
64
+ usage_complete: bool = True
65
+
66
+ def __post_init__(self) -> None:
67
+ counters = (
68
+ self.input_tokens_reported,
69
+ self.output_tokens_reported,
70
+ self.total_tokens_reported,
71
+ self.calls_with_reported_usage,
72
+ self.calls_with_partial_usage,
73
+ self.calls_with_unavailable_usage,
74
+ )
75
+ if any(value < 0 for value in counters):
76
+ raise ValueError("Token usage aggregate values must not be negative.")
77
+ complete = (
78
+ self.calls_with_partial_usage == 0
79
+ and self.calls_with_unavailable_usage == 0
80
+ )
81
+ if self.usage_complete is not complete:
82
+ raise ValueError("Token usage aggregate completeness is inconsistent.")
83
+
84
+ @property
85
+ def call_count(self) -> int:
86
+ return (
87
+ self.calls_with_reported_usage
88
+ + self.calls_with_partial_usage
89
+ + self.calls_with_unavailable_usage
90
+ )
91
+
92
+ def add(self, usage: TokenUsage) -> TokenUsageAggregate:
93
+ return TokenUsageAggregate(
94
+ input_tokens_reported=self.input_tokens_reported + (usage.input_tokens or 0),
95
+ output_tokens_reported=self.output_tokens_reported + (usage.output_tokens or 0),
96
+ total_tokens_reported=self.total_tokens_reported + (usage.total_tokens or 0),
97
+ calls_with_reported_usage=(
98
+ self.calls_with_reported_usage
99
+ + int(usage.availability is UsageAvailability.REPORTED)
100
+ ),
101
+ calls_with_partial_usage=(
102
+ self.calls_with_partial_usage
103
+ + int(usage.availability is UsageAvailability.PARTIAL)
104
+ ),
105
+ calls_with_unavailable_usage=(
106
+ self.calls_with_unavailable_usage
107
+ + int(usage.availability is UsageAvailability.UNAVAILABLE)
108
+ ),
109
+ usage_complete=(
110
+ self.usage_complete
111
+ and usage.availability is UsageAvailability.REPORTED
112
+ ),
113
+ )
114
+
115
+ def to_usage(self) -> TokenUsage:
116
+ if self.call_count == 0 or (
117
+ self.calls_with_reported_usage == 0
118
+ and self.calls_with_partial_usage == 0
119
+ ):
120
+ return TokenUsage.unavailable()
121
+ availability = (
122
+ UsageAvailability.REPORTED
123
+ if self.usage_complete
124
+ else UsageAvailability.PARTIAL
125
+ )
126
+ if availability is UsageAvailability.PARTIAL:
127
+ values: tuple[int | None, int | None, int | None] = (
128
+ self.input_tokens_reported,
129
+ self.output_tokens_reported,
130
+ None,
131
+ )
132
+ else:
133
+ values = (
134
+ self.input_tokens_reported,
135
+ self.output_tokens_reported,
136
+ self.total_tokens_reported,
137
+ )
138
+ return TokenUsage(*values, availability)
139
+
140
+ def to_dict(self) -> dict[str, int | bool]:
141
+ return {
142
+ "input_tokens_reported": self.input_tokens_reported,
143
+ "output_tokens_reported": self.output_tokens_reported,
144
+ "total_tokens_reported": self.total_tokens_reported,
145
+ "calls_with_reported_usage": self.calls_with_reported_usage,
146
+ "calls_with_partial_usage": self.calls_with_partial_usage,
147
+ "calls_with_unavailable_usage": self.calls_with_unavailable_usage,
148
+ "usage_complete": self.usage_complete,
149
+ }
150
+
151
+
152
+ @dataclass(frozen=True, slots=True)
153
+ class ModelMessage:
154
+ """A minimal Nexus-owned chat message."""
155
+
156
+ role: ModelRole
157
+ content: str
158
+
159
+
160
+ @dataclass(frozen=True, slots=True)
161
+ class ModelResponse:
162
+ """A normalized non-streaming model response."""
163
+
164
+ content: str
165
+ usage: TokenUsage | None = None
166
+
167
+
168
+ @dataclass(frozen=True, slots=True)
169
+ class ModelChunk:
170
+ """A normalized streaming model fragment."""
171
+
172
+ content: str
173
+ usage: TokenUsage | None = None