Blu3Orange commited on
Commit
af2657b
·
1 Parent(s): ff5767d

feat: Introduce smolagents tools for juror agents

Browse files

- Added EvidenceLookupTool for semantic search of case evidence.
- Implemented CaseQueryTool for querying various aspects of the case.
- Created a new CaseIndex for efficient semantic search using LlamaIndex.
- Updated JuryDeliberation to utilize new tools and integrate case indexing.
- Refactored OrchestratorAgent to support reasoning steps and tool calls.
- Enhanced requirements for LlamaIndex and related dependencies.

.env.example CHANGED
@@ -10,3 +10,7 @@ OPENAI_DEFAULT_MODEL=gpt-4o
10
  # ElevenLabs Voice IDs
11
  VALOR_VOICE_ID=your_valor_voice_id
12
  GLOOM_VOICE_ID=your_gloom_voice_id
 
 
 
 
 
10
  # ElevenLabs Voice IDs
11
  VALOR_VOICE_ID=your_valor_voice_id
12
  GLOOM_VOICE_ID=your_gloom_voice_id
13
+
14
+ LAMAINDEX_API_KEY=llx-xxxxxxxxxxxxxxxxxxxxxxxxxxx
15
+
16
+ NEBIUS_API_KEY=
agents/__init__.py CHANGED
@@ -1,9 +1,18 @@
1
- """Agent implementations for 12 Angry Agents."""
 
 
 
2
 
3
- from .base_juror import JurorAgent
4
  from .config_loader import load_juror_configs
 
 
 
 
 
5
 
6
  __all__ = [
7
- "JurorAgent",
 
 
8
  "load_juror_configs",
9
  ]
 
1
+ """Agent implementations for 12 Angry Agents.
2
+
3
+ Uses smolagents CodeAgent with LlamaIndex-powered tools for autonomous reasoning.
4
+ """
5
 
 
6
  from .config_loader import load_juror_configs
7
+ from .smolagent_juror import (
8
+ SmolagentJuror,
9
+ ReasoningStep,
10
+ AgentResult,
11
+ )
12
 
13
  __all__ = [
14
+ "SmolagentJuror",
15
+ "ReasoningStep",
16
+ "AgentResult",
17
  "load_juror_configs",
18
  ]
agents/{base_juror.py → smolagent_juror.py} RENAMED
@@ -1,46 +1,133 @@
1
- """Base juror agent implementation using Gemini."""
 
 
 
 
 
 
 
2
 
3
  import asyncio
4
- import json
5
- import os
6
  import random
7
- from typing import Any
 
 
8
 
9
- from google import genai
10
- from google.genai import types
11
 
12
  from core.models import JurorConfig, JurorMemory, ArgumentMemory
13
  from core.game_state import GameState, DeliberationTurn
14
  from core.conviction import conviction_to_text
15
- from case_db.models import CriminalCase
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
 
 
 
 
 
 
 
17
 
18
- class JurorAgent:
19
- """AI-powered juror agent using Gemini for reasoning."""
20
 
21
- def __init__(self, config: JurorConfig, api_key: str | None = None):
22
- """Initialize juror agent.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  Args:
25
- config: Juror configuration.
26
- api_key: Gemini API key. Defaults to GEMINI_API_KEY env var.
 
27
  """
28
  self.config = config
