File Coverage

blib/lib/Sim/Agent/Runner.pm
Criterion Covered Total %
statement 15 176 8.5
branch 0 74 0.0
condition 0 30 0.0
subroutine 5 17 29.4
pod 0 2 0.0
total 20 299 6.6


line stmt bran cond sub pod time code
1             package Sim::Agent::Runner;
2              
3 1     1   7 use strict;
  1         4  
  1         42  
4 1     1   6 use warnings;
  1         2  
  1         54  
5              
6 1     1   549 use Sim::Agent::Engine;
  1         4  
  1         43  
7 1     1   622 use Sim::Agent::Hook;
  1         4  
  1         44  
8 1     1   587 use Sim::Agent::LLM::Ollama;
  1         4  
  1         2732  
9              
10             sub new
11             {
12 0     0 0   my ($class, %opts) = @_;
13              
14 0 0         die "graph required" unless $opts{graph};
15 0 0         die "journal required" unless $opts{journal};
16              
17             my $self =
18             {
19             graph => $opts{graph},
20             journal => $opts{journal},
21 0   0       dev => $opts{dev} || 0,
22             state => {},
23             };
24              
25 0           bless $self, $class;
26 0           return $self;
27             }
28              
29             sub run
30             {
31 0     0 0   my ($self) = @_;
32              
33 0           $self->_initialize_state;
34 0           $self->_main_loop;
35             }
36              
37             sub _initialize_state
38             {
39 0     0     my ($self) = @_;
40              
41             $self->{state} =
42             {
43 0           queue => [],
44             completed => {},
45             pending_inputs => {},
46             iteration_count => 0,
47             active => {},
48             call_count => {},
49             pingpong => {},
50             };
51              
52 0           push @{ $self->{state}->{queue} }, $self->{graph}->{entry};
  0            
53              
54 0           $self->{journal}->log("[START] entry=$self->{graph}->{entry}");
55             }
56              
57             sub _main_loop
58             {
59 0     0     my ($self) = @_;
60              
61 0           my $graph = $self->{graph};
62 0           my $state = $self->{state};
63              
64 0           while (@{ $state->{queue} })
  0            
65             {
66              
67 0           $state->{iteration_count}++;
68              
69 0 0         if ($state->{iteration_count} > $graph->{limits}->{max_iterations})
70             {
71 0           $self->{journal}->log("[ABORT] max_iterations reached");
72 0           last;
73             }
74              
75 0           my $agent_name = shift @{ $state->{queue} };
  0            
76              
77 0 0         next if $state->{active}->{$agent_name};
78              
79 0 0         my $agent = $graph->{agents}->{$agent_name}
80             or die "Unknown agent: $agent_name";
81              
82 0           $self->{journal}->log("[ACTIVATE] $agent_name");
83              
84 0 0         unless (Sim::Agent::Engine::dependencies_satisfied($agent, $state))
85             {
86 0           push @{ $state->{queue} }, $agent_name;
  0            
87 0           next;
88             }
89              
90 0           $state->{active}->{$agent_name} = 1;
91              
92 0           my $result;
93              
94 0 0         if ($agent->{role} eq 'worker')
    0          
    0          
95             {
96 0           $result = $self->_run_worker($agent);
97             }
98             elsif ($agent->{role} eq 'critic')
99             {
100 0           $result = $self->_run_critic($agent);
101             }
102             elsif ($agent->{role} eq 'chief')
103             {
104 0           $result = $self->_run_critic($agent); # chief behaves like critic
105             }
106             else
107             {
108 0           die "Unknown role: $agent->{role}";
109             }
110              
111 0           delete $state->{active}->{$agent_name};
112              
113 0           $state->{completed}->{$agent_name} = $result;
114              
115 0 0 0       if ($agent->{role} eq 'chief' && $result->{status} eq 'stop')
116             {
117 0           $self->{journal}->log("[STOP] chief terminated execution");
118 0           last;
119             }
120              
121 0           $self->_route_result($agent, $result);
122             }
123              
124 0           $self->{journal}->log("[END]");
125             }
126              
127              
128             sub _run_worker {
129 0     0     my ($self, $agent) = @_;
130              
131 0           my $graph = $self->{graph};
132 0           my $state = $self->{state};
133 0           my $name = $agent->{name};
134              
135 0   0       my $inputs = delete $state->{pending_inputs}->{$name} || {};
136              
137 0           $self->{journal}->log("[WORKER] $name");
138              
139             # Initial generation (this should call prompt hook + LLM)
140 0           my $output = $self->_call_prompt($agent, $inputs);
141              
142 0           my $cycles = 0;
143 0           my $max_cycles = $graph->{limits}->{max_self_cycles};
144 0 0         $max_cycles = 0 unless defined $max_cycles;
145              
146 0           my $tainted = 0;
147 0           my $taint_reason = undef;
148              
149 0           my $prev_output = undef;
150              
151             # Self-reflection loop (bounded)
152 0           while ($max_cycles > 0)
153             {
154              
155 0           my $crit = $self->_call_selfcrit($agent, $output);
156              
157 0 0         die "Self-critic hook must return a hashref"
158             unless ref($crit) eq 'HASH';
159              
160 0   0       my $st = $crit->{status} // die "Self-critic hook must return { status => ... }";
161              
162 0 0         if ($st eq 'ok')
    0          
163             {
164 0           last;
165             }
166             elsif ($st eq 'revise')
167             {
168              
169 0           $cycles++;
170 0 0         if ($cycles >= $max_cycles)
171             {
172 0           $tainted = 1;
173 0           $taint_reason = 'max_self_cycles';
174 0           last;
175             }
176              
177 0           my $revised = $self->_call_revision($agent, $output, $crit);
178              
179 0 0         die "Revision hook must return a scalar string"
180             if ref($revised);
181              
182 0 0 0       if (defined $prev_output && defined $revised && $revised eq $prev_output)
      0        
183             {
184 0           $tainted = 1;
185 0           $taint_reason = 'stagnation';
186 0           last;
187             }
188              
189 0           $prev_output = $output;
190 0           $output = $revised;
191 0           next;
192             }
193             else
194             {
195 0           die "Self-critic status must be 'ok' or 'revise' (got '$st')";
196             }
197             }
198              
199 0           my %meta = ( cycles => $cycles );
200 0 0         if ($tainted)
201             {
202 0   0       $meta{taint_reason} = $taint_reason || 'unknown';
203             }
204              
205             return
206             {
207 0 0         agent => $name,
208             status => 'ok',
209             output => $output,
210             tainted => $tainted ? 1 : 0,
211             meta => \%meta,
212             };
213             }
214              
215              
216             sub _run_critic
217             {
218 0     0     my ($self, $agent) = @_;
219              
220 0           my $graph = $self->{graph};
221 0           my $state = $self->{state};
222 0           my $name = $agent->{name};
223              
224 0   0       my $inputs = delete $state->{pending_inputs}->{$name} || {};
225              
226 0           my @senders = keys %$inputs;
227 0 0         die "Critic $name activated with no inputs"
228             unless @senders;
229              
230 0 0         die "Critic $name expects exactly one input, got: " . join(",", @senders)
231             unless @senders == 1;
232              
233 0           my $sender = $senders[0];
234 0           my $packet = $inputs->{$sender};
235              
236 0           my $parse = $self->_call_critic_parse($agent, $packet);
237              
238 0 0         die "Critic parse hook must return a hashref"
239             unless ref($parse) eq 'HASH';
240              
241 0   0       my $status = $parse->{status} // die "Critic parse hook must return { status => ... }";
242              
243             # ------------------------------------------------------------
244             # Ping-pong limiter: after N consecutive FAILs for (sender->critic),
245             # force OK and continue downstream with taint.
246             # ------------------------------------------------------------
247 0           my $max_pp = $graph->{limits}->{max_pingpong};
248 0 0         $max_pp = 0 unless defined $max_pp;
249              
250 0           my $pp_key = "$sender->$name";
251 0           my $forced_ok = 0;
252              
253 0 0 0       if ($status eq 'ok') {
    0          
    0          
254 0           delete $state->{pingpong}->{$pp_key};
255             }
256             elsif ($status eq 'fail' && $max_pp > 0)
257             {
258              
259 0           $state->{pingpong}->{$pp_key}++;
260 0           my $n = $state->{pingpong}->{$pp_key};
261              
262 0 0         if ($n >= $max_pp) {
263 0           $forced_ok = 1;
264 0           $status = 'ok';
265 0           delete $state->{pingpong}->{$pp_key};
266              
267 0           $self->{journal}->log("[FORCE-OK] $name accepted $sender after $max_pp fails");
268             }
269             }
270             elsif ($status eq 'stop')
271             {
272             # Chief can return stop. No pingpong logic applies.
273             }
274             else
275             {
276 0           die "Critic status must be 'ok', 'fail', or 'stop' (got '$status')";
277             }
278              
279             # ------------------------------------------------------------
280             # Pass-through payload on OK (and forced OK):
281             # - output is original sender output
282             # - source aliases downstream key to original sender (crucial for wait-for joins)
283             # On FAIL:
284             # - no source alias (downstream sees key CriticName)
285             # - output is still pass-through (useful), critique goes in meta
286             # ------------------------------------------------------------
287              
288 0 0         my $source = ($status eq 'ok') ? $sender : undef;
289              
290 0           my %meta = (
291             reviewed => $sender,
292             );
293              
294 0 0         $meta{critique} = $parse->{critique} if exists $parse->{critique};
295 0 0         if ($forced_ok)
296             {
297 0           $meta{forced_ok} = 1;
298 0           $meta{pingpong_fails} = $max_pp;
299             }
300              
301 0 0         my $tainted = ($packet->{tainted} ? 1 : 0);
302 0 0         $tainted = 1 if $forced_ok;
303              
304             return
305             {
306             agent => $name,
307             source => $source, # used by routing key alias
308             status => $status, # ok|fail|stop
309             output => $packet->{output}, # pass-through payload
310 0           tainted => $tainted,
311             meta => \%meta,
312             };
313             }
314              
315              
316             sub _route_result
317             {
318 0     0     my ($self, $agent, $result) = @_;
319              
320 0           my $state = $self->{state};
321 0           my $graph = $self->{graph};
322              
323 0           my @targets;
324              
325 0 0 0       if ($agent->{role} eq 'worker')
    0          
326             {
327 0           push @targets, $agent->{send_to};
328             }
329             elsif ($agent->{role} eq 'critic' || $agent->{role} eq 'chief') {
330              
331 0 0         if ($result->{status} eq 'ok')
332             {
333 0           @targets = @{ $agent->{on_ok} };
  0            
334             }
335             else
336             {
337 0           @targets = @{ $agent->{on_fail} };
  0            
338             }
339             }
340              
341 0           for my $target (@targets) {
342              
343 0           $self->{journal}->log("[ROUTE] $agent->{name} -> $target");
344              
345 0   0       my $key = $result->{source} // $agent->{name};
346 0           $state->{pending_inputs}->{$target}->{$key} = $result;
347              
348 0 0         unless (Sim::Agent::Engine::is_enqueued_or_active($state, $target))
349             {
350 0 0         if (Sim::Agent::Engine::dependencies_satisfied($graph->{agents}->{$target}, $state))
351             {
352 0           push @{ $state->{queue} }, $target;
  0            
353             }
354             }
355             }
356             }
357              
358              
359             sub _call_prompt {
360 0     0     my ($self, $agent, $inputs) = @_;
361              
362 0           my $hook = Sim::Agent::Hook::load($agent->{prompt_file});
363 0           my $prompt = Sim::Agent::Hook::invoke($hook, { inputs => $inputs }, $agent, $self);
364              
365 0           $self->{state}->{call_count}->{ $agent->{name} }++;
366 0           my $call_id = $self->{state}->{call_count}->{ $agent->{name} };
367              
368 0           my $agent_dir = $self->{journal}->agent_dir($agent->{name});
369              
370             return Sim::Agent::LLM::Ollama::call_model(
371             model => $agent->{model},
372 0           prompt => $prompt,
373             agent_dir => $agent_dir,
374             call_id => $call_id,
375             );
376             }
377              
378             sub _call_prompt_DELETED
379             {
380 0     0     my ($self, $agent, $inputs) = @_;
381              
382 0           my $hook = Sim::Agent::Hook::load($agent->{prompt_file});
383 0           my $prompt = Sim::Agent::Hook::invoke($hook, { inputs => $inputs }, $agent, $self);
384              
385 0           $self->{state}->{call_count}->{ $agent->{name} }++;
386 0           my $call_id = $self->{state}->{call_count}->{ $agent->{name} };
387              
388 0           return $prompt;
389             }
390              
391             sub _call_selfcrit
392             {
393 0     0     my ($self, $agent, $output) = @_;
394              
395 0           my $hook = Sim::Agent::Hook::load($agent->{selfcrit_file});
396 0           return Sim::Agent::Hook::invoke($hook, { current_output => $output }, $agent, $self);
397             }
398              
399             sub _call_revision
400             {
401 0     0     my ($self, $agent, $output, $crit) = @_;
402              
403 0           my $hook = Sim::Agent::Hook::load($agent->{revision_file});
404             return Sim::Agent::Hook::invoke($hook,
405             {
406             current_output => $output,
407             critique => $crit->{critique},
408 0           }, $agent, $self);
409             }
410              
411             sub _call_critic_parse
412             {
413 0     0     my ($self, $agent, $packet) = @_;
414              
415 0           my $hook = Sim::Agent::Hook::load($agent->{parse_file});
416 0           return Sim::Agent::Hook::invoke($hook, { input_packet => $packet }, $agent, $self);
417             }
418              
419              
420              
421             1;
422              
423              
424             =pod
425              
426             =head1 NAME
427              
428             Sim::Agent::Runner - Executes a compiled Sim::Agent graph (FIFO scheduler, hook invocation, journaling)
429              
430             =head1 DESCRIPTION
431              
432             Internal runner for Sim::Agent. Owns runtime state (queue, pending inputs, completed packets, counters) and coordinates hook invocation, LLM calls, and deterministic routing.
433              
434             See L for the user-facing DSL and behavior.
435              
436             =head1 AUTHOR
437              
438             Gian Luca Brunetti (2026), gianluca.brunetti@gmail.com
439              
440             =head1 LICENSE
441              
442             The GNU General Public License v3.0
443              
444             =cut
445