devtorch-core 3.0.1__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.
- devtorch_core/__init__.py +158 -0
- devtorch_core/aggphi_textual.py +275 -0
- devtorch_core/alerts/__init__.py +23 -0
- devtorch_core/alerts/base.py +46 -0
- devtorch_core/alerts/config.py +60 -0
- devtorch_core/alerts/dispatcher.py +110 -0
- devtorch_core/alerts/jira.py +96 -0
- devtorch_core/alerts/linear.py +72 -0
- devtorch_core/alerts/pagerduty.py +66 -0
- devtorch_core/alerts/slack.py +81 -0
- devtorch_core/alerts/teams.py +70 -0
- devtorch_core/audit/__init__.py +43 -0
- devtorch_core/audit/exporter.py +297 -0
- devtorch_core/audit/privacy.py +101 -0
- devtorch_core/audit/scrubber.py +149 -0
- devtorch_core/audit/service.py +67 -0
- devtorch_core/audit/signing.py +127 -0
- devtorch_core/broadcast/__init__.py +4 -0
- devtorch_core/broadcast/broadcaster.py +100 -0
- devtorch_core/broadcast/watcher.py +71 -0
- devtorch_core/capability.py +639 -0
- devtorch_core/cloud/__init__.py +1 -0
- devtorch_core/cloud/client_config.py +472 -0
- devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
- devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
- devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
- devtorch_core/cloud/client_configs/.opencode.json +15 -0
- devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
- devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
- devtorch_core/cloud/mcp_client.py +229 -0
- devtorch_core/cloud/setup.py +144 -0
- devtorch_core/cloud/sync.py +143 -0
- devtorch_core/cloud/sync_bundle.py +603 -0
- devtorch_core/cloud/sync_conflicts.py +159 -0
- devtorch_core/cloud/sync_state.py +159 -0
- devtorch_core/cloud/team_sync.py +283 -0
- devtorch_core/codex/__init__.py +9 -0
- devtorch_core/codex/__main__.py +97 -0
- devtorch_core/codex/capture.py +208 -0
- devtorch_core/codex/proxy.py +412 -0
- devtorch_core/concept_catalog.py +209 -0
- devtorch_core/consolidation/__init__.py +3 -0
- devtorch_core/consolidation/synthesizer.py +87 -0
- devtorch_core/consolidation/workflow.py +175 -0
- devtorch_core/daemon/__init__.py +27 -0
- devtorch_core/daemon/supervisor.py +293 -0
- devtorch_core/daemon/watcher.py +244 -0
- devtorch_core/dashboard_api.py +2012 -0
- devtorch_core/deltaf.py +97 -0
- devtorch_core/disclosure.py +50 -0
- devtorch_core/divergence/__init__.py +3 -0
- devtorch_core/divergence/detector.py +166 -0
- devtorch_core/gateway/__init__.py +32 -0
- devtorch_core/gateway/key_manager.py +124 -0
- devtorch_core/gateway/metrics_webhook.py +252 -0
- devtorch_core/gateway/policy.py +262 -0
- devtorch_core/gateway/server.py +727 -0
- devtorch_core/gateway/sso.py +233 -0
- devtorch_core/gcc.py +1246 -0
- devtorch_core/github/__init__.py +35 -0
- devtorch_core/github/app.py +240 -0
- devtorch_core/github/comment_builder.py +113 -0
- devtorch_core/github/pat.py +76 -0
- devtorch_core/github/pr_parser.py +82 -0
- devtorch_core/github/pr_reporter.py +555 -0
- devtorch_core/gitlab/__init__.py +177 -0
- devtorch_core/hitl/__init__.py +4 -0
- devtorch_core/hitl/channels.py +129 -0
- devtorch_core/hitl/orchestrator.py +95 -0
- devtorch_core/hooks/__init__.py +17 -0
- devtorch_core/hooks/claude_code.py +228 -0
- devtorch_core/hooks/git_capture.py +341 -0
- devtorch_core/hooks/git_commit.py +182 -0
- devtorch_core/hooks/installer.py +733 -0
- devtorch_core/hooks/pre_commit.py +157 -0
- devtorch_core/hooks/runner.py +344 -0
- devtorch_core/identity/__init__.py +4 -0
- devtorch_core/identity/agent.py +86 -0
- devtorch_core/identity/providers.py +85 -0
- devtorch_core/invariants.py +182 -0
- devtorch_core/mcp/__init__.py +10 -0
- devtorch_core/mcp/auth.py +177 -0
- devtorch_core/mcp/server.py +1049 -0
- devtorch_core/metrics/__init__.py +35 -0
- devtorch_core/metrics/aggregate.py +215 -0
- devtorch_core/metrics/calibrate.py +198 -0
- devtorch_core/metrics/calibration.py +125 -0
- devtorch_core/metrics/credibility.py +288 -0
- devtorch_core/metrics/delivery_time.py +70 -0
- devtorch_core/metrics/dhs.py +126 -0
- devtorch_core/metrics/mcs.py +96 -0
- devtorch_core/metrics/roi.py +88 -0
- devtorch_core/metrics/session_writer.py +81 -0
- devtorch_core/metrics/shadow_ai.py +117 -0
- devtorch_core/metrics/sprint_writer.py +243 -0
- devtorch_core/observability/__init__.py +78 -0
- devtorch_core/observability/datadog.py +157 -0
- devtorch_core/observability/formatter.py +119 -0
- devtorch_core/observability/report.py +264 -0
- devtorch_core/observability/servicenow.py +147 -0
- devtorch_core/observability/splunk.py +218 -0
- devtorch_core/observability/webhook.py +227 -0
- devtorch_core/parser/__init__.py +30 -0
- devtorch_core/parser/blocks.py +216 -0
- devtorch_core/parser/inference.py +159 -0
- devtorch_core/parser/thinking.py +112 -0
- devtorch_core/projects.py +169 -0
- devtorch_core/prompt_artifact.py +76 -0
- devtorch_core/proxy/__init__.py +9 -0
- devtorch_core/proxy/routes/__init__.py +1 -0
- devtorch_core/proxy/routes/anthropic.py +264 -0
- devtorch_core/proxy/routes/azure_openai.py +336 -0
- devtorch_core/proxy/routes/gemini.py +331 -0
- devtorch_core/proxy/routes/groq.py +284 -0
- devtorch_core/proxy/routes/ollama.py +279 -0
- devtorch_core/proxy/routes/openai.py +287 -0
- devtorch_core/proxy/server.py +356 -0
- devtorch_core/query/__init__.py +15 -0
- devtorch_core/query/grep.py +181 -0
- devtorch_core/query/hybrid.py +86 -0
- devtorch_core/query/semantic.py +157 -0
- devtorch_core/rdp.py +105 -0
- devtorch_core/reasoning/__init__.py +4 -0
- devtorch_core/reasoning/entry.py +31 -0
- devtorch_core/reasoning/store.py +122 -0
- devtorch_core/reasoning_plus/__init__.py +70 -0
- devtorch_core/reasoning_plus/augmenter.py +326 -0
- devtorch_core/reasoning_plus/capture.py +51 -0
- devtorch_core/reasoning_plus/config.py +256 -0
- devtorch_core/reasoning_plus/context.py +262 -0
- devtorch_core/reasoning_plus/learning/__init__.py +72 -0
- devtorch_core/reasoning_plus/learning/analytics.py +141 -0
- devtorch_core/reasoning_plus/learning/api.py +313 -0
- devtorch_core/reasoning_plus/learning/chain.py +285 -0
- devtorch_core/reasoning_plus/learning/composer.py +74 -0
- devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
- devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
- devtorch_core/reasoning_plus/learning/extractor.py +207 -0
- devtorch_core/reasoning_plus/learning/models.py +116 -0
- devtorch_core/reasoning_plus/learning/provenance.py +126 -0
- devtorch_core/reasoning_plus/learning/recorder.py +81 -0
- devtorch_core/reasoning_plus/learning/relevance.py +122 -0
- devtorch_core/reasoning_plus/learning/state.py +86 -0
- devtorch_core/reasoning_plus/learning/store.py +160 -0
- devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- devtorch_core/reasoning_plus/prompt.py +90 -0
- devtorch_core/rep.py +134 -0
- devtorch_core/rep_network/__init__.py +25 -0
- devtorch_core/rep_network/merge.py +70 -0
- devtorch_core/rep_network/node.py +137 -0
- devtorch_core/rep_network/server.py +140 -0
- devtorch_core/rep_network/sync.py +207 -0
- devtorch_core/sensitivity.py +182 -0
- devtorch_core/serve.py +258 -0
- devtorch_core/session/__init__.py +39 -0
- devtorch_core/session/disagreement.py +188 -0
- devtorch_core/session/models.py +114 -0
- devtorch_core/session/orchestrator.py +182 -0
- devtorch_core/session/planner.py +169 -0
- devtorch_core/session/simulator.py +132 -0
- devtorch_core/signing.py +290 -0
- devtorch_core/sis.py +197 -0
- devtorch_core/storage.py +308 -0
- devtorch_core/templates/__init__.py +6 -0
- devtorch_core/templates/engine.py +122 -0
- devtorch_core/templates/go.py +18 -0
- devtorch_core/templates/infra.py +19 -0
- devtorch_core/templates/library/__init__.py +18 -0
- devtorch_core/templates/library/api_design.md +27 -0
- devtorch_core/templates/library/bug_fix.md +27 -0
- devtorch_core/templates/library/decision_record.md +27 -0
- devtorch_core/templates/library/engine.py +228 -0
- devtorch_core/templates/library/security_review.md +30 -0
- devtorch_core/templates/python.py +19 -0
- devtorch_core/templates/react.py +18 -0
- devtorch_core/templates/typescript.py +18 -0
- devtorch_core/theta.py +221 -0
- devtorch_core/theta_synthesis.py +268 -0
- devtorch_core/topics.py +320 -0
- devtorch_core/variance.py +219 -0
- devtorch_core/wrapper/__init__.py +52 -0
- devtorch_core/wrapper/anthropic.py +487 -0
- devtorch_core/wrapper/base.py +562 -0
- devtorch_core/wrapper/bedrock.py +342 -0
- devtorch_core/wrapper/gemini.py +422 -0
- devtorch_core/wrapper/ollama.py +527 -0
- devtorch_core/wrapper/openai.py +461 -0
- devtorch_core-3.0.1.dist-info/METADATA +867 -0
- devtorch_core-3.0.1.dist-info/RECORD +193 -0
- devtorch_core-3.0.1.dist-info/WHEEL +5 -0
- devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
- devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""HITL disagreement resolution for multi-agent sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import datetime as _dt
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from devtorch_core import GCCRepository
|
|
14
|
+
from devtorch_core.hitl import HITLChannel, HITLOrchestrator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclasses.dataclass
|
|
18
|
+
class DisagreementRecord:
|
|
19
|
+
"""
|
|
20
|
+
Durable record of a disagreement between agents on a concept.
|
|
21
|
+
|
|
22
|
+
Positions are free-form agent stances (e.g. a disclosure level or a design
|
|
23
|
+
choice) plus a confidence score. The resolver can auto-consolidate, ask
|
|
24
|
+
a human, or escalate.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
disagreement_id: str
|
|
28
|
+
session_id: str
|
|
29
|
+
concept: str
|
|
30
|
+
positions: List[Dict[str, Any]] # [{agent_id, position, confidence}, ...]
|
|
31
|
+
state: str # "open" | "consolidated" | "escalated"
|
|
32
|
+
resolution: str
|
|
33
|
+
created_at: str
|
|
34
|
+
updated_at: str
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict:
|
|
37
|
+
return dataclasses.asdict(self)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_dict(cls, d: dict) -> "DisagreementRecord":
|
|
41
|
+
known = {f.name for f in dataclasses.fields(cls)}
|
|
42
|
+
return cls(**{k: v for k, v in d.items() if k in known})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DisagreementResolver:
|
|
46
|
+
"""
|
|
47
|
+
Records and resolves disagreements from multi-agent sessions.
|
|
48
|
+
|
|
49
|
+
Persisted layout: `.GCC/sessions/disagreements/<disagreement_id>.json`
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, gcc_dir: Path, channels: Optional[List[HITLChannel]] = None) -> None:
|
|
53
|
+
self._gcc_dir = Path(gcc_dir)
|
|
54
|
+
self._dir = self._gcc_dir / "sessions" / "disagreements"
|
|
55
|
+
self._channels = channels or []
|
|
56
|
+
|
|
57
|
+
def _ensure_dir(self) -> None:
|
|
58
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
def _persist(self, record: DisagreementRecord) -> Path:
|
|
61
|
+
self._ensure_dir()
|
|
62
|
+
path = self._dir / f"{record.disagreement_id}.json"
|
|
63
|
+
path.write_text(
|
|
64
|
+
json.dumps(record.to_dict(), indent=2, sort_keys=True) + "\n",
|
|
65
|
+
encoding="utf-8",
|
|
66
|
+
)
|
|
67
|
+
return path
|
|
68
|
+
|
|
69
|
+
def _load(self, disagreement_id: str) -> Optional[DisagreementRecord]:
|
|
70
|
+
path = self._dir / f"{disagreement_id}.json"
|
|
71
|
+
if not path.exists():
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
return DisagreementRecord.from_dict(
|
|
75
|
+
json.loads(path.read_text(encoding="utf-8"))
|
|
76
|
+
)
|
|
77
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def record_disagreement(
|
|
81
|
+
self,
|
|
82
|
+
session_id: str,
|
|
83
|
+
concept: str,
|
|
84
|
+
positions: List[Dict[str, Any]],
|
|
85
|
+
) -> DisagreementRecord:
|
|
86
|
+
"""Record a new disagreement and persist it."""
|
|
87
|
+
now = _dt.datetime.now(tz=_dt.timezone.utc).isoformat()
|
|
88
|
+
record = DisagreementRecord(
|
|
89
|
+
disagreement_id=str(uuid.uuid4()),
|
|
90
|
+
session_id=session_id,
|
|
91
|
+
concept=concept,
|
|
92
|
+
positions=positions,
|
|
93
|
+
state="open",
|
|
94
|
+
resolution="",
|
|
95
|
+
created_at=now,
|
|
96
|
+
updated_at=now,
|
|
97
|
+
)
|
|
98
|
+
self._persist(record)
|
|
99
|
+
return record
|
|
100
|
+
|
|
101
|
+
def list_disagreements(self, session_id: Optional[str] = None) -> List[DisagreementRecord]:
|
|
102
|
+
"""Return all disagreements, optionally filtered by session_id."""
|
|
103
|
+
results: List[DisagreementRecord] = []
|
|
104
|
+
if not self._dir.exists():
|
|
105
|
+
return results
|
|
106
|
+
for f in sorted(self._dir.glob("*.json")):
|
|
107
|
+
try:
|
|
108
|
+
d = json.loads(f.read_text(encoding="utf-8"))
|
|
109
|
+
if session_id is not None and d.get("session_id") != session_id:
|
|
110
|
+
continue
|
|
111
|
+
results.append(DisagreementRecord.from_dict(d))
|
|
112
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
113
|
+
continue
|
|
114
|
+
return results
|
|
115
|
+
|
|
116
|
+
def resolve(
|
|
117
|
+
self,
|
|
118
|
+
disagreement_id: str,
|
|
119
|
+
human_decision: Optional[str] = None,
|
|
120
|
+
channels: Optional[List[HITLChannel]] = None,
|
|
121
|
+
) -> DisagreementRecord:
|
|
122
|
+
"""
|
|
123
|
+
Resolve a disagreement.
|
|
124
|
+
|
|
125
|
+
- If *human_decision* is provided, record it as the consolidated resolution.
|
|
126
|
+
- Otherwise, if the disagreement needs human input, run the existing
|
|
127
|
+
HITLOrchestrator flow. If the orchestrator returns a resolved/auto
|
|
128
|
+
resolved consolidation record, use its decision; otherwise escalate.
|
|
129
|
+
- If no human input is required, auto-consolidate by selecting the
|
|
130
|
+
highest-confidence position.
|
|
131
|
+
"""
|
|
132
|
+
record = self._load(disagreement_id)
|
|
133
|
+
if record is None:
|
|
134
|
+
raise ValueError(f"Disagreement {disagreement_id!r} not found")
|
|
135
|
+
|
|
136
|
+
now = _dt.datetime.now(tz=_dt.timezone.utc).isoformat()
|
|
137
|
+
|
|
138
|
+
if human_decision is not None:
|
|
139
|
+
record.resolution = human_decision
|
|
140
|
+
record.state = "consolidated"
|
|
141
|
+
elif self.needs_human_input(record):
|
|
142
|
+
hitl_channels = channels if channels is not None else self._channels
|
|
143
|
+
# gcc_dir is the .GCC directory; GCCRepository expects the project root.
|
|
144
|
+
repo = GCCRepository(self._gcc_dir.parent)
|
|
145
|
+
orchestrator = HITLOrchestrator(repo, channels=hitl_channels, poll_attempts=1)
|
|
146
|
+
consolidation = orchestrator.run(record.concept)
|
|
147
|
+
if consolidation is not None and consolidation.state in ("resolved", "auto_resolved"):
|
|
148
|
+
record.resolution = consolidation.decision or ""
|
|
149
|
+
record.state = "consolidated"
|
|
150
|
+
else:
|
|
151
|
+
record.state = "escalated"
|
|
152
|
+
else:
|
|
153
|
+
best = max(
|
|
154
|
+
record.positions,
|
|
155
|
+
key=lambda p: float(p.get("confidence", 0.0)),
|
|
156
|
+
)
|
|
157
|
+
record.resolution = str(best.get("position", ""))
|
|
158
|
+
record.state = "consolidated"
|
|
159
|
+
|
|
160
|
+
record.updated_at = now
|
|
161
|
+
self._persist(record)
|
|
162
|
+
return record
|
|
163
|
+
|
|
164
|
+
def needs_human_input(self, disagreement: DisagreementRecord) -> bool:
|
|
165
|
+
"""
|
|
166
|
+
Return True when the disagreement should be escalated to a human.
|
|
167
|
+
|
|
168
|
+
Triggers:
|
|
169
|
+
- confidence gap between the highest and lowest position > 0.3
|
|
170
|
+
- positions contain both PUBLIC and PRIVATE disclosure stances
|
|
171
|
+
"""
|
|
172
|
+
positions = disagreement.positions
|
|
173
|
+
if not positions:
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
confidences = [float(p.get("confidence", 0.0)) for p in positions]
|
|
177
|
+
if max(confidences) - min(confidences) > 0.3:
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
public_seen = any(
|
|
181
|
+
re.search(r"\bPUBLIC\b", str(p.get("position", "")))
|
|
182
|
+
for p in positions
|
|
183
|
+
)
|
|
184
|
+
private_seen = any(
|
|
185
|
+
re.search(r"\bPRIVATE\b", str(p.get("position", "")))
|
|
186
|
+
for p in positions
|
|
187
|
+
)
|
|
188
|
+
return public_seen and private_seen
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sprint 28 — Session orchestration and sub-task planning models.
|
|
3
|
+
|
|
4
|
+
Lightweight dataclasses for multi-agent sessions, subtasks, and agent role
|
|
5
|
+
assignments. All objects are plain dicts round-trippable via to_dict/from_dict
|
|
6
|
+
so they can be stored as JSON under `.GCC/sessions/`.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import datetime as dt
|
|
12
|
+
from typing import Any, Dict, List
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Session lifecycle statuses as string constants.
|
|
16
|
+
class SessionStatus:
|
|
17
|
+
PENDING = "pending"
|
|
18
|
+
PLANNING = "planning"
|
|
19
|
+
ACTIVE = "active"
|
|
20
|
+
PAUSED = "paused"
|
|
21
|
+
COMPLETED = "completed"
|
|
22
|
+
FAILED = "failed"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Module-level aliases for convenience.
|
|
26
|
+
PENDING = SessionStatus.PENDING
|
|
27
|
+
PLANNING = SessionStatus.PLANNING
|
|
28
|
+
ACTIVE = SessionStatus.ACTIVE
|
|
29
|
+
PAUSED = SessionStatus.PAUSED
|
|
30
|
+
COMPLETED = SessionStatus.COMPLETED
|
|
31
|
+
FAILED = SessionStatus.FAILED
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclasses.dataclass
|
|
35
|
+
class AgentAssignment:
|
|
36
|
+
agent_id: str
|
|
37
|
+
role: str
|
|
38
|
+
capabilities: List[str] = dataclasses.field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict:
|
|
41
|
+
return dataclasses.asdict(self)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_dict(cls, data: dict) -> "AgentAssignment":
|
|
45
|
+
return cls(**data)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclasses.dataclass
|
|
49
|
+
class Subtask:
|
|
50
|
+
subtask_id: str
|
|
51
|
+
session_id: str
|
|
52
|
+
description: str
|
|
53
|
+
dependencies: List[str] = dataclasses.field(default_factory=list)
|
|
54
|
+
status: str = PENDING
|
|
55
|
+
assigned_role: str = ""
|
|
56
|
+
concept: str = ""
|
|
57
|
+
assumptions: Dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
58
|
+
created_at: str = dataclasses.field(default_factory=lambda: _now_iso())
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict:
|
|
61
|
+
return dataclasses.asdict(self)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_dict(cls, data: dict) -> "Subtask":
|
|
65
|
+
return cls(**data)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclasses.dataclass
|
|
69
|
+
class Session:
|
|
70
|
+
session_id: str
|
|
71
|
+
name: str
|
|
72
|
+
problem: str
|
|
73
|
+
status: str = PENDING
|
|
74
|
+
agent_roles: List[AgentAssignment] = dataclasses.field(default_factory=list)
|
|
75
|
+
subtasks: List[Subtask] = dataclasses.field(default_factory=list)
|
|
76
|
+
assumptions: Dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
77
|
+
created_at: str = dataclasses.field(default_factory=lambda: _now_iso())
|
|
78
|
+
updated_at: str = dataclasses.field(default_factory=lambda: _now_iso())
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict:
|
|
81
|
+
return {
|
|
82
|
+
"session_id": self.session_id,
|
|
83
|
+
"name": self.name,
|
|
84
|
+
"problem": self.problem,
|
|
85
|
+
"status": self.status,
|
|
86
|
+
"agent_roles": [a.to_dict() for a in self.agent_roles],
|
|
87
|
+
"subtasks": [s.to_dict() for s in self.subtasks],
|
|
88
|
+
"assumptions": dict(self.assumptions),
|
|
89
|
+
"created_at": self.created_at,
|
|
90
|
+
"updated_at": self.updated_at,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_dict(cls, data: dict) -> "Session":
|
|
95
|
+
return cls(
|
|
96
|
+
session_id=data["session_id"],
|
|
97
|
+
name=data["name"],
|
|
98
|
+
problem=data["problem"],
|
|
99
|
+
status=data["status"],
|
|
100
|
+
agent_roles=[AgentAssignment.from_dict(a) for a in data.get("agent_roles", [])],
|
|
101
|
+
subtasks=[Subtask.from_dict(s) for s in data.get("subtasks", [])],
|
|
102
|
+
assumptions=data.get("assumptions", {}),
|
|
103
|
+
created_at=data["created_at"],
|
|
104
|
+
updated_at=data["updated_at"],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _now_iso() -> str:
|
|
109
|
+
return dt.datetime.now(tz=dt.timezone.utc).isoformat()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_terminal_status(status: str) -> bool:
|
|
113
|
+
"""Return True if a session status is terminal."""
|
|
114
|
+
return status in (SessionStatus.COMPLETED, SessionStatus.FAILED)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sprint 28 — Session orchestrator.
|
|
3
|
+
|
|
4
|
+
Manages the lifecycle of a multi-agent session: creation, persistence under
|
|
5
|
+
`.GCC/sessions/<id>.json`, updates, and closure. When `DEVTORCH_SESSION_CLOUD_URL`
|
|
6
|
+
is set, session metadata is also POSTed to that endpoint. If the endpoint is
|
|
7
|
+
unreachable, the orchestrator falls back to local storage and logs a
|
|
8
|
+
`SESSION_CLOUD_FALLBACK` event to `.GCC/events.log.jsonl`.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, List, Optional
|
|
19
|
+
|
|
20
|
+
from devtorch_core.gcc import GCCRepository
|
|
21
|
+
from devtorch_core.storage import LocalFileBackend, StorageBackend
|
|
22
|
+
|
|
23
|
+
from .models import AgentAssignment, Session, SessionStatus
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
SESSION_DIR_NAME = "sessions"
|
|
27
|
+
SESSION_COUNTER_FILE = ".counter"
|
|
28
|
+
SESSION_CLOUD_URL_ENV = "DEVTORCH_SESSION_CLOUD_URL"
|
|
29
|
+
SESSION_CLOUD_FALLBACK_EVENT = "SESSION_CLOUD_FALLBACK"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SessionOrchestrator:
|
|
33
|
+
"""Create, persist, and update multi-agent sessions."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
gcc_dir: str | Path,
|
|
38
|
+
backend: Optional[StorageBackend] = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
gcc_dir: Path to the `.GCC` directory for this project.
|
|
44
|
+
backend: Optional StorageBackend. Defaults to LocalFileBackend.
|
|
45
|
+
"""
|
|
46
|
+
self.gcc_dir = Path(gcc_dir)
|
|
47
|
+
self._repo = GCCRepository.at(self.gcc_dir.parent)
|
|
48
|
+
self._backend = backend if backend is not None else LocalFileBackend(self.gcc_dir)
|
|
49
|
+
self._sessions_key = SESSION_DIR_NAME
|
|
50
|
+
|
|
51
|
+
def _session_key(self, session_id: str) -> str:
|
|
52
|
+
return f"{self._sessions_key}/{session_id}.json"
|
|
53
|
+
|
|
54
|
+
def _counter_key(self) -> str:
|
|
55
|
+
return f"{self._sessions_key}/{SESSION_COUNTER_FILE}"
|
|
56
|
+
|
|
57
|
+
def _ensure_gcc(self) -> None:
|
|
58
|
+
"""Initialize the `.GCC` repository if it has not been created yet."""
|
|
59
|
+
if not self._repo.is_initialized():
|
|
60
|
+
self._repo.init()
|
|
61
|
+
|
|
62
|
+
def _next_session_id(self, name: str, problem: str) -> str:
|
|
63
|
+
"""Deterministic session ID based on content + counter (no wall-clock)."""
|
|
64
|
+
counter = 0
|
|
65
|
+
counter_key = self._counter_key()
|
|
66
|
+
if self._backend.exists(counter_key):
|
|
67
|
+
raw = self._backend.read_bytes(counter_key).decode("utf-8").strip()
|
|
68
|
+
try:
|
|
69
|
+
counter = int(raw)
|
|
70
|
+
except ValueError:
|
|
71
|
+
counter = 0
|
|
72
|
+
counter += 1
|
|
73
|
+
self._backend.write_bytes(counter_key, str(counter).encode("utf-8"))
|
|
74
|
+
content = f"session:{name}:{problem}:{counter}"
|
|
75
|
+
hash_part = hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
|
|
76
|
+
return f"s{counter:08x}-{hash_part}"
|
|
77
|
+
|
|
78
|
+
def _write_session(self, session: Session) -> None:
|
|
79
|
+
session.updated_at = _now_iso()
|
|
80
|
+
self._backend.write_bytes(
|
|
81
|
+
self._session_key(session.session_id),
|
|
82
|
+
json.dumps(session.to_dict(), indent=2, sort_keys=True).encode("utf-8"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _read_session(self, session_id: str) -> Session:
|
|
86
|
+
key = self._session_key(session_id)
|
|
87
|
+
if not self._backend.exists(key):
|
|
88
|
+
raise FileNotFoundError(f"Session not found: {session_id}")
|
|
89
|
+
data = json.loads(self._backend.read_bytes(key).decode("utf-8"))
|
|
90
|
+
return Session.from_dict(data)
|
|
91
|
+
|
|
92
|
+
def _log_cloud_fallback(self, session: Session, error: str) -> None:
|
|
93
|
+
"""Log a SESSION_CLOUD_FALLBACK event to `.GCC/events.log.jsonl`."""
|
|
94
|
+
self._ensure_gcc()
|
|
95
|
+
self._repo._append_event(
|
|
96
|
+
event_type=SESSION_CLOUD_FALLBACK_EVENT,
|
|
97
|
+
payload={
|
|
98
|
+
"session_id": session.session_id,
|
|
99
|
+
"name": session.name,
|
|
100
|
+
"error": error,
|
|
101
|
+
"cloud_url": os.environ.get(SESSION_CLOUD_URL_ENV, ""),
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _post_to_cloud(self, session: Session) -> None:
|
|
106
|
+
"""POST session metadata to the configured cloud URL if any."""
|
|
107
|
+
cloud_url = os.environ.get(SESSION_CLOUD_URL_ENV, "").strip()
|
|
108
|
+
if not cloud_url:
|
|
109
|
+
return
|
|
110
|
+
try:
|
|
111
|
+
data = json.dumps(session.to_dict(), sort_keys=True).encode("utf-8")
|
|
112
|
+
req = urllib.request.Request(
|
|
113
|
+
cloud_url,
|
|
114
|
+
data=data,
|
|
115
|
+
headers={"Content-Type": "application/json"},
|
|
116
|
+
method="POST",
|
|
117
|
+
)
|
|
118
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
119
|
+
resp.read()
|
|
120
|
+
except Exception as exc: # noqa: BLE001
|
|
121
|
+
self._log_cloud_fallback(session, str(exc))
|
|
122
|
+
|
|
123
|
+
def start_session(
|
|
124
|
+
self,
|
|
125
|
+
name: str,
|
|
126
|
+
problem: str,
|
|
127
|
+
agent_assignments: Optional[List[AgentAssignment]] = None,
|
|
128
|
+
) -> Session:
|
|
129
|
+
"""Create a new session and persist it locally (and to the cloud if configured)."""
|
|
130
|
+
self._ensure_gcc()
|
|
131
|
+
session_id = self._next_session_id(name, problem)
|
|
132
|
+
session = Session(
|
|
133
|
+
session_id=session_id,
|
|
134
|
+
name=name,
|
|
135
|
+
problem=problem,
|
|
136
|
+
status=SessionStatus.PLANNING,
|
|
137
|
+
agent_roles=agent_assignments or [],
|
|
138
|
+
)
|
|
139
|
+
self._write_session(session)
|
|
140
|
+
self._post_to_cloud(session)
|
|
141
|
+
return session
|
|
142
|
+
|
|
143
|
+
def list_sessions(self) -> List[Session]:
|
|
144
|
+
"""Return all stored sessions as `Session` objects."""
|
|
145
|
+
keys = self._backend.list_keys(self._sessions_key)
|
|
146
|
+
sessions: List[Session] = []
|
|
147
|
+
for key in keys:
|
|
148
|
+
if key.endswith(".json") and not key.endswith(f"/{SESSION_COUNTER_FILE}"):
|
|
149
|
+
try:
|
|
150
|
+
data = json.loads(self._backend.read_bytes(key).decode("utf-8"))
|
|
151
|
+
sessions.append(Session.from_dict(data))
|
|
152
|
+
except (json.JSONDecodeError, OSError):
|
|
153
|
+
continue
|
|
154
|
+
return sessions
|
|
155
|
+
|
|
156
|
+
def get_session(self, session_id: str) -> Session:
|
|
157
|
+
"""Load a session by ID."""
|
|
158
|
+
return self._read_session(session_id)
|
|
159
|
+
|
|
160
|
+
def update_session(self, session_id: str, **kwargs: Any) -> Session:
|
|
161
|
+
"""Update a session's fields and persist the change."""
|
|
162
|
+
session = self._read_session(session_id)
|
|
163
|
+
for key, value in kwargs.items():
|
|
164
|
+
if hasattr(session, key):
|
|
165
|
+
setattr(session, key, value)
|
|
166
|
+
self._write_session(session)
|
|
167
|
+
self._post_to_cloud(session)
|
|
168
|
+
return session
|
|
169
|
+
|
|
170
|
+
def close_session(self, session_id: str) -> Session:
|
|
171
|
+
"""Mark a session as completed."""
|
|
172
|
+
return self.update_session(session_id, status=SessionStatus.COMPLETED)
|
|
173
|
+
|
|
174
|
+
def save_session(self, session: Session) -> None:
|
|
175
|
+
"""Persist a fully constructed Session object (used by tests and simulators)."""
|
|
176
|
+
self._write_session(session)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _now_iso() -> str:
|
|
180
|
+
from datetime import datetime, timezone
|
|
181
|
+
|
|
182
|
+
return datetime.now(tz=timezone.utc).isoformat()
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sprint 28 — Sub-task planner.
|
|
3
|
+
|
|
4
|
+
Decomposes a session problem into a list of `Subtask` objects with dependency
|
|
5
|
+
tracking. The default strategy is deterministic rule-based: it parses numbered
|
|
6
|
+
or bulleted lists and falls back to sentence splitting. Dependency links can be
|
|
7
|
+
expressed with `[after:N]` tags where N is a 1-based subtask index.
|
|
8
|
+
|
|
9
|
+
For tests, the decomposition strategy can be injected via the `strategy`
|
|
10
|
+
callable constructor argument.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import re
|
|
16
|
+
from typing import Callable, List, Optional
|
|
17
|
+
|
|
18
|
+
from .models import Session, SessionStatus, Subtask
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_AFTER_TAG_RE = re.compile(r"\[after:(\d+)\]")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _default_strategy(problem: str) -> List[dict]:
|
|
25
|
+
"""
|
|
26
|
+
Deterministic rule-based decomposition.
|
|
27
|
+
|
|
28
|
+
1. Treat lines that begin with a number, bullet, or dash as separate items.
|
|
29
|
+
2. If no such lines are found, split on sentence boundaries.
|
|
30
|
+
3. If the problem is empty, return a single item that captures the original
|
|
31
|
+
problem text.
|
|
32
|
+
"""
|
|
33
|
+
items = []
|
|
34
|
+
for line in problem.splitlines():
|
|
35
|
+
line = line.strip()
|
|
36
|
+
if not line:
|
|
37
|
+
continue
|
|
38
|
+
match = re.match(r"^(?:\d+[\.\)]\s*|[-\*]\s*)(.*)$", line)
|
|
39
|
+
if match:
|
|
40
|
+
items.append(match.group(1).strip())
|
|
41
|
+
|
|
42
|
+
if not items:
|
|
43
|
+
sentences = [s.strip() for s in problem.split(".") if s.strip()]
|
|
44
|
+
if not sentences:
|
|
45
|
+
return [{"description": problem, "dependencies": []}]
|
|
46
|
+
items = sentences
|
|
47
|
+
|
|
48
|
+
return [{"description": item, "dependencies": []} for item in items]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _extract_after_tags(description: str) -> tuple[str, List[str]]:
|
|
52
|
+
"""Return (clean_description, list_of_1_based_dependency_indices)."""
|
|
53
|
+
deps: List[str] = []
|
|
54
|
+
|
|
55
|
+
def _collect(match: re.Match) -> str:
|
|
56
|
+
deps.append(match.group(1))
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
clean = _AFTER_TAG_RE.sub(_collect, description).strip()
|
|
60
|
+
return clean, deps
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SubtaskPlanner:
|
|
64
|
+
"""Decompose problems into subtasks and track their execution order."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, strategy: Optional[Callable[[str], List[dict]]] = None) -> None:
|
|
67
|
+
self._strategy = strategy if strategy is not None else _default_strategy
|
|
68
|
+
self._counter = 0
|
|
69
|
+
|
|
70
|
+
def _next_subtask_id(self, session_id: str, description: str) -> str:
|
|
71
|
+
"""Deterministic subtask ID based on content + counter (no wall-clock)."""
|
|
72
|
+
self._counter += 1
|
|
73
|
+
content = f"subtask:{session_id}:{description}:{self._counter}"
|
|
74
|
+
hash_part = hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
|
|
75
|
+
return f"t{self._counter:08x}-{hash_part}"
|
|
76
|
+
|
|
77
|
+
def plan(
|
|
78
|
+
self,
|
|
79
|
+
session_id: str,
|
|
80
|
+
problem: str,
|
|
81
|
+
orchestrator: "SessionOrchestrator", # type: ignore[name-defined]
|
|
82
|
+
) -> List[Subtask]:
|
|
83
|
+
"""Decompose `problem` and store the resulting subtasks in the session."""
|
|
84
|
+
# Import locally to avoid circular dependency at module load time.
|
|
85
|
+
from .orchestrator import SessionOrchestrator
|
|
86
|
+
|
|
87
|
+
if not isinstance(orchestrator, SessionOrchestrator):
|
|
88
|
+
raise TypeError("orchestrator must be a SessionOrchestrator")
|
|
89
|
+
|
|
90
|
+
items = self._strategy(problem)
|
|
91
|
+
|
|
92
|
+
# Parse descriptions and dependency indices before IDs are generated.
|
|
93
|
+
parsed: List[tuple[str, List[str]]] = []
|
|
94
|
+
for item in items:
|
|
95
|
+
desc = item.get("description", "")
|
|
96
|
+
explicit_deps = item.get("dependencies", [])
|
|
97
|
+
clean_desc, after_indices = _extract_after_tags(desc)
|
|
98
|
+
parsed.append((clean_desc, after_indices + explicit_deps))
|
|
99
|
+
|
|
100
|
+
# Generate subtasks with deterministic IDs based on the cleaned content.
|
|
101
|
+
subtasks: List[Subtask] = []
|
|
102
|
+
for clean_desc, _ in parsed:
|
|
103
|
+
subtask_id = self._next_subtask_id(session_id, clean_desc)
|
|
104
|
+
subtasks.append(
|
|
105
|
+
Subtask(
|
|
106
|
+
subtask_id=subtask_id,
|
|
107
|
+
session_id=session_id,
|
|
108
|
+
description=clean_desc,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Resolve [after:N] indices to actual subtask IDs.
|
|
113
|
+
for i, (_, dep_indices) in enumerate(parsed):
|
|
114
|
+
resolved: List[str] = []
|
|
115
|
+
for idx in dep_indices:
|
|
116
|
+
try:
|
|
117
|
+
n = int(idx) - 1
|
|
118
|
+
except ValueError:
|
|
119
|
+
continue
|
|
120
|
+
if 0 <= n < len(subtasks):
|
|
121
|
+
resolved.append(subtasks[n].subtask_id)
|
|
122
|
+
subtasks[i].dependencies = resolved
|
|
123
|
+
|
|
124
|
+
orchestrator.update_session(
|
|
125
|
+
session_id,
|
|
126
|
+
subtasks=subtasks,
|
|
127
|
+
status=SessionStatus.ACTIVE,
|
|
128
|
+
)
|
|
129
|
+
return subtasks
|
|
130
|
+
|
|
131
|
+
def next_subtask(
|
|
132
|
+
self,
|
|
133
|
+
session_id: str,
|
|
134
|
+
orchestrator: "SessionOrchestrator", # type: ignore[name-defined]
|
|
135
|
+
) -> Optional[Subtask]:
|
|
136
|
+
"""Return the next pending subtask whose dependencies are all completed."""
|
|
137
|
+
from .orchestrator import SessionOrchestrator
|
|
138
|
+
|
|
139
|
+
if not isinstance(orchestrator, SessionOrchestrator):
|
|
140
|
+
raise TypeError("orchestrator must be a SessionOrchestrator")
|
|
141
|
+
|
|
142
|
+
session = orchestrator.get_session(session_id)
|
|
143
|
+
completed = {s.subtask_id for s in session.subtasks if s.status == SessionStatus.COMPLETED}
|
|
144
|
+
for subtask in session.subtasks:
|
|
145
|
+
if subtask.status == SessionStatus.PENDING:
|
|
146
|
+
if all(dep in completed for dep in subtask.dependencies):
|
|
147
|
+
return subtask
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def mark_subtask(
|
|
151
|
+
self,
|
|
152
|
+
session_id: str,
|
|
153
|
+
subtask_id: str,
|
|
154
|
+
status: str,
|
|
155
|
+
orchestrator: "SessionOrchestrator", # type: ignore[name-defined]
|
|
156
|
+
) -> Optional[Subtask]:
|
|
157
|
+
"""Update the status of a subtask and persist the session."""
|
|
158
|
+
from .orchestrator import SessionOrchestrator
|
|
159
|
+
|
|
160
|
+
if not isinstance(orchestrator, SessionOrchestrator):
|
|
161
|
+
raise TypeError("orchestrator must be a SessionOrchestrator")
|
|
162
|
+
|
|
163
|
+
session = orchestrator.get_session(session_id)
|
|
164
|
+
for subtask in session.subtasks:
|
|
165
|
+
if subtask.subtask_id == subtask_id:
|
|
166
|
+
subtask.status = status
|
|
167
|
+
orchestrator.update_session(session_id, subtasks=session.subtasks)
|
|
168
|
+
return subtask
|
|
169
|
+
return None
|