29
  self.memory = JurorMemory(juror_id=config.juror_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
- # Initialize Gemini client
32
- api_key = api_key or os.getenv("GEMINI_API_KEY")
33
- if not api_key:
34
- raise ValueError("GEMINI_API_KEY not set")
35
- self.client = genai.Client(api_key=api_key)
 
 
 
 
 
 
 
 
 
 
36
 
37
- def _build_system_prompt(self, case: CriminalCase, game_state: GameState) -> str:
38
- """Build the system prompt for the juror."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  guilty, not_guilty = game_state.get_vote_tally()
40
 
41
  # Get recent arguments
42
  recent_args = self.memory.get_recent_arguments(5)
43
- recent_args_text = ""
44
  if recent_args:
45
  recent_args_text = "\n".join(
46
  f"- {arg.speaker_id}: [{arg.argument_type}] {arg.content_summary}"
@@ -55,19 +142,16 @@ class JurorAgent:
55
  # Format reasoning chain
56
  reasoning_text = " ".join(self.memory.reasoning_chain[-3:]) if self.memory.reasoning_chain else "Still forming opinion."
57
 
58
- return f"""# JUROR IDENTITY
59
  You are {self.config.name}, Juror #{self.config.seat_number}.
60
  {self.config.personality_prompt}
61
 
62
  # THE CASE: {case.title}
63
  {case.summary}
64
 
65
- # KEY EVIDENCE
66
- {case.get_evidence_summary()}
67
-
68
  # YOUR CURRENT POSITION
69
  - Conviction Level: {conviction_to_text(self.memory.current_conviction)}
70
- - Your reasoning: {reasoning_text}
71
  - Your doubts: {doubts_text}
72
 
73
  # RECENT DELIBERATION
@@ -75,95 +159,142 @@ You are {self.config.name}, Juror #{self.config.seat_number}.
75
 
76
  # CURRENT VOTE TALLY
77
  Guilty: {guilty} | Not Guilty: {not_guilty}
 
78
 
79
- # INSTRUCTIONS
80
- You must respond IN CHARACTER as {self.config.name}. Stay true to your personality:
81
- - Archetype: {self.config.archetype}
82
- - Be authentic to your background and perspective
83
- - Keep your argument focused and natural (2-4 sentences typically)
84
- - You may address other jurors directly or speak to the room
85
- - Consider but don't simply repeat previous arguments
 
 
 
 
 
 
 
 
86
  """
 
 
 
 
 
 
 
 
87
 
88
  async def generate_argument(
89
  self,
90
- case: CriminalCase,
91
  game_state: GameState,
92
- ) -> DeliberationTurn:
93
- """Generate an argument for this juror's turn.
94
-
95
- Args:
96
- case: The criminal case.
97
- game_state: Current game state.
98
 
99
  Returns:
100
- DeliberationTurn with the generated argument.
101
  """
102
- system_prompt = self._build_system_prompt(case, game_state)
103
-
104
- # Determine argument type based on archetype tendencies
105
- argument_types = self._get_preferred_argument_types()
106
- selected_type = random.choice(argument_types)
107
 
108
- user_prompt = f"""Make an argument in the deliberation. Your argument style should lean toward: {selected_type}
 
 
109
 
110
- Respond with a JSON object in this exact format:
111
- {{
112
- "argument_type": "{selected_type}",
113
- "content": "Your argument here - speak naturally as your character would",
114
- "target_juror": null or "juror_X" if addressing someone specific,
115
- "internal_reasoning": "Brief note about why you're making this argument"
116
- }}
117
 
118
- Remember to stay in character as {self.config.name}!"""
 
119
 
120
- try:
121
- response = await asyncio.to_thread(
122
- self.client.models.generate_content,
123
- model=self.config.model_id,
124
- contents=[
125
- types.Content(
126
- role="user",
127
- parts=[types.Part(text=system_prompt + "\n\n" + user_prompt)]
128
- )
129
- ],
130
- config=types.GenerateContentConfig(
131
- temperature=self.config.temperature,
132
- response_mime_type="application/json",
133
- ),
134
- )
135
 
136
- # Parse response
137
- response_text = response.text.strip()
138
- result = json.loads(response_text)
139
 
140
- # Create turn
141
  turn = DeliberationTurn(
142
  round_number=game_state.round_number,
143
  speaker_id=self.config.juror_id,
144
  speaker_name=self.config.name,
145
- argument_type=result.get("argument_type", selected_type),
146
- content=result.get("content", "I need more time to think about this."),
147
- target_id=result.get("target_juror"),
148
  )
149
 
150
  # Update own memory
151
- self.memory.arguments_made.append(turn.content)
152
- if result.get("internal_reasoning"):
153
- self.memory.reasoning_chain.append(result["internal_reasoning"])
154
 
155
- return turn
156
 
157
  except Exception as e:
158
- # Fallback response on error
159
- print(f"Error generating argument for {self.config.name}: {e}")
160
- return DeliberationTurn(
161
  round_number=game_state.round_number,
162
  speaker_id=self.config.juror_id,
163
  speaker_name=self.config.name,
164
  argument_type="observation",
165
  content=f"*{self.config.name} pauses thoughtfully* I'm still considering the evidence...",
166
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
  def _get_preferred_argument_types(self) -> list[str]:
169
  """Get argument types this archetype prefers."""
@@ -183,34 +314,22 @@ Remember to stay in character as {self.config.name}!"""
183
  return archetype_preferences.get(self.config.archetype, ["observation", "logical"])
184
 
185
  def receive_argument(self, argument: DeliberationTurn, impact: float = 0.0) -> None:
186
- """Process an argument from another juror.
187
-
188
- Args:
189
- argument: The argument that was made.
190
- impact: Pre-calculated conviction change.
191
- """
192
- # Create memory of argument
193
  arg_memory = ArgumentMemory(
194
  speaker_id=argument.speaker_id,
195
- content_summary=argument.content[:200], # Truncate for memory
196
  argument_type=argument.argument_type,
197
  persuasiveness=abs(impact),
198
  round_heard=argument.round_number,
199
  )
200
  self.memory.add_argument(arg_memory)
201
-
202
- # Update conviction
203
  self.memory.update_conviction(impact)
204
 
205
- def set_initial_conviction(self, case: CriminalCase) -> float:
206
- """Set initial conviction based on case and archetype.
 
 
207
 
208
- Args:
209
- case: The case being deliberated.
210
-
211
- Returns:
212
- Initial conviction score (0.0 to 1.0).
213
- """
214
  # Base conviction by case difficulty
215
  if case.difficulty == "clear_guilty":
216
  base = 0.7
@@ -225,9 +344,6 @@ Remember to stay in character as {self.config.name}!"""
225
  base += 0.15
226
  elif lean == "defense":
227
  base -= 0.15
228
- elif lean == "minority":
229
- # Will be set based on majority later
230
- pass
231
  elif lean == "random":
232
  base += random.uniform(-0.2, 0.2)
233
 
@@ -243,3 +359,18 @@ Remember to stay in character as {self.config.name}!"""
243
  def get_vote(self) -> str:
244
  """Get current vote based on conviction."""
245
  return self.memory.get_current_vote()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """smolagents-based juror agent with CodeAgent and tool use.
2
+
3
+ Uses:
4
+ - smolagents CodeAgent for multi-step reasoning
5
+ - LiteLLMModel for multi-provider LLM support
6
+ - LlamaIndex-powered tools for evidence/case queries
7
+ - Visible reasoning steps for UI display
8
+ """
9
 
10
  import asyncio
 
 
11
  import random
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from typing import TYPE_CHECKING
15
 
16
+ from smolagents import CodeAgent, LiteLLMModel
 
17
 
18
  from core.models import JurorConfig, JurorMemory, ArgumentMemory
19
  from core.game_state import GameState, DeliberationTurn
20
  from core.conviction import conviction_to_text
21
+ from agents.tools import EvidenceLookupTool, CaseQueryTool
22
+
23
+ if TYPE_CHECKING:
24
+ from case_db.models import CriminalCase
25
+ from case_db.index import CaseIndex
26
+
27
+
28
+ @dataclass
29
+ class ReasoningStep:
30
+ """A single step in the agent's reasoning process."""
31
+ step_number: int
32
+ action: str # "thinking", "tool_call", "observation", "final_answer"
33
+ content: str
34
+ tool_name: str | None = None
35
+ tool_input: str | None = None
36
+ tool_output: str | None = None
37
+
38
 
39
+ @dataclass
40
+ class AgentResult:
41
+ """Result from running the smolagent."""
42
+ final_answer: str
43
+ reasoning_steps: list[ReasoningStep] = field(default_factory=list)
44
+ tool_calls_made: list[str] = field(default_factory=list)
45
+ total_steps: int = 0
46
 
 
 
47
 
48
+ class SmolagentJuror:
49
+ """Autonomous juror agent with tool access and visible reasoning.
50
+
51
+ Uses smolagents CodeAgent for multi-step reasoning with tools.
52
+ Agents can autonomously query evidence and case details during deliberation.
53
+ Reasoning steps are captured for UI display.
54
+ """
55
+
56
+ DEFAULT_MODEL_ID = "gemini/gemini-2.0-flash"
57
+ MAX_STEPS = 3 # Limit reasoning steps for performance
58
+
59
+ def __init__(
60
+ self,
61
+ config: JurorConfig,
62
+ case_index: "CaseIndex | None" = None,
63
+ case: "CriminalCase | None" = None,
64
+ ):
65
+ """Initialize the smolagent juror.
66
 
67
  Args:
68
+ config: Juror configuration
69
+ case_index: LlamaIndex CaseIndex for semantic search
70
+ case: CriminalCase for tools if no index provided
71
  """
72
  self.config = config
73
  self.memory = JurorMemory(juror_id=config.juror_id)
74
+ self.case_index = case_index
75
+ self.case = case
76
+
77
+ # Build model ID from config
78
+ model_id = f"{config.model_provider}/{config.model_id}"
79
+ if config.model_provider == "gemini":
80
+ model_id = f"gemini/{config.model_id}"
81
+
82
+ # Initialize LiteLLM model
83
+ self.model = LiteLLMModel(
84
+ model_id=model_id,
85
+ temperature=config.temperature,
86
+ )
87
+
88
+ # Initialize tools
89
+ self.tools = self._create_tools()
90
 
91
+ # Create CodeAgent
92
+ self.agent = CodeAgent(
93
+ tools=self.tools,
94
+ model=self.model,
95
+ max_steps=self.MAX_STEPS,
96
+ verbosity_level=1,
97
+ )
98
+
99
+ # Store last reasoning for UI
100
+ self.last_reasoning_steps: list[ReasoningStep] = []
101
+ self.last_tool_calls: list[str] = []
102
+
103
+ def _create_tools(self) -> list:
104
+ """Create tools for the agent."""
105
+ tools = []
106
 
107
+ if self.case_index is not None:
108
+ # Full LlamaIndex-powered tools
109
+ tools.append(EvidenceLookupTool(self.case_index))
110
+ tools.append(CaseQueryTool(self.case_index))
111
+ elif self.case is not None:
112
+ # Mock tools when no index
113
+ from agents.tools.evidence_tool import EvidenceLookupToolMock
114
+ from agents.tools.case_query_tool import CaseQueryToolMock
115
+ tools.append(EvidenceLookupToolMock(self.case))
116
+ tools.append(CaseQueryToolMock(self.case))
117
+
118
+ return tools
119
+
120
+ def _build_prompt(
121
+ self,
122
+ case: "CriminalCase",
123
+ game_state: GameState,
124
+ task: str = "speak"
125
+ ) -> str:
126
+ """Build the prompt for the agent."""
127
  guilty, not_guilty = game_state.get_vote_tally()
128
 
129
  # Get recent arguments
130
  recent_args = self.memory.get_recent_arguments(5)
 
131
  if recent_args:
132
  recent_args_text = "\n".join(
133
  f"- {arg.speaker_id}: [{arg.argument_type}] {arg.content_summary}"
 
142
  # Format reasoning chain
143
  reasoning_text = " ".join(self.memory.reasoning_chain[-3:]) if self.memory.reasoning_chain else "Still forming opinion."
144
 
145
+ base_prompt = f"""# JUROR IDENTITY
146
  You are {self.config.name}, Juror #{self.config.seat_number}.
147
  {self.config.personality_prompt}
148
 
149
  # THE CASE: {case.title}
150
  {case.summary}
151
 
 
 
 
152
  # YOUR CURRENT POSITION
153
  - Conviction Level: {conviction_to_text(self.memory.current_conviction)}
154
+ - Your reasoning so far: {reasoning_text}
155
  - Your doubts: {doubts_text}
156
 
157
  # RECENT DELIBERATION
 
159
 
160
  # CURRENT VOTE TALLY
161
  Guilty: {guilty} | Not Guilty: {not_guilty}
162
+ """
163
 
164
+ if task == "speak":
165
+ task_prompt = f"""
166
+ # YOUR TASK
167
+ Make an argument in the deliberation. You have access to tools to look up evidence
168
+ and query the case if needed. Use them if you need to verify facts or find specific
169
+ evidence to support your argument.
170
+
171
+ Stay in character as {self.config.name} ({self.config.archetype}).
172
+ - Be authentic to your personality and background
173
+ - Keep your argument focused (2-4 sentences)
174
+ - You may address other jurors or speak to the room
175
+ - Base your argument on evidence when possible
176
+
177
+ Provide your argument as a single statement that your character would say out loud
178
+ in the jury room. Do not include internal thoughts - just what you would say.
179
  """
180
+ else: # react
181
+ task_prompt = """
182
+ # YOUR TASK
183
+ React to the latest argument. You may use tools to verify claims made.
184
+ Return a brief internal reaction (not spoken aloud).
185
+ """
186
+
187
+ return base_prompt + task_prompt
188
 
189
  async def generate_argument(
190
  self,
191
+ case: "CriminalCase",
192
  game_state: GameState,
193
+ ) -> tuple[DeliberationTurn, list[ReasoningStep]]:
194
+ """Generate an argument using the CodeAgent.
 
 
 
 
195
 
196
  Returns:
197
+ Tuple of (DeliberationTurn, list of ReasoningSteps for UI)
198
  """
199
+ prompt = self._build_prompt(case, game_state, task="speak")
 
 
 
 
200
 
201
+ try:
202
+ # Run the agent
203
+ result = await asyncio.to_thread(self.agent.run, prompt)
204
 
205
+ # Extract reasoning steps from agent logs
206
+ self.last_reasoning_steps = self._extract_reasoning_steps()
207
+ self.last_tool_calls = self._extract_tool_calls()
 
 
 
 
208
 
209
+ # Parse the result
210
+ content = str(result).strip()
211
 
212
+ # Clean up the content
213
+ content = self._clean_content(content)
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
+ # Determine argument type based on archetype
216
+ argument_types = self._get_preferred_argument_types()
217
+ selected_type = random.choice(argument_types)
218
 
219
+ # Create the turn
220
  turn = DeliberationTurn(
221
  round_number=game_state.round_number,
222
  speaker_id=self.config.juror_id,
223
  speaker_name=self.config.name,
224
+ argument_type=selected_type,
225
+ content=content,
 
226
  )
227
 
228
  # Update own memory
229
+ self.memory.arguments_made.append(content)
 
 
230
 
231
+ return turn, self.last_reasoning_steps
232
 
233
  except Exception as e:
234
+ print(f"Error in SmolagentJuror.generate_argument for {self.config.name}: {e}")
235
+ # Fallback response
236
+ turn = DeliberationTurn(
237
  round_number=game_state.round_number,
238
  speaker_id=self.config.juror_id,
239
  speaker_name=self.config.name,
240
  argument_type="observation",
241
  content=f"*{self.config.name} pauses thoughtfully* I'm still considering the evidence...",
242
  )
243
+ return turn, []
244
+
245
+ def _extract_reasoning_steps(self) -> list[ReasoningStep]:
246
+ """Extract reasoning steps from agent logs."""
247
+ steps = []
248
+
249
+ try:
250
+ if hasattr(self.agent, 'logs') and self.agent.logs:
251
+ for i, log in enumerate(self.agent.logs):
252
+ step = ReasoningStep(
253
+ step_number=i + 1,
254
+ action=log.get('type', 'thinking'),
255
+ content=log.get('content', str(log)),
256
+ tool_name=log.get('tool_name'),
257
+ tool_input=log.get('tool_input'),
258
+ tool_output=log.get('tool_output'),
259
+ )
260
+ steps.append(step)
261
+ except Exception:
262
+ pass
263
+
264
+ # If no logs available, create a simple step
265
+ if not steps:
266
+ steps.append(ReasoningStep(
267
+ step_number=1,
268
+ action="thinking",
269
+ content="Analyzed case and formed argument based on personality and evidence."
270
+ ))
271
+
272
+ return steps
273
+
274
+ def _extract_tool_calls(self) -> list[str]:
275
+ """Extract tool calls from reasoning steps."""
276
+ calls = []
277
+ for step in self.last_reasoning_steps:
278
+ if step.tool_name:
279
+ calls.append(f"{step.tool_name}({step.tool_input})")
280
+ return calls
281
+
282
+ def _clean_content(self, content: str) -> str:
283
+ """Clean up agent output for display."""
284
+ # Remove code blocks
285
+ content = re.sub(r'```[\s\S]*?```', '', content)
286
+ # Remove markdown formatting
287
+ content = re.sub(r'\*\*([^*]+)\*\*', r'\1', content)
288
+ # Remove quotes that might wrap the whole response
289
+ content = content.strip('"\'')
290
+ # Clean up whitespace
291
+ content = ' '.join(content.split())
292
+
293
+ # Ensure it's not empty
294
+ if not content:
295
+ content = "I need more time to consider the evidence."
296
+
297
+ return content
298
 
299
  def _get_preferred_argument_types(self) -> list[str]:
300
  """Get argument types this archetype prefers."""
 
314
  return archetype_preferences.get(self.config.archetype, ["observation", "logical"])
315
 
316
  def receive_argument(self, argument: DeliberationTurn, impact: float = 0.0) -> None:
317
+ """Process an argument from another juror."""
 
 
 
 
 
 
318
  arg_memory = ArgumentMemory(
319
  speaker_id=argument.speaker_id,
320
+ content_summary=argument.content[:200],
321
  argument_type=argument.argument_type,
322
  persuasiveness=abs(impact),
323
  round_heard=argument.round_number,
324
  )
325
  self.memory.add_argument(arg_memory)
 
 
326
  self.memory.update_conviction(impact)
327
 
328
+ def set_initial_conviction(self, case: "CriminalCase") -> float:
329
+ """Set initial conviction based on case and archetype."""
330
+ # Store case reference for tools
331
+ self.case = case
332
 
 
 
 
 
 
 
333
  # Base conviction by case difficulty
334
  if case.difficulty == "clear_guilty":
335
  base = 0.7
 
344
  base += 0.15
345
  elif lean == "defense":
346
  base -= 0.15
 
 
 
347
  elif lean == "random":
348
  base += random.uniform(-0.2, 0.2)
349
 
 
359
  def get_vote(self) -> str:
360
  """Get current vote based on conviction."""
361
  return self.memory.get_current_vote()
362
+
363
+ def get_reasoning_for_ui(self) -> list[str]:
364
+ """Get formatted reasoning steps for UI display."""
365
+ formatted = []
366
+ for step in self.last_reasoning_steps:
367
+ if step.tool_name:
368
+ formatted.append(
369
+ f"Step {step.step_number}: Tool call - {step.tool_name}(\"{step.tool_input}\")"
370
+ )
371
+ if step.tool_output:
372
+ formatted.append(f" Result: {step.tool_output[:100]}...")
373
+ else:
374
+ formatted.append(f"Step {step.step_number}: {step.action} - {step.content[:100]}")
375
+
376
+ return formatted
agents/tools/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """smolagents tools for juror agents.
2
+
3
+ Tools allow agents to autonomously query case evidence and details
4
+ during deliberation, enabling true agentic behavior.
5
+ """
6
+
7
+ from .evidence_tool import EvidenceLookupTool
8
+ from .case_query_tool import CaseQueryTool
9
+
10
+ __all__ = [
11
+ "EvidenceLookupTool",
12
+ "CaseQueryTool",
13
+ ]
agents/tools/case_query_tool.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Case query tool for smolagents.
2
+
3
+ Allows juror agents to query any aspect of the case.
4
+ """
5
+
6
+ from typing import TYPE_CHECKING
7
+
8
+ from smolagents import Tool
9
+
10
+ if TYPE_CHECKING:
11
+ from case_db.index import CaseIndex
12
+ from case_db.models import CriminalCase
13
+
14
+
15
+ class CaseQueryTool(Tool):
16
+ """Tool for agents to query any aspect of the case.
17
+
18
+ General-purpose tool that allows agents to ask questions about
19
+ the case - timeline, people, charges, context, connections between facts.
20
+
21
+ Examples of queries an agent might make:
22
+ - "What are the charges against the defendant?"
23
+ - "Who discovered the body?"
24
+ - "What is the defendant's background?"
25
+ - "What time did the incident occur?"
26
+ - "What is the connection between the witness and defendant?"
27
+ """
28
+
29
+ name = "case_query"
30
+ description = """Query any aspect of the case. Use this tool to:
31
+ - Understand the timeline of events
32
+ - Learn about people involved (defendant, witnesses, victims)
33
+ - Clarify the charges and legal context
34
+ - Find connections between facts
35
+ - Get background information
36
+ - Understand who said what
37
+
38
+ This is a general-purpose case query tool. Ask any question about
39
+ the case in natural language, such as "What happened on the night
40
+ of the incident?" or "What is the relationship between the defendant
41
+ and the victim?"."""
42
+
43
+ inputs = {
44
+ "query": {
45
+ "type": "string",
46
+ "description": "Question about the case (natural language)"
47
+ }
48
+ }
49
+ output_type = "string"
50
+
51
+ def __init__(self, case_index: "CaseIndex"):
52
+ """Initialize with a case index.
53
+
54
+ Args:
55
+ case_index: LlamaIndex CaseIndex for semantic search
56
+ """
57
+ super().__init__()
58
+ self.case_index = case_index
59
+
60
+ def forward(self, query: str) -> str:
61
+ """Execute the case query.
62
+
63
+ Args:
64
+ query: Natural language question about the case
65
+
66
+ Returns:
67
+ Answer synthesized from case documents
68
+ """
69
+ if self.case_index is None:
70
+ return "Error: No case index available. Case query disabled."
71
+
72
+ try:
73
+ result = self.case_index.query(query)
74
+ return result if result else "No information found for this query."
75
+ except Exception as e:
76
+ return f"Error querying case: {str(e)}"
77
+
78
+
79
+ class CaseQueryToolMock(Tool):
80
+ """Mock case query tool for testing without LlamaIndex.
81
+
82
+ Returns case information based on simple pattern matching.
83
+ """
84
+
85
+ name = "case_query"
86
+ description = "Query case information (mock version for testing)"
87
+ inputs = {
88
+ "query": {
89
+ "type": "string",
90
+ "description": "Question about the case"
91
+ }
92
+ }
93
+ output_type = "string"
94
+
95
+ def __init__(self, case: "CriminalCase"):
96
+ """Initialize with a case object.
97
+
98
+ Args:
99
+ case: CriminalCase object
100
+ """
101
+ super().__init__()
102
+ self.case = case
103
+
104
+ def forward(self, query: str) -> str:
105
+ """Simple pattern-based case query.
106
+
107
+ Args:
108
+ query: Question about the case
109
+
110
+ Returns:
111
+ Relevant case information
112
+ """
113
+ if not self.case:
114
+ return "No case information available."
115
+
116
+ query_lower = query.lower()
117
+
118
+ if "charge" in query_lower:
119
+ return f"Charges: {', '.join(self.case.charges)}"
120
+
121
+ if "defendant" in query_lower or "accused" in query_lower:
122
+ if self.case.defendant:
123
+ return f"Defendant: {self.case.defendant.name}. {self.case.defendant.background}"
124
+ return "Defendant information not available."
125
+
126
+ if "witness" in query_lower:
127
+ if self.case.witnesses:
128
+ summaries = [f"- {w.name} ({w.role}): {w.testimony_summary}"
129
+ for w in self.case.witnesses]
130
+ return "Witnesses:\n" + "\n".join(summaries)
131
+ return "No witness information available."
132
+
133
+ if "evidence" in query_lower:
134
+ if self.case.evidence:
135
+ summaries = [f"- [{e.type}] {e.description}"
136
+ for e in self.case.evidence]
137
+ return "Evidence:\n" + "\n".join(summaries)
138
+ return "No evidence information available."
139
+
140
+ if "summary" in query_lower or "what happened" in query_lower:
141
+ return self.case.summary
142
+
143
+ # Default: return case summary
144
+ return f"Case: {self.case.title}\n\n{self.case.summary}"
agents/tools/evidence_tool.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evidence lookup tool for smolagents.
2
+
3
+ Allows juror agents to semantically search case evidence.
4
+ """
5
+
6
+ from typing import TYPE_CHECKING
7
+
8
+ from smolagents import Tool
9
+
10
+ if TYPE_CHECKING:
11
+ from case_db.index import CaseIndex
12
+ from case_db.models import CriminalCase
13
+
14
+
15
+ class EvidenceLookupTool(Tool):
16
+ """Tool for agents to search case evidence semantically.
17
+
18
+ Enables juror agents to autonomously query evidence during deliberation.
19
+ Uses LlamaIndex for semantic search.
20
+
21
+ Examples of queries an agent might make:
22
+ - "fingerprints found at the scene"
23
+ - "timeline of events"
24
+ - "weapon used in the crime"
25
+ - "DNA evidence"
26
+ - "alibi documentation"
27
+ """
28
+
29
+ name = "evidence_lookup"
30
+ description = """Search case evidence semantically. Use this tool to find:
31
+ - Physical evidence (weapons, fingerprints, DNA, items found at scene)
32
+ - Documentary evidence (receipts, records, contracts, photos)
33
+ - Forensic evidence (lab results, medical reports, autopsy)
34
+ - Timeline evidence (timestamps, alibis, movement records)
35
+
36
+ Ask about specific items or search for evidence types. The search is semantic,
37
+ so you can ask natural questions like "what physical evidence links the defendant
38
+ to the crime scene?" or "evidence about the timeline of events"."""
39
+
40
+ inputs = {
41
+ "query": {
42
+ "type": "string",
43
+ "description": "What evidence to search for (natural language query)"
44
+ }
45
+ }
46
+ output_type = "string"
47
+
48
+ def __init__(self, case_index: "CaseIndex"):
49
+ """Initialize with a case index.
50
+
51
+ Args:
52
+ case_index: LlamaIndex CaseIndex for semantic search
53
+ """
54
+ super().__init__()
55
+ self.case_index = case_index
56
+
57
+ def forward(self, query: str) -> str:
58
+ """Execute the evidence search.
59
+
60
+ Args:
61
+ query: Natural language query about evidence
62
+
63
+ Returns:
64
+ Relevant evidence information from the case
65
+ """
66
+ if self.case_index is None:
67
+ return "Error: No case index available. Evidence lookup disabled."
68
+
69
+ try:
70
+ result = self.case_index.query_evidence(query)
71
+ return result if result else "No relevant evidence found for this query."
72
+ except Exception as e:
73
+ return f"Error searching evidence: {str(e)}"
74
+
75
+
76
+ class EvidenceLookupToolMock(Tool):
77
+ """Mock evidence lookup tool for testing without LlamaIndex.
78
+
79
+ Uses simple keyword matching instead of semantic search.
80
+ """
81
+
82
+ name = "evidence_lookup"
83
+ description = "Search case evidence (mock version for testing)"
84
+ inputs = {
85
+ "query": {
86
+ "type": "string",
87
+ "description": "What evidence to search for"
88
+ }
89
+ }
90
+ output_type = "string"
91
+
92
+ def __init__(self, case: "CriminalCase"):
93
+ """Initialize with a case object.
94
+
95
+ Args:
96
+ case: CriminalCase object with evidence list
97
+ """
98
+ super().__init__()
99
+ self.case = case
100
+
101
+ def forward(self, query: str) -> str:
102
+ """Simple keyword-based evidence search.
103
+
104
+ Args:
105
+ query: Keywords to search for
106
+
107
+ Returns:
108
+ Matching evidence descriptions
109
+ """
110
+ if not self.case or not self.case.evidence:
111
+ return "No evidence available."
112
+
113
+ query_lower = query.lower()
114
+ matches = []
115
+
116
+ for evidence in self.case.evidence:
117
+ if (query_lower in evidence.description.lower() or
118
+ query_lower in evidence.type.lower()):
119
+ matches.append(f"[{evidence.type}] {evidence.description}")
120
+
121
+ if matches:
122
+ return "\n".join(matches)
123
+ return f"No evidence found matching '{query}'"
app.py CHANGED
@@ -1,14 +1,11 @@
1
  """12 Angry Agents - Main Gradio Application.
2
 
3
  AI-powered jury deliberation simulation where 11 AI agents + 1 human player
4
- debate criminal cases. A Judge narrator orchestrates the experience.
5
  """
6
 
7
  import asyncio
8
- import os
9
- import random
10
  from pathlib import Path
11
- from typing import Any
12
 
13
  import gradio as gr
14
  from dotenv import load_dotenv
@@ -20,12 +17,10 @@ load_dotenv()
20
  import sys
21
  sys.path.insert(0, str(Path(__file__).parent))
22
 
23
- from core.game_state import GameState, GamePhase, DeliberationTurn
24
- from core.models import JurorConfig
25
- from core.conviction import calculate_conviction_change
26
- from core.orchestrator import OrchestratorAgent, TurnManager
27
- from case_db import CaseLoader, CriminalCase
28
- from agents import load_juror_configs, JurorAgent
29
  from ui.components import render_jury_box, render_vote_tally
30
 
31
 
@@ -39,7 +34,7 @@ class JuryDeliberation:
39
  def __init__(self):
40
  self.case_loader = CaseLoader()
41
  self.juror_configs = load_juror_configs()
42
- self.juror_agents: dict[str, JurorAgent] = {}
43
  self.orchestrator: OrchestratorAgent | None = None
44
  self.current_case: CriminalCase | None = None
45
 
@@ -68,12 +63,19 @@ class JuryDeliberation:
68
  []
69
  )
70
 
71
- # Initialize juror agents (skip player seat 7)
 
 
 
72
  self.juror_agents = {}
73
  for config in self.juror_configs:
74
  if config.archetype != "player":
75
  try:
76
- agent = JurorAgent(config)
 
 
 
 
77
  agent.set_initial_conviction(self.current_case)
78
  self.juror_agents[config.juror_id] = agent
79
  except Exception as e:
@@ -111,7 +113,7 @@ class JuryDeliberation:
111
  jury_html = render_jury_box(self.juror_configs, self.game_state)
112
 
113
  chat_history = [
114
- {"role": "assistant", "content": f"**⚖️ Judge:** Members of the jury, you are here today to determine the fate of the defendant in the case of {self.current_case.title}. Please review the evidence carefully and deliberate with your fellow jurors. The burden of proof lies with the prosecution."}
115
  ]
116
 
117
  return case_summary, evidence_html, jury_html, chat_history
@@ -151,6 +153,22 @@ class JuryDeliberation:
151
  {"role": "assistant", "content": f"**{config.emoji} {config.name}:** {turn.content}"}
152
  )
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  # Vote changes
155
  for juror_id, old_vote, new_vote in result.vote_changes:
