floorctl 0.3.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.
- floorctl/__init__.py +187 -0
- floorctl/agent.py +423 -0
- floorctl/artifacts.py +454 -0
- floorctl/backends/__init__.py +18 -0
- floorctl/backends/base.py +91 -0
- floorctl/backends/firestore.py +371 -0
- floorctl/backends/memory.py +224 -0
- floorctl/backends/websocket.py +361 -0
- floorctl/config.py +159 -0
- floorctl/consensus.py +496 -0
- floorctl/conviction.py +514 -0
- floorctl/floor.py +128 -0
- floorctl/metrics.py +394 -0
- floorctl/moderator.py +425 -0
- floorctl/phases.py +69 -0
- floorctl/py.typed +0 -0
- floorctl/relay/__init__.py +5 -0
- floorctl/relay/__main__.py +31 -0
- floorctl/relay/server.py +392 -0
- floorctl/session.py +196 -0
- floorctl/state.py +113 -0
- floorctl/transcript.py +373 -0
- floorctl/types.py +150 -0
- floorctl/urgency.py +253 -0
- floorctl/validators.py +323 -0
- floorctl-0.3.0.dist-info/METADATA +89 -0
- floorctl-0.3.0.dist-info/RECORD +29 -0
- floorctl-0.3.0.dist-info/WHEEL +4 -0
- floorctl-0.3.0.dist-info/licenses/LICENSE +21 -0
floorctl/__init__.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
floorctl — Contention-based multi-agent coordination with transactional floor control.
|
|
3
|
+
|
|
4
|
+
A framework for building multi-agent systems where agents compete for a shared
|
|
5
|
+
floor, self-validate their outputs, and are observed by a reactive moderator.
|
|
6
|
+
|
|
7
|
+
New in v0.2: Artifacts, Consensus, and Conviction primitives for
|
|
8
|
+
structured collaboration, formal decision-making, and position tracking.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from floorctl import FloorAgent, FloorSession, AgentProfile, InMemoryBackend
|
|
12
|
+
|
|
13
|
+
agent = FloorAgent(name="Alpha", profile=AgentProfile(...), generate_fn=..., backend=...)
|
|
14
|
+
session = FloorSession(backend=InMemoryBackend())
|
|
15
|
+
session.add_agent(agent)
|
|
16
|
+
result = session.run("session-001", topic="...")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from floorctl.agent import FloorAgent
|
|
20
|
+
from floorctl.moderator import (
|
|
21
|
+
ModeratorObserver,
|
|
22
|
+
InterventionDetector,
|
|
23
|
+
InterventionResult,
|
|
24
|
+
EscalationDetector,
|
|
25
|
+
DominanceDetector,
|
|
26
|
+
SilenceDetector,
|
|
27
|
+
)
|
|
28
|
+
from floorctl.session import FloorSession
|
|
29
|
+
from floorctl.state import ConversationState, AgentMemory
|
|
30
|
+
from floorctl.config import (
|
|
31
|
+
ArenaConfig,
|
|
32
|
+
AgentProfile,
|
|
33
|
+
StyleContract,
|
|
34
|
+
PhaseConfig,
|
|
35
|
+
PhaseSequence,
|
|
36
|
+
FloorConfig,
|
|
37
|
+
ModeratorConfig,
|
|
38
|
+
ConsensusConfig,
|
|
39
|
+
ConvictionConfig,
|
|
40
|
+
)
|
|
41
|
+
from floorctl.urgency import UrgencyScorer, UrgencyResult
|
|
42
|
+
from floorctl.types import (
|
|
43
|
+
TurnRecord,
|
|
44
|
+
TurnData,
|
|
45
|
+
SessionResult,
|
|
46
|
+
SessionStatus,
|
|
47
|
+
Temperament,
|
|
48
|
+
ValidationResult,
|
|
49
|
+
FloorEvent,
|
|
50
|
+
GenerateFn,
|
|
51
|
+
ModeratorFn,
|
|
52
|
+
)
|
|
53
|
+
from floorctl.transcript import export_transcript
|
|
54
|
+
from floorctl.validators import (
|
|
55
|
+
Validator,
|
|
56
|
+
run_validators,
|
|
57
|
+
SpeakerPrefixValidator,
|
|
58
|
+
DuplicateValidator,
|
|
59
|
+
LengthValidator,
|
|
60
|
+
BannedPhraseValidator,
|
|
61
|
+
StyleContractValidator,
|
|
62
|
+
PhaseValidator,
|
|
63
|
+
)
|
|
64
|
+
from floorctl.metrics import AgentMetrics, SessionMetrics
|
|
65
|
+
from floorctl.backends.memory import InMemoryBackend
|
|
66
|
+
|
|
67
|
+
# Optional distributed backends
|
|
68
|
+
try:
|
|
69
|
+
from floorctl.backends.websocket import WebSocketBackend
|
|
70
|
+
except ImportError:
|
|
71
|
+
WebSocketBackend = None # type: ignore[assignment,misc]
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
from floorctl.backends.firestore import FirestoreBackend
|
|
75
|
+
except ImportError:
|
|
76
|
+
FirestoreBackend = None # type: ignore[assignment,misc]
|
|
77
|
+
|
|
78
|
+
from floorctl.floor import FloorController
|
|
79
|
+
from floorctl.phases import PhaseManager
|
|
80
|
+
|
|
81
|
+
# New primitives (v0.2)
|
|
82
|
+
from floorctl.artifacts import (
|
|
83
|
+
ArtifactStore,
|
|
84
|
+
Artifact,
|
|
85
|
+
ArtifactVersion,
|
|
86
|
+
ArtifactType,
|
|
87
|
+
ArtifactStatus,
|
|
88
|
+
)
|
|
89
|
+
from floorctl.consensus import (
|
|
90
|
+
ConsensusProtocol,
|
|
91
|
+
Proposal,
|
|
92
|
+
Vote,
|
|
93
|
+
ConsensusResult,
|
|
94
|
+
QuorumRule,
|
|
95
|
+
VoteChoice,
|
|
96
|
+
ProposalStatus,
|
|
97
|
+
)
|
|
98
|
+
from floorctl.conviction import (
|
|
99
|
+
ConvictionTracker,
|
|
100
|
+
EvidenceEvent,
|
|
101
|
+
EvidenceType,
|
|
102
|
+
DriftDirection,
|
|
103
|
+
PositionState,
|
|
104
|
+
ConvictionSnapshot,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
__version__ = "0.3.0"
|
|
108
|
+
|
|
109
|
+
__all__ = [
|
|
110
|
+
# Core
|
|
111
|
+
"FloorAgent",
|
|
112
|
+
"FloorSession",
|
|
113
|
+
"ModeratorObserver",
|
|
114
|
+
# Config
|
|
115
|
+
"ArenaConfig",
|
|
116
|
+
"AgentProfile",
|
|
117
|
+
"StyleContract",
|
|
118
|
+
"PhaseConfig",
|
|
119
|
+
"PhaseSequence",
|
|
120
|
+
"FloorConfig",
|
|
121
|
+
"ModeratorConfig",
|
|
122
|
+
"ConsensusConfig",
|
|
123
|
+
"ConvictionConfig",
|
|
124
|
+
# State
|
|
125
|
+
"ConversationState",
|
|
126
|
+
"AgentMemory",
|
|
127
|
+
# Urgency
|
|
128
|
+
"UrgencyScorer",
|
|
129
|
+
"UrgencyResult",
|
|
130
|
+
# Validators
|
|
131
|
+
"Validator",
|
|
132
|
+
"run_validators",
|
|
133
|
+
"SpeakerPrefixValidator",
|
|
134
|
+
"DuplicateValidator",
|
|
135
|
+
"LengthValidator",
|
|
136
|
+
"BannedPhraseValidator",
|
|
137
|
+
"StyleContractValidator",
|
|
138
|
+
"PhaseValidator",
|
|
139
|
+
# Metrics
|
|
140
|
+
"AgentMetrics",
|
|
141
|
+
"SessionMetrics",
|
|
142
|
+
# Backends
|
|
143
|
+
"InMemoryBackend",
|
|
144
|
+
"WebSocketBackend",
|
|
145
|
+
"FirestoreBackend",
|
|
146
|
+
# Types
|
|
147
|
+
"TurnRecord",
|
|
148
|
+
"TurnData",
|
|
149
|
+
"SessionResult",
|
|
150
|
+
"SessionStatus",
|
|
151
|
+
"Temperament",
|
|
152
|
+
"ValidationResult",
|
|
153
|
+
"FloorEvent",
|
|
154
|
+
"GenerateFn",
|
|
155
|
+
"ModeratorFn",
|
|
156
|
+
# Transcript Export
|
|
157
|
+
"export_transcript",
|
|
158
|
+
# Artifacts (v0.2)
|
|
159
|
+
"ArtifactStore",
|
|
160
|
+
"Artifact",
|
|
161
|
+
"ArtifactVersion",
|
|
162
|
+
"ArtifactType",
|
|
163
|
+
"ArtifactStatus",
|
|
164
|
+
# Consensus (v0.2)
|
|
165
|
+
"ConsensusProtocol",
|
|
166
|
+
"Proposal",
|
|
167
|
+
"Vote",
|
|
168
|
+
"ConsensusResult",
|
|
169
|
+
"QuorumRule",
|
|
170
|
+
"VoteChoice",
|
|
171
|
+
"ProposalStatus",
|
|
172
|
+
# Conviction (v0.2)
|
|
173
|
+
"ConvictionTracker",
|
|
174
|
+
"EvidenceEvent",
|
|
175
|
+
"EvidenceType",
|
|
176
|
+
"DriftDirection",
|
|
177
|
+
"PositionState",
|
|
178
|
+
"ConvictionSnapshot",
|
|
179
|
+
# Internals (for advanced usage)
|
|
180
|
+
"FloorController",
|
|
181
|
+
"PhaseManager",
|
|
182
|
+
"InterventionDetector",
|
|
183
|
+
"InterventionResult",
|
|
184
|
+
"EscalationDetector",
|
|
185
|
+
"DominanceDetector",
|
|
186
|
+
"SilenceDetector",
|
|
187
|
+
]
|
floorctl/agent.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FloorAgent — the active agent SDK for contention-based coordination.
|
|
3
|
+
|
|
4
|
+
Extracted from Sangam's CouncilSDK. Agents independently:
|
|
5
|
+
1. Watch the transcript for new turns
|
|
6
|
+
2. Compute urgency to decide whether to speak
|
|
7
|
+
3. Compete for the floor via atomic claims
|
|
8
|
+
4. Generate responses via user-provided LLM function
|
|
9
|
+
5. Self-validate before posting
|
|
10
|
+
6. Release the floor
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
import threading
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from floorctl.backends.base import Backend
|
|
22
|
+
from floorctl.config import AgentProfile, ArenaConfig, PhaseConfig, StyleContract
|
|
23
|
+
from floorctl.floor import FloorController
|
|
24
|
+
from floorctl.metrics import AgentMetrics
|
|
25
|
+
from floorctl.state import ConversationState
|
|
26
|
+
from floorctl.types import GenerateFn, TurnData, TurnRecord, UrgencyResult
|
|
27
|
+
from floorctl.urgency import UrgencyScorer
|
|
28
|
+
from floorctl.validators import Validator, run_validators, strip_speaker_prefix
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("floorctl.agent")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FloorAgent:
|
|
34
|
+
"""
|
|
35
|
+
Active agent that independently watches, decides, generates, and posts.
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
agent = FloorAgent(name="Alpha", profile=..., generate_fn=..., backend=...)
|
|
39
|
+
agent.listen_and_react(session_id) # blocks until session ends
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
name: str,
|
|
45
|
+
profile: AgentProfile,
|
|
46
|
+
generate_fn: GenerateFn,
|
|
47
|
+
backend: Backend,
|
|
48
|
+
validators: list[Validator] | None = None,
|
|
49
|
+
config: ArenaConfig | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.name = name
|
|
52
|
+
self.profile = profile
|
|
53
|
+
self.generate_fn = generate_fn
|
|
54
|
+
self.backend = backend
|
|
55
|
+
self.validators = validators or []
|
|
56
|
+
self.config = config or ArenaConfig()
|
|
57
|
+
|
|
58
|
+
# Internal state
|
|
59
|
+
self.state = ConversationState()
|
|
60
|
+
self._session_id: str = ""
|
|
61
|
+
self._speaking = False
|
|
62
|
+
self._is_running = False
|
|
63
|
+
self._gave_opening = False
|
|
64
|
+
self._my_turns_this_phase: dict[str, int] = {}
|
|
65
|
+
self._last_posted_turn_index = -10
|
|
66
|
+
self._stop_event = threading.Event()
|
|
67
|
+
|
|
68
|
+
# Core engines
|
|
69
|
+
self.urgency_scorer = UrgencyScorer()
|
|
70
|
+
self.floor_ctrl = FloorController(
|
|
71
|
+
agent_name=name,
|
|
72
|
+
backend=backend,
|
|
73
|
+
config=self.config.floor,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Metrics
|
|
77
|
+
self.metrics: AgentMetrics | None = None
|
|
78
|
+
|
|
79
|
+
# Agent names in session (for validators)
|
|
80
|
+
self._agent_names: list[str] = []
|
|
81
|
+
|
|
82
|
+
def join_session(self, session_id: str) -> None:
|
|
83
|
+
"""Register this agent in the session."""
|
|
84
|
+
self._session_id = session_id
|
|
85
|
+
session_state = self.backend.get_session_state(session_id)
|
|
86
|
+
self.state.topic = session_state.get("topic", "")
|
|
87
|
+
self.state.phase = session_state.get("phase", "")
|
|
88
|
+
self._agent_names = session_state.get("participants", [])
|
|
89
|
+
self.metrics = AgentMetrics(self.name, session_id)
|
|
90
|
+
|
|
91
|
+
def listen_and_react(self, session_id: str) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Block: watch transcript, compute urgency, compete for floor, speak.
|
|
94
|
+
Returns when session ends or stop() is called.
|
|
95
|
+
"""
|
|
96
|
+
self._session_id = session_id
|
|
97
|
+
self._is_running = True
|
|
98
|
+
self._stop_event.clear()
|
|
99
|
+
|
|
100
|
+
if not self.metrics:
|
|
101
|
+
self.metrics = AgentMetrics(self.name, session_id)
|
|
102
|
+
|
|
103
|
+
# Load existing turns
|
|
104
|
+
existing_turns = self.backend.get_turns(session_id)
|
|
105
|
+
for turn in existing_turns:
|
|
106
|
+
self.state.add_turn(turn.speaker, turn.text, turn.is_moderator)
|
|
107
|
+
|
|
108
|
+
# Subscribe to new turns
|
|
109
|
+
unsub_turns = self.backend.subscribe_turns(session_id, self._on_new_turn)
|
|
110
|
+
|
|
111
|
+
# Subscribe to session changes (phase updates, floor signals)
|
|
112
|
+
unsub_session = self.backend.subscribe_session(session_id, self._on_session_update)
|
|
113
|
+
|
|
114
|
+
logger.info(f"[{self.name}] Listening on session {session_id}")
|
|
115
|
+
|
|
116
|
+
# Block until stopped
|
|
117
|
+
self._stop_event.wait()
|
|
118
|
+
|
|
119
|
+
unsub_turns()
|
|
120
|
+
unsub_session()
|
|
121
|
+
self._is_running = False
|
|
122
|
+
logger.info(f"[{self.name}] Stopped listening")
|
|
123
|
+
|
|
124
|
+
def stop(self) -> None:
|
|
125
|
+
"""Signal the agent to stop listening."""
|
|
126
|
+
self._stop_event.set()
|
|
127
|
+
self.floor_ctrl.cancel_retry()
|
|
128
|
+
|
|
129
|
+
# ── Callbacks ────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def _on_new_turn(self, turn: TurnRecord) -> None:
|
|
132
|
+
"""Called when a new turn appears in the transcript."""
|
|
133
|
+
# Record in local state
|
|
134
|
+
self.state.add_turn(turn.speaker, turn.text, turn.is_moderator)
|
|
135
|
+
|
|
136
|
+
# Don't react to our own turns
|
|
137
|
+
if turn.speaker == self.name:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Don't react if we're currently speaking
|
|
141
|
+
if self._speaking:
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
# Check session status
|
|
145
|
+
session_state = self.backend.get_session_state(self._session_id)
|
|
146
|
+
if session_state.get("status") == "COMPLETED":
|
|
147
|
+
self.stop()
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
# Update phase from session
|
|
151
|
+
new_phase = session_state.get("phase", self.state.phase)
|
|
152
|
+
if new_phase != self.state.phase:
|
|
153
|
+
self.state.phase = new_phase
|
|
154
|
+
self.state.reset_phase_count()
|
|
155
|
+
|
|
156
|
+
# Check if we should speak
|
|
157
|
+
self._try_speak(turn)
|
|
158
|
+
|
|
159
|
+
def _on_session_update(self, state: dict[str, Any]) -> None:
|
|
160
|
+
"""Called when session-level state changes."""
|
|
161
|
+
new_phase = state.get("phase", "")
|
|
162
|
+
if new_phase and new_phase != self.state.phase:
|
|
163
|
+
logger.info(f"[{self.name}] Phase changed: {self.state.phase} → {new_phase}")
|
|
164
|
+
self.state.phase = new_phase
|
|
165
|
+
self.state.reset_phase_count()
|
|
166
|
+
|
|
167
|
+
if state.get("status") == "COMPLETED":
|
|
168
|
+
self.stop()
|
|
169
|
+
|
|
170
|
+
# Floor recovery signal
|
|
171
|
+
if (state.get("floor_released_without_post")
|
|
172
|
+
and state.get("floor_released_by") != self.name):
|
|
173
|
+
if self.metrics:
|
|
174
|
+
self.metrics.record_floor_retry(executed=True)
|
|
175
|
+
|
|
176
|
+
# ── Core Turn Logic ──────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def _try_speak(self, last_turn: TurnRecord) -> None:
|
|
179
|
+
"""Decide whether to compete for floor and speak."""
|
|
180
|
+
# Skip terminal phases
|
|
181
|
+
phase_config = self.config.phases.get(self.state.phase)
|
|
182
|
+
if phase_config and phase_config.is_terminal:
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
# Check per-phase turn cap
|
|
186
|
+
my_phase_turns = self.state.agent_turns_in_phase(self.name)
|
|
187
|
+
if my_phase_turns >= self.config.floor.max_turns_per_phase_per_agent:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
# Check min turns between speaking
|
|
191
|
+
turns_since = self.state.turns_since_agent_spoke(self.name)
|
|
192
|
+
if turns_since < self.config.floor.min_turns_between_speaking:
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
# Compute urgency
|
|
196
|
+
threshold_adj = self.floor_ctrl.starvation_threshold_reduction()
|
|
197
|
+
urgency_result = self.urgency_scorer.compute(
|
|
198
|
+
agent_name=self.name,
|
|
199
|
+
last_text=last_turn.text,
|
|
200
|
+
last_speaker=last_turn.speaker,
|
|
201
|
+
is_moderator=last_turn.is_moderator,
|
|
202
|
+
state=self.state,
|
|
203
|
+
profile=self.profile,
|
|
204
|
+
threshold_adjustment=threshold_adj,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Record metrics
|
|
208
|
+
if self.metrics:
|
|
209
|
+
self.metrics.record_urgency(
|
|
210
|
+
urgency=urgency_result.score,
|
|
211
|
+
threshold=urgency_result.threshold,
|
|
212
|
+
triggered_by=urgency_result.triggered_by,
|
|
213
|
+
phase=self.state.phase,
|
|
214
|
+
above_threshold=urgency_result.above_threshold,
|
|
215
|
+
components=urgency_result.components,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if not urgency_result.above_threshold:
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
logger.debug(
|
|
222
|
+
f"[{self.name}] Urgency {urgency_result.score:.3f} > "
|
|
223
|
+
f"{urgency_result.threshold:.3f} — claiming floor"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Try to claim the floor
|
|
227
|
+
claimed = self.floor_ctrl.try_claim(self._session_id)
|
|
228
|
+
if self.metrics:
|
|
229
|
+
self.metrics.record_floor_claim(success=claimed)
|
|
230
|
+
self.metrics.record_floor_event(
|
|
231
|
+
success=claimed, is_retry=False,
|
|
232
|
+
urgency=urgency_result.score,
|
|
233
|
+
threshold=urgency_result.threshold,
|
|
234
|
+
phase=self.state.phase,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if not claimed:
|
|
238
|
+
# Schedule retry
|
|
239
|
+
if self.metrics:
|
|
240
|
+
self.metrics.record_floor_retry(scheduled=True)
|
|
241
|
+
self.floor_ctrl.schedule_retry(
|
|
242
|
+
self._session_id,
|
|
243
|
+
lambda: self._retry_claim(),
|
|
244
|
+
phase_turns=my_phase_turns,
|
|
245
|
+
)
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
# We have the floor — generate and post
|
|
249
|
+
self._speaking = True
|
|
250
|
+
claim_time = time.time()
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
response = self._generate_response()
|
|
254
|
+
if response:
|
|
255
|
+
self._post_turn(response)
|
|
256
|
+
if self.metrics:
|
|
257
|
+
hold_time = time.time() - claim_time
|
|
258
|
+
self.metrics.record_floor_hold_time(hold_time)
|
|
259
|
+
self.metrics.record_floor_release(posted_successfully=True)
|
|
260
|
+
self.metrics.record_silence_duration(turns_since)
|
|
261
|
+
self.floor_ctrl.release(self._session_id, posted_successfully=True)
|
|
262
|
+
else:
|
|
263
|
+
# Validation failed after retries
|
|
264
|
+
if self.metrics:
|
|
265
|
+
self.metrics.record_floor_release(posted_successfully=False)
|
|
266
|
+
self.floor_ctrl.release(self._session_id, posted_successfully=False)
|
|
267
|
+
except Exception as e:
|
|
268
|
+
logger.error(f"[{self.name}] Error during generation: {e}")
|
|
269
|
+
self.floor_ctrl.release(self._session_id, posted_successfully=False)
|
|
270
|
+
finally:
|
|
271
|
+
self._speaking = False
|
|
272
|
+
|
|
273
|
+
def _retry_claim(self) -> None:
|
|
274
|
+
"""Retry floor claim after delay."""
|
|
275
|
+
if not self._is_running or self._speaking:
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
claimed = self.floor_ctrl.try_claim(self._session_id)
|
|
279
|
+
if self.metrics:
|
|
280
|
+
self.metrics.record_floor_retry(executed=True)
|
|
281
|
+
self.metrics.record_floor_claim(success=claimed)
|
|
282
|
+
self.metrics.record_floor_event(
|
|
283
|
+
success=claimed, is_retry=True,
|
|
284
|
+
phase=self.state.phase,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
if claimed:
|
|
288
|
+
if self.metrics:
|
|
289
|
+
self.metrics.record_floor_retry(succeeded=True)
|
|
290
|
+
self._speaking = True
|
|
291
|
+
claim_time = time.time()
|
|
292
|
+
try:
|
|
293
|
+
response = self._generate_response()
|
|
294
|
+
if response:
|
|
295
|
+
self._post_turn(response)
|
|
296
|
+
if self.metrics:
|
|
297
|
+
self.metrics.record_floor_hold_time(time.time() - claim_time)
|
|
298
|
+
self.metrics.record_floor_release(posted_successfully=True)
|
|
299
|
+
self.floor_ctrl.release(self._session_id, posted_successfully=True)
|
|
300
|
+
else:
|
|
301
|
+
self.floor_ctrl.release(self._session_id, posted_successfully=False)
|
|
302
|
+
except Exception as e:
|
|
303
|
+
logger.error(f"[{self.name}] Retry error: {e}")
|
|
304
|
+
self.floor_ctrl.release(self._session_id, posted_successfully=False)
|
|
305
|
+
finally:
|
|
306
|
+
self._speaking = False
|
|
307
|
+
|
|
308
|
+
# ── Generation + Validation ──────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
def _generate_response(self) -> str | None:
|
|
311
|
+
"""Generate a response with self-validation and retry loop."""
|
|
312
|
+
phase_config = self.config.phases.get(self.state.phase)
|
|
313
|
+
style_contract = self.profile.style_contract
|
|
314
|
+
|
|
315
|
+
context = self._build_context()
|
|
316
|
+
retries = 0
|
|
317
|
+
last_failures: list[str] = []
|
|
318
|
+
|
|
319
|
+
for attempt in range(1 + self.config.max_self_retries):
|
|
320
|
+
if attempt > 0:
|
|
321
|
+
context["retry_failures"] = last_failures
|
|
322
|
+
context["retry_attempt"] = attempt
|
|
323
|
+
|
|
324
|
+
gen_start = time.time()
|
|
325
|
+
try:
|
|
326
|
+
draft = self.generate_fn(self.name, context)
|
|
327
|
+
except Exception as e:
|
|
328
|
+
logger.error(f"[{self.name}] Generate failed: {e}")
|
|
329
|
+
if self.metrics:
|
|
330
|
+
self.metrics.record_generation_time(time.time() - gen_start)
|
|
331
|
+
continue
|
|
332
|
+
gen_time = time.time() - gen_start
|
|
333
|
+
|
|
334
|
+
if self.metrics:
|
|
335
|
+
self.metrics.record_generation_time(gen_time)
|
|
336
|
+
|
|
337
|
+
# Run validators
|
|
338
|
+
if self.validators:
|
|
339
|
+
passed, failures = run_validators(
|
|
340
|
+
draft, self.name, self.state,
|
|
341
|
+
validators=self.validators,
|
|
342
|
+
style_contract=style_contract,
|
|
343
|
+
phase_config=phase_config,
|
|
344
|
+
agent_names=self._agent_names,
|
|
345
|
+
)
|
|
346
|
+
if self.metrics:
|
|
347
|
+
failure_names = [f.split("]")[0].strip("[") for f in failures]
|
|
348
|
+
self.metrics.record_validation(passed, failure_names)
|
|
349
|
+
|
|
350
|
+
if not passed:
|
|
351
|
+
logger.debug(
|
|
352
|
+
f"[{self.name}] Validation failed (attempt {attempt + 1}): "
|
|
353
|
+
+ "; ".join(failures)
|
|
354
|
+
)
|
|
355
|
+
last_failures = failures
|
|
356
|
+
retries += 1
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
# Passed validation (or no validators)
|
|
360
|
+
if self.metrics:
|
|
361
|
+
self.metrics.record_retries_for_turn(retries)
|
|
362
|
+
return draft
|
|
363
|
+
|
|
364
|
+
logger.warning(
|
|
365
|
+
f"[{self.name}] All {self.config.max_self_retries + 1} attempts failed"
|
|
366
|
+
)
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
def _build_context(self) -> dict[str, Any]:
|
|
370
|
+
"""Build the context dict passed to the user's generate_fn."""
|
|
371
|
+
phase_config = self.config.phases.get(self.state.phase)
|
|
372
|
+
mem = self.state.ensure_agent_memory(self.name)
|
|
373
|
+
|
|
374
|
+
context: dict[str, Any] = {
|
|
375
|
+
"topic": self.state.topic,
|
|
376
|
+
"phase": self.state.phase,
|
|
377
|
+
"agent_name": self.name,
|
|
378
|
+
"personality": self.profile.personality,
|
|
379
|
+
"rolling_summary": self.state.rolling_summary,
|
|
380
|
+
"recent_turns": self.state.get_last_n_turns_text(self.config.context_window_turns),
|
|
381
|
+
"open_threads": self.state.open_threads,
|
|
382
|
+
"agent_memory": {
|
|
383
|
+
"last_points": mem.last_points[-5:],
|
|
384
|
+
"turns_spoken": mem.turns_spoken,
|
|
385
|
+
},
|
|
386
|
+
"all_agents": self._agent_names,
|
|
387
|
+
"turn_index": self.state.turn_index,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if self.profile.style_contract:
|
|
391
|
+
context["style_contract"] = self.profile.style_contract.description
|
|
392
|
+
|
|
393
|
+
if phase_config:
|
|
394
|
+
context["phase_constraints"] = phase_config.constraints
|
|
395
|
+
context["phase_max_words"] = phase_config.max_words
|
|
396
|
+
context["phase_min_words"] = phase_config.min_words
|
|
397
|
+
|
|
398
|
+
return context
|
|
399
|
+
|
|
400
|
+
def _post_turn(self, response: str) -> None:
|
|
401
|
+
"""Post a validated response to the backend."""
|
|
402
|
+
turn = TurnData(
|
|
403
|
+
agent_name=self.name,
|
|
404
|
+
transcript=response,
|
|
405
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
406
|
+
is_moderator=False,
|
|
407
|
+
turn_type=self.state.phase.lower(),
|
|
408
|
+
)
|
|
409
|
+
self.backend.post_turn(self._session_id, turn)
|
|
410
|
+
|
|
411
|
+
if self.metrics:
|
|
412
|
+
self.metrics.record_turn_posted(self.state.phase)
|
|
413
|
+
|
|
414
|
+
# Update local tracking
|
|
415
|
+
self._my_turns_this_phase[self.state.phase] = (
|
|
416
|
+
self._my_turns_this_phase.get(self.state.phase, 0) + 1
|
|
417
|
+
)
|
|
418
|
+
self._last_posted_turn_index = self.state.turn_index
|
|
419
|
+
|
|
420
|
+
logger.info(
|
|
421
|
+
f"[{self.name}] Posted turn in {self.state.phase} "
|
|
422
|
+
f"(turn #{self.state.turn_index})"
|
|
423
|
+
)
|