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 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
+ )