156
  other_config = next(c for c in self.juror_configs if c.juror_id == juror_id)
 
1
  """12 Angry Agents - Main Gradio Application.
2
 
3
  AI-powered jury deliberation simulation where 11 AI agents + 1 human player
4
+ debate criminal cases. Uses smolagents CodeAgent with LlamaIndex-powered tools.
5
  """
6
 
7
  import asyncio
 
 
8
  from pathlib import Path
 
9
 
10
  import gradio as gr
11
  from dotenv import load_dotenv
 
17
  import sys
18
  sys.path.insert(0, str(Path(__file__).parent))
19
 
20
+ from core.game_state import GameState, GamePhase
21
+ from core.orchestrator import OrchestratorAgent
22
+ from case_db import CaseLoader, CriminalCase, CaseIndexFactory
23
+ from agents import load_juror_configs, SmolagentJuror
 
 
24
  from ui.components import render_jury_box, render_vote_tally
25
 
26
 
 
34
  def __init__(self):
35
  self.case_loader = CaseLoader()
36
  self.juror_configs = load_juror_configs()
37
+ self.juror_agents: dict[str, SmolagentJuror] = {}
38
  self.orchestrator: OrchestratorAgent | None = None
39
  self.current_case: CriminalCase | None = None
40
 
 
63
  []
64
  )
65
 
66
+ # Create LlamaIndex for this case
67
+ case_index = CaseIndexFactory.get_index(self.current_case)
68
+
69
+ # Initialize juror agents with case index (skip player seat 7)
70
  self.juror_agents = {}
71
  for config in self.juror_configs:
72
  if config.archetype != "player":
73
  try:
74
+ agent = SmolagentJuror(
75
+ config,
76
+ case_index=case_index,
77
+ case=self.current_case
78
+ )
79
  agent.set_initial_conviction(self.current_case)
80
  self.juror_agents[config.juror_id] = agent
81
  except Exception as e:
 
113
  jury_html = render_jury_box(self.juror_configs, self.game_state)
114
 
115
  chat_history = [
116
+ {"role": "assistant", "content": f"**Judge:** Members of the jury, you are here today to determine the fate of the defendant in the case of {self.current_case.title}. Please review the evidence carefully and deliberate with your fellow jurors. The burden of proof lies with the prosecution."}
117
  ]
118
 
119
  return case_summary, evidence_html, jury_html, chat_history
 
153
  {"role": "assistant", "content": f"**{config.emoji} {config.name}:** {turn.content}"}
154
  )
155
 
156
+ # Show reasoning steps if available (smolagents feature)
157
+ if result.reasoning_steps:
158
+ reasoning_text = "\n".join([f" {step}" for step in result.reasoning_steps])
159
+ new_messages.append({
160
+ "role": "assistant",
161
+ "content": f"*{config.emoji} {config.name}'s reasoning:*\n```\n{reasoning_text}\n```"
162
+ })
163
+
164
+ # Show tool calls if any
165
+ if result.tool_calls:
166
+ tools_text = ", ".join(result.tool_calls)
167
+ new_messages.append({
168
+ "role": "assistant",
169
+ "content": f"*Tools used: {tools_text}*"
170
+ })
171
+
172
  # Vote changes
173
  for juror_id, old_vote, new_vote in result.vote_changes:
174
  other_config = next(c for c in self.juror_configs if c.juror_id == juror_id)
case_db/__init__.py CHANGED
@@ -7,6 +7,7 @@ from .models import (
7
  Defendant,
8
  )
9
  from .loader import CaseLoader
 
10
 
11
  __all__ = [
12
  "CriminalCase",
@@ -14,4 +15,6 @@ __all__ = [
14
  "Witness",
15
  "Defendant",
16
  "CaseLoader",
 
 
17
  ]
 
7
  Defendant,
8
  )
9
  from .loader import CaseLoader
10
+ from .index import CaseIndex, CaseIndexFactory
11
 
12
  __all__ = [
13
  "CriminalCase",
 
15
  "Witness",
16
  "Defendant",
17
  "CaseLoader",
18
+ "CaseIndex",
19
+ "CaseIndexFactory",
20
  ]
case_db/index.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LlamaIndex case index for semantic search over case documents.
2
+
3
+ Provides RAG capabilities for smolagents tools to query evidence and case details.
4
+ Uses Nebius embeddings via the decoupled embedding service.
5
+ """
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from llama_index.core import VectorStoreIndex, Document
10
+
11
+ from services.embeddings import get_embedding_service
12
+
13
+ if TYPE_CHECKING:
14
+ from case_db.models import CriminalCase
15
+
16
+
17
+ class CaseIndex:
18
+ """Semantic search over case documents for agent tool use.
19
+
20
+ Creates a vector index from case summary, evidence, and witness testimonies.
21
+ Agents can query this index to find relevant information during deliberation.
22
+
23
+ Uses Nebius embeddings (4096-dim) via the centralized embedding service.
24
+ """
25
+
26
+ def __init__(self, case: "CriminalCase"):
27
+ """Initialize the case index.
28
+
29
+ Args:
30
+ case: The criminal case to index
31
+ """
32
+ self.case = case
33
+
34
+ # Initialize embedding service (configures LlamaIndex Settings)
35
+ self._embedding_service = get_embedding_service()
36
+
37
+ # Build the index
38
+ self.index = self._build_index()
39
+ self.query_engine = self.index.as_query_engine()
40
+
41
+ def _build_index(self) -> VectorStoreIndex:
42
+ """Build vector index from case documents."""
43
+ documents = []
44
+
45
+ # Index case summary
46
+ documents.append(Document(
47
+ text=self.case.summary,
48
+ metadata={
49
+ "type": "summary",
50
+ "case_id": self.case.case_id
51
+ }
52
+ ))
53
+
54
+ # Index charges
55
+ if self.case.charges:
56
+ charges_text = f"Charges: {', '.join(self.case.charges)}"
57
+ documents.append(Document(
58
+ text=charges_text,
59
+ metadata={
60
+ "type": "charges",
61
+ "case_id": self.case.case_id
62
+ }
63
+ ))
64
+
65
+ # Index each piece of evidence
66
+ for evidence in self.case.evidence:
67
+ doc_text = f"Evidence ({evidence.type}): {evidence.description}"
68
+ if evidence.contestable and evidence.contest_reason:
69
+ doc_text += f" [Contestable: {evidence.contest_reason}]"
70
+
71
+ documents.append(Document(
72
+ text=doc_text,
73
+ metadata={
74
+ "type": "evidence",
75
+ "evidence_type": evidence.type,
76
+ "evidence_id": evidence.evidence_id,
77
+ "case_id": self.case.case_id,
78
+ "strength_prosecution": evidence.strength_prosecution,
79
+ "strength_defense": evidence.strength_defense,
80
+ }
81
+ ))
82
+
83
+ # Index witness testimonies
84
+ for witness in self.case.witnesses:
85
+ doc_text = (
86
+ f"Witness {witness.name} ({witness.role}, {witness.side}): "
87
+ f"{witness.testimony_summary}"
88
+ )
89
+ if witness.credibility_issues:
90
+ doc_text += f" [Credibility issues: {', '.join(witness.credibility_issues)}]"
91
+
92
+ documents.append(Document(
93
+ text=doc_text,
94
+ metadata={
95
+ "type": "witness",
96
+ "witness_id": witness.witness_id,
97
+ "witness_name": witness.name,
98
+ "witness_role": witness.role,
99
+ "witness_side": witness.side,
100
+ "case_id": self.case.case_id,
101
+ }
102
+ ))
103
+
104
+ # Index defendant background if available
105
+ if self.case.defendant:
106
+ defendant_text = f"Defendant: {self.case.defendant.name}"
107
+ if self.case.defendant.age:
108
+ defendant_text += f", age {self.case.defendant.age}"
109
+ if self.case.defendant.occupation:
110
+ defendant_text += f", {self.case.defendant.occupation}"
111
+ if self.case.defendant.background:
112
+ defendant_text += f". Background: {self.case.defendant.background}"
113
+ if self.case.defendant.prior_record:
114
+ defendant_text += f". Prior record: {', '.join(self.case.defendant.prior_record)}"
115
+
116
+ documents.append(Document(
117
+ text=defendant_text,
118
+ metadata={
119
+ "type": "defendant",
120
+ "case_id": self.case.case_id,
121
+ }
122
+ ))
123
+
124
+ # Index prosecution arguments
125
+ for i, arg in enumerate(self.case.prosecution_arguments):
126
+ documents.append(Document(
127
+ text=f"Prosecution argument: {arg}",
128
+ metadata={
129
+ "type": "prosecution_argument",
130
+ "argument_index": i,
131
+ "case_id": self.case.case_id,
132
+ }
133
+ ))
134
+
135
+ # Index defense arguments
136
+ for i, arg in enumerate(self.case.defense_arguments):
137
+ documents.append(Document(
138
+ text=f"Defense argument: {arg}",
139
+ metadata={
140
+ "type": "defense_argument",
141
+ "argument_index": i,
142
+ "case_id": self.case.case_id,
143
+ }
144
+ ))
145
+
146
+ return VectorStoreIndex.from_documents(documents)
147
+
148
+ def query(self, question: str) -> str:
149
+ """Query the case index for relevant information.
150
+
151
+ Args:
152
+ question: Natural language question about the case
153
+
154
+ Returns:
155
+ Synthesized answer from relevant case documents
156
+ """
157
+ response = self.query_engine.query(question)
158
+ return str(response)
159
+
160
+ def query_evidence(self, query: str) -> str:
161
+ """Query specifically for evidence-related information.
162
+
163
+ Args:
164
+ query: What evidence to search for
165
+
166
+ Returns:
167
+ Relevant evidence information
168
+ """
169
+ full_query = f"Evidence related to: {query}"
170
+ return self.query(full_query)
171
+
172
+ def query_witnesses(self, query: str) -> str:
173
+ """Query specifically for witness testimony.
174
+
175
+ Args:
176
+ query: What witness information to search for
177
+
178
+ Returns:
179
+ Relevant witness testimony
180
+ """
181
+ full_query = f"Witness testimony about: {query}"
182
+ return self.query(full_query)
183
+
184
+ def get_all_evidence_summaries(self) -> list[str]:
185
+ """Get list of all evidence summaries for quick reference."""
186
+ return [
187
+ f"[{e.evidence_id}] {e.type}: {e.description}"
188
+ for e in self.case.evidence
189
+ ]
190
+
191
+ def get_all_witness_summaries(self) -> list[str]:
192
+ """Get list of all witness summaries for quick reference."""
193
+ return [
194
+ f"[{w.witness_id}] {w.name} ({w.role}): {w.testimony_summary[:100]}..."
195
+ for w in self.case.witnesses
196
+ ]
197
+
198
+
199
+ class CaseIndexFactory:
200
+ """Factory for creating and caching case indices."""
201
+
202
+ _cache: dict[str, CaseIndex] = {}
203
+
204
+ @classmethod
205
+ def get_index(cls, case: "CriminalCase") -> CaseIndex:
206
+ """Get or create a case index.
207
+
208
+ Caches indices by case_id to avoid rebuilding.
209
+
210
+ Args:
211
+ case: The criminal case to index
212
+
213
+ Returns:
214
+ CaseIndex for the case
215
+ """
216
+ if case.case_id not in cls._cache:
217
+ cls._cache[case.case_id] = CaseIndex(case)
218
+ return cls._cache[case.case_id]
219
+
220
+ @classmethod
221
+ def clear_cache(cls) -> None:
222
+ """Clear the index cache."""
223
+ cls._cache.clear()
core/orchestrator.py CHANGED
@@ -12,7 +12,7 @@ from core.models import JurorConfig, JurorMemory
12
  from core.conviction import calculate_conviction_change
13
 
14
  if TYPE_CHECKING:
15
- from agents.base_juror import JurorAgent
16
  from case_db.models import CriminalCase
17
 
18
 
@@ -21,15 +21,17 @@ class SpeakerWeight:
21
  """Weight information for speaker selection."""
22
  juror_id: str
23
  weight: float
24
- reason: str # Why this weight was assigned
25
 
26
 
27
  @dataclass
28
  class TurnResult:
29
  """Result of a single turn in deliberation."""
30
  turn: DeliberationTurn
31
- conviction_changes: dict[str, float] # juror_id -> delta
32
- vote_changes: list[tuple[str, str, str]] # (juror_id, old_vote, new_vote)
 
 
33
 
34
 
35
  class TurnManager:
@@ -42,17 +44,14 @@ class TurnManager:
42
  4. Some randomness to keep things unpredictable
43
  """
44
 
45
- # Weights for different factors
46
- ON_FENCE_BONUS = 2.0 # Bonus for jurors near 0.5 conviction
47
- RECENCY_PENALTY = 0.3 # Multiplier for recent speakers
48
- INFLUENCE_WEIGHT = 1.5 # How much influence affects selection
49
- RANDOM_FACTOR = 0.3 # Random noise to add variety
50
-
51
- # Track how many rounds before a juror can be "prioritized" again
52
  RECENCY_WINDOW = 2
53
 
54
  def __init__(self):
55
- self.speaker_history: list[list[str]] = [] # Per-round speaker lists
56
 
57
  def select_speakers(
58
  self,
@@ -62,23 +61,10 @@ class TurnManager:
62
  num_speakers: int = None,
63
  exclude_player: bool = True
64
  ) -> list[str]:
65
- """Select speakers for the next round using weighted selection.
66
-
67
- Args:
68
- game_state: Current game state
69
- juror_configs: All juror configurations
70
- juror_memories: Memory state for each juror
71
- num_speakers: Number of speakers (1-4, random if None)
72
- exclude_player: Whether to exclude player from selection
73
-
74
- Returns:
75
- List of juror_ids selected to speak
76
- """
77
- # Determine number of speakers
78
  if num_speakers is None:
79
  num_speakers = random.randint(1, 3)
80
 
81
- # Get eligible jurors
82
  eligible = [
83
  c for c in juror_configs
84
  if not (exclude_player and c.is_player())
@@ -87,20 +73,14 @@ class TurnManager:
87
  if not eligible:
88
  return []
89
 
90
- # Calculate weights for each juror
91
  weights = self._calculate_weights(
92
  eligible,
93
  juror_memories,
94
  game_state.round_number
95
  )
96
 
97
- # Select speakers using weighted random selection
98
  selected = self._weighted_select(weights, min(num_speakers, len(eligible)))
99
-
100
- # Record this round's speakers
101
  self.speaker_history.append(selected)
102
-
103
- # Update game state speaking queue
104
  game_state.speaking_queue = selected
105
 
106
  return selected
@@ -118,55 +98,45 @@ class TurnManager:
118
  jid = config.juror_id
119
  memory = memories.get(jid)
120
 
121
- # Base weight from influence
122
  base_weight = 0.5 + (config.influence * self.INFLUENCE_WEIGHT)
123
 
124
- # On-the-fence bonus (conviction between 0.35-0.65)
125
  if memory:
126
  conviction = memory.current_conviction
127
  fence_distance = abs(conviction - 0.5)
128
- if fence_distance < 0.15: # Very on the fence
129
  fence_bonus = self.ON_FENCE_BONUS * (1 - fence_distance / 0.15)
130
  else:
131
  fence_bonus = 0.0
132
  else:
133
  fence_bonus = 0.0
134
 
135
- # Recency penalty - spoke recently?
136
  recency_multiplier = 1.0
137
  reason_parts = []
138
 
139
  for rounds_ago, speakers in enumerate(reversed(self.speaker_history[-self.RECENCY_WINDOW:])):
140
  if jid in speakers:
141
- # More recent = bigger penalty
142
  penalty = self.RECENCY_PENALTY ** (rounds_ago + 1)
143
  recency_multiplier *= penalty
144
  reason_parts.append(f"spoke {rounds_ago + 1} rounds ago")
145
  break
146
 
147
- # Volatility bonus - volatile jurors speak more
148
  volatility_bonus = config.volatility * 0.5
149
-
150
- # Calculate final weight
151
  weight = (base_weight + fence_bonus + volatility_bonus) * recency_multiplier
152
-
153
- # Add some randomness
154
  weight += random.uniform(0, self.RANDOM_FACTOR)
155
 
156
- # Build reason string
157
  reasons = []
158
  if fence_bonus > 0:
159
  reasons.append(f"on fence (+{fence_bonus:.2f})")
160
  if recency_multiplier < 1.0:
161
  reasons.append(f"recent speaker (x{recency_multiplier:.2f})")
162
  if config.influence > 0.6:
163
- reasons.append(f"high influence")
164
  if config.volatility > 0.6:
165
- reasons.append(f"volatile")
166
 
167
  weights.append(SpeakerWeight(
168
  juror_id=jid,
169
- weight=max(0.1, weight), # Minimum weight to ensure everyone has a chance
170
  reason=", ".join(reasons) if reasons else "baseline"
171
  ))
172
 
@@ -185,12 +155,10 @@ class TurnManager:
185
  if not remaining:
186
  break
187
 
188
- # Calculate total weight
189
  total = sum(w.weight for w in remaining)
190
  if total <= 0:
191
  break
192
 
193
- # Random selection
194
  r = random.uniform(0, total)
195
  cumulative = 0
196
 
@@ -203,15 +171,6 @@ class TurnManager:
203
 
204
  return selected
205
 
206
- def get_speaker_weights_debug(
207
- self,
208
- configs: list[JurorConfig],
209
- memories: dict[str, JurorMemory],
210
- current_round: int
211
- ) -> list[SpeakerWeight]:
212
- """Get weights for debugging/display purposes."""
213
- return self._calculate_weights(configs, memories, current_round)
214
-
215
  def reset(self):
216
  """Reset speaker history for new game."""
217
  self.speaker_history = []
@@ -230,7 +189,7 @@ class OrchestratorAgent:
230
  def __init__(
231
  self,
232
  juror_configs: list[JurorConfig],
233
- juror_agents: dict[str, "JurorAgent"],
234
  case: "CriminalCase"
235
  ):
236
  self.juror_configs = juror_configs
@@ -238,10 +197,8 @@ class OrchestratorAgent:
238
  self.case = case
239
  self.turn_manager = TurnManager()
240
 
241
- # Initialize game state
242
  self.state = GameState(case_id=case.case_id)
243
 
244
- # Initialize votes and convictions from agents
245
  for jid, agent in juror_agents.items():
246
  self.state.votes[jid] = agent.get_vote()
247
  self.state.conviction_scores[jid] = agent.memory.current_conviction
@@ -259,21 +216,12 @@ class OrchestratorAgent:
259
  self,
260
  num_speakers: int = None
261
  ) -> list[TurnResult]:
262
- """Run a single round of deliberation.
263
-
264
- Args:
265
- num_speakers: Number of AI speakers this round (random 1-3 if None)
266
-
267
- Returns:
268
- List of TurnResult for each speaker
269
- """
270
  self.state.round_number += 1
271
  results = []
272
 
273
- # Record vote snapshot at start of round
274
  votes_at_start = dict(self.state.votes)
275
 
276
- # Select speakers using fair queue
277
  speakers = self.turn_manager.select_speakers(
278
  self.state,
279
  self.juror_configs,
@@ -282,13 +230,11 @@ class OrchestratorAgent:
282
  exclude_player=True
283
  )
284
 
285
- # Process each speaker
286
  for speaker_id in speakers:
287
  result = await self._process_speaker_turn(speaker_id)
288
  if result:
289
  results.append(result)
290
 
291
- # Check for vote stability
292
  if self.state.votes == votes_at_start:
293
  self.state.rounds_without_change += 1
294
  else:
@@ -297,21 +243,29 @@ class OrchestratorAgent:
297
  return results
298
 
299
  async def _process_speaker_turn(self, speaker_id: str) -> TurnResult | None:
300
- """Process a single speaker's turn.
301
-
302
- Args:
303
- speaker_id: ID of the speaking juror
304
-
305
- Returns:
306
- TurnResult with argument and impacts, or None on error
307
- """
308
  agent = self.juror_agents.get(speaker_id)
309
  if not agent:
310
  return None
311
 
312
  try:
313
- # Generate argument
314
- turn = await agent.generate_argument(self.case, self.state)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
 
316
  # Process reactions from other jurors
317
  conviction_changes = {}
@@ -321,14 +275,12 @@ class OrchestratorAgent:
321
  if other_id == speaker_id:
322
  continue
323
 
324
- # Calculate conviction change
325
  old_vote = self.state.votes.get(other_id)
326
- old_conviction = other_agent.memory.current_conviction
327
-
328
- # Base impact with some randomness
329
  base_impact = random.uniform(-0.15, 0.15)
330
 
331
- # Calculate actual impact
 
 
332
  delta = calculate_conviction_change(
333
  other_agent.config,
334
  other_agent.memory,
@@ -336,52 +288,69 @@ class OrchestratorAgent:
336
  base_impact=base_impact
337
  )
338
 
339
- # Apply to agent
340
  other_agent.receive_argument(turn, delta)
341
  conviction_changes[other_id] = delta
342
-
343
- # Record in turn impact
344
  turn.impact[other_id] = delta
345
 
346
- # Check for vote change
347
  new_vote = other_agent.get_vote()
348
  if old_vote != new_vote:
349
  self.state.votes[other_id] = new_vote
350
  vote_changes.append((other_id, old_vote, new_vote))
351
 
352
- # Update conviction in game state
353
  self.state.conviction_scores[other_id] = other_agent.memory.current_conviction
354
 
355
- # Log the turn
356
  self.state.deliberation_log.append(turn)
357
 
358
  return TurnResult(
359
  turn=turn,
360
  conviction_changes=conviction_changes,
361
- vote_changes=vote_changes
 
 
362
  )
363
 
364
  except Exception as e:
365
  print(f"Error processing turn for {speaker_id}: {e}")
366
  return None
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  def process_player_argument(
369
  self,
370
  content: str,
371
  argument_type: str,
372
  target_id: str | None = None
373
  ) -> TurnResult:
374
- """Process an argument from the human player.
375
-
376
- Args:
377
- content: The argument text
378
- argument_type: Type of argument (e.g., "challenge_evidence")
379
- target_id: Optional specific juror being addressed
380
-
381
- Returns:
382
- TurnResult with impacts
383
- """
384
- # Create turn
385
  turn = DeliberationTurn(
386
  round_number=self.state.round_number,
387
  speaker_id="juror_7",
@@ -391,17 +360,13 @@ class OrchestratorAgent:
391
  target_id=target_id
392
  )
393
 
394
- # Process reactions
395
  conviction_changes = {}
396
  vote_changes = []
397
 
398
  for juror_id, agent in self.juror_agents.items():
399
  old_vote = self.state.votes.get(juror_id)
400
-
401
- # Player arguments have slightly higher base impact
402
  base_impact = random.uniform(-0.1, 0.1) * 1.2
403
 
404
- # Bonus if targeting this specific juror
405
  if target_id == juror_id:
406
  base_impact *= 1.5
407
 
@@ -416,7 +381,6 @@ class OrchestratorAgent:
416
  conviction_changes[juror_id] = delta
417
  turn.impact[juror_id] = delta
418
 
419
- # Check vote change
420
  new_vote = agent.get_vote()
421
  if old_vote != new_vote:
422
  self.state.votes[juror_id] = new_vote
@@ -433,15 +397,10 @@ class OrchestratorAgent:
433
  )
434
 
435
  def set_player_side(self, side: str) -> None:
436
- """Set the player's chosen side.
437
-
438
- Args:
439
- side: "prosecute" or "defend"
440
- """
441
  self.state.player_side = side
442
  self.state.phase = GamePhase.DELIBERATION
443
 
444
- # Set player vote
445
  player_vote = "guilty" if side == "prosecute" else "not_guilty"
446
  self.state.votes["juror_7"] = player_vote
447
  self.state.conviction_scores["juror_7"] = 0.8 if side == "prosecute" else 0.2
 
12
  from core.conviction import calculate_conviction_change
13
 
14
  if TYPE_CHECKING:
15
+ from agents.smolagent_juror import SmolagentJuror
16
  from case_db.models import CriminalCase
17
 
18
 
 
21
  """Weight information for speaker selection."""
22
  juror_id: str
23
  weight: float
24
+ reason: str
25
 
26
 
27
  @dataclass
28
  class TurnResult:
29
  """Result of a single turn in deliberation."""
30
  turn: DeliberationTurn
31
+ conviction_changes: dict[str, float]
32
+ vote_changes: list[tuple[str, str, str]]
33
+ reasoning_steps: list[str] = field(default_factory=list)
34
+ tool_calls: list[str] = field(default_factory=list)
35
 
36
 
37
  class TurnManager:
 
44
  4. Some randomness to keep things unpredictable
45
  """
46
 
47
+ ON_FENCE_BONUS = 2.0
48
+ RECENCY_PENALTY = 0.3
49
+ INFLUENCE_WEIGHT = 1.5
50
+ RANDOM_FACTOR = 0.3
 
 
 
51
  RECENCY_WINDOW = 2
52
 
53
  def __init__(self):
54
+ self.speaker_history: list[list[str]] = []
55
 
56
  def select_speakers(
57
  self,
 
61
  num_speakers: int = None,
62
  exclude_player: bool = True
63
  ) -> list[str]:
64
+ """Select speakers for the next round using weighted selection."""
 
 
 
 
 
 
 
 
 
 
 
 
65
  if num_speakers is None:
66
  num_speakers = random.randint(1, 3)
67
 
 
68
  eligible = [
69
  c for c in juror_configs
70
  if not (exclude_player and c.is_player())
 
73
  if not eligible:
74
  return []
75
 
 
76
  weights = self._calculate_weights(
77
  eligible,
78
  juror_memories,
79
  game_state.round_number
80
  )
81
 
 
82
  selected = self._weighted_select(weights, min(num_speakers, len(eligible)))
 
 
83
  self.speaker_history.append(selected)
 
 
84
  game_state.speaking_queue = selected
85
 
86
  return selected
 
98
  jid = config.juror_id
99
  memory = memories.get(jid)
100
 
 
101
  base_weight = 0.5 + (config.influence * self.INFLUENCE_WEIGHT)
102
 
 
103
  if memory:
104
  conviction = memory.current_conviction
105
  fence_distance = abs(conviction - 0.5)
106
+ if fence_distance < 0.15:
107
  fence_bonus = self.ON_FENCE_BONUS * (1 - fence_distance / 0.15)
108
  else:
109
  fence_bonus = 0.0
110
  else:
111
  fence_bonus = 0.0
112
 
 
113
  recency_multiplier = 1.0
114
  reason_parts = []
115
 
116
  for rounds_ago, speakers in enumerate(reversed(self.speaker_history[-self.RECENCY_WINDOW:])):
117
  if jid in speakers:
 
118
  penalty = self.RECENCY_PENALTY ** (rounds_ago + 1)
119
  recency_multiplier *= penalty
120
  reason_parts.append(f"spoke {rounds_ago + 1} rounds ago")
121
  break
122
 
 
123
  volatility_bonus = config.volatility * 0.5
 
 
124
  weight = (base_weight + fence_bonus + volatility_bonus) * recency_multiplier
 
 
125
  weight += random.uniform(0, self.RANDOM_FACTOR)
126
 
 
127
  reasons = []
128
  if fence_bonus > 0:
129
  reasons.append(f"on fence (+{fence_bonus:.2f})")
130
  if recency_multiplier < 1.0:
131
  reasons.append(f"recent speaker (x{recency_multiplier:.2f})")
132
  if config.influence > 0.6:
133
+ reasons.append("high influence")
134
  if config.volatility > 0.6:
135
+ reasons.append("volatile")
136
 
137
  weights.append(SpeakerWeight(
138
  juror_id=jid,
139
+ weight=max(0.1, weight),
140
  reason=", ".join(reasons) if reasons else "baseline"
141
  ))
142
 
 
155
  if not remaining:
156
  break
157
 
 
158
  total = sum(w.weight for w in remaining)
159
  if total <= 0:
160
  break
161
 
 
162
  r = random.uniform(0, total)
163
  cumulative = 0
164
 
 
171
 
172
  return selected
173
 
 
 
 
 
 
 
 
 
 
174
  def reset(self):
175
  """Reset speaker history for new game."""
176
  self.speaker_history = []
 
189
  def __init__(
190
  self,
191
  juror_configs: list[JurorConfig],
192
+ juror_agents: dict[str, "SmolagentJuror"],
193
  case: "CriminalCase"
194
  ):
195
  self.juror_configs = juror_configs
 
197
  self.case = case
198
  self.turn_manager = TurnManager()
199
 
 
200
  self.state = GameState(case_id=case.case_id)
201
 
 
202
  for jid, agent in juror_agents.items():
203
  self.state.votes[jid] = agent.get_vote()
204
  self.state.conviction_scores[jid] = agent.memory.current_conviction
 
216
  self,
217
  num_speakers: int = None
218
  ) -> list[TurnResult]:
219
+ """Run a single round of deliberation."""
 
 
 
 
 
 
 
220
  self.state.round_number += 1
221
  results = []
222
 
 
223
  votes_at_start = dict(self.state.votes)
224
 
 
225
  speakers = self.turn_manager.select_speakers(
226
  self.state,
227
  self.juror_configs,
 
230
  exclude_player=True
231
  )
232
 
 
233
  for speaker_id in speakers:
234
  result = await self._process_speaker_turn(speaker_id)
235
  if result:
236
  results.append(result)
237
 
 
238
  if self.state.votes == votes_at_start:
239
  self.state.rounds_without_change += 1
240
  else:
 
243
  return results
244
 
245
  async def _process_speaker_turn(self, speaker_id: str) -> TurnResult | None:
246
+ """Process a single speaker's turn."""
 
 
 
 
 
 
 
247
  agent = self.juror_agents.get(speaker_id)
248
  if not agent:
249
  return None
250
 
251
  try:
252
+ # Generate argument - SmolagentJuror always returns (turn, reasoning_steps)
253
+ turn, reasoning_data = await agent.generate_argument(self.case, self.state)
254
+
255
+ # Extract reasoning steps for UI
256
+ reasoning_steps = []
257
+ if reasoning_data:
258
+ reasoning_steps = [
259
+ f"Step {s.step_number}: {s.action} - {s.content[:100]}"
260
+ if hasattr(s, 'step_number') else str(s)
261
+ for s in reasoning_data
262
+ ]
263
+
264
+ # Extract tool calls
265
+ tool_calls = agent.last_tool_calls if hasattr(agent, 'last_tool_calls') else []
266
+
267
+ # Select active listeners for full processing
268
+ active_listeners = self._select_active_listeners(turn)
269
 
270
  # Process reactions from other jurors
271
  conviction_changes = {}
 
275
  if other_id == speaker_id:
276
  continue
277
 
 
278
  old_vote = self.state.votes.get(other_id)
 
 
 
279
  base_impact = random.uniform(-0.15, 0.15)
280
 
281
+ if other_id in active_listeners:
282
+ base_impact *= 1.2
283
+
284
  delta = calculate_conviction_change(
285
  other_agent.config,
286
  other_agent.memory,
 
288
  base_impact=base_impact
289
  )
290
 
 
291
  other_agent.receive_argument(turn, delta)
292
  conviction_changes[other_id] = delta
 
 
293
  turn.impact[other_id] = delta
294
 
 
295
  new_vote = other_agent.get_vote()
296
  if old_vote != new_vote:
297
  self.state.votes[other_id] = new_vote
298
  vote_changes.append((other_id, old_vote, new_vote))
299
 
 
300
  self.state.conviction_scores[other_id] = other_agent.memory.current_conviction
301
 
 
302
  self.state.deliberation_log.append(turn)
303
 
304
  return TurnResult(
305
  turn=turn,
306
  conviction_changes=conviction_changes,
307
+ vote_changes=vote_changes,
308
+ reasoning_steps=reasoning_steps,
309
+ tool_calls=tool_calls,
310
  )
311
 
312
  except Exception as e:
313
  print(f"Error processing turn for {speaker_id}: {e}")
314
  return None
315
 
316
+ def _select_active_listeners(
317
+ self,
318
+ turn: DeliberationTurn,
319
+ max_active: int = 3
320
+ ) -> list[str]:
321
+ """Select jurors for full agent processing (active listeners)."""
322
+ active = []
323
+
324
+ for jid, agent in self.juror_agents.items():
325
+ if jid == turn.speaker_id:
326
+ continue
327
+
328
+ if 0.35 < agent.memory.current_conviction < 0.65:
329
+ active.append((jid, 3))
330
+ elif agent.config.influence > 0.7:
331
+ active.append((jid, 2))
332
+ elif turn.target_id == jid:
333
+ active.append((jid, 3))
334
+ elif len(agent.memory.conviction_history) > 1:
335
+ recent_change = abs(
336
+ agent.memory.conviction_history[-1] -
337
+ agent.memory.conviction_history[-2]
338
+ ) if len(agent.memory.conviction_history) >= 2 else 0
339
+ if recent_change > 0.1:
340
+ active.append((jid, 2))
341
+ else:
342
+ active.append((jid, 1))
343
+
344
+ active.sort(key=lambda x: x[1], reverse=True)
345
+ return [jid for jid, _ in active[:max_active]]
346
+
347
  def process_player_argument(
348
  self,
349
  content: str,
350
  argument_type: str,
351
  target_id: str | None = None
352
  ) -> TurnResult:
353
+ """Process an argument from the human player."""
 
 
 
 
 
 
 
 
 
 
354
  turn = DeliberationTurn(
355
  round_number=self.state.round_number,
356
  speaker_id="juror_7",
 
360
  target_id=target_id
361
  )
362
 
 
363
  conviction_changes = {}
364
  vote_changes = []
365
 
366
  for juror_id, agent in self.juror_agents.items():
367
  old_vote = self.state.votes.get(juror_id)
 
 
368
  base_impact = random.uniform(-0.1, 0.1) * 1.2
369
 
 
370
  if target_id == juror_id:
371
  base_impact *= 1.5
372
 
 
381
  conviction_changes[juror_id] = delta
382
  turn.impact[juror_id] = delta
383
 
 
384
  new_vote = agent.get_vote()
385
  if old_vote != new_vote:
386
  self.state.votes[juror_id] = new_vote
 
397
  )
398
 
399
  def set_player_side(self, side: str) -> None:
400
+ """Set the player's chosen side."""
 
 
 
 
401
  self.state.player_side = side
402
  self.state.phase = GamePhase.DELIBERATION
403
 
 
404
  player_vote = "guilty" if side == "prosecute" else "not_guilty"
405
  self.state.votes["juror_7"] = player_vote
406
  self.state.conviction_scores["juror_7"] = 0.8 if side == "prosecute" else 0.2
requirements.txt CHANGED
@@ -13,6 +13,11 @@ elevenlabs>=1.0.0
13
 
14
  # Agents
15
  smolagents>=1.0.0
 
 
 
 
 
16
 
17
  # Utilities
18
  httpx>=0.27.0
 
13
 
14
  # Agents
15
  smolagents>=1.0.0
16
+ litellm>=1.30.0
17
+
18
+ # LlamaIndex RAG with Nebius embeddings
19
+ llama-index-core>=0.10.0
20
+ llama-index-embeddings-nebius>=0.1.0
21
 
22
  # Utilities
23
  httpx>=0.27.0