File Coverage

blib/lib/Sim/AgentSoar/Worker.pm
Criterion Covered Total %
statement 15 130 11.5
branch 0 62 0.0
condition 0 32 0.0
subroutine 5 17 29.4
pod 3 3 100.0
total 23 244 9.4


line stmt bran cond sub pod time code
1             package Sim::AgentSoar::Worker;
2              
3 1     1   7 use strict;
  1         2  
  1         41  
4 1     1   5 use warnings;
  1         1  
  1         116  
5              
6             our $VERSION = '0.06';
7              
8 1     1   7 use JSON::PP qw(decode_json);
  1         2  
  1         106  
9 1     1   7 use Sim::AgentSoar::Engine ();
  1         1  
  1         26  
10 1     1   1180 use File::Temp qw(tempfile);
  1         21438  
  1         3369  
11              
12             sub new
13             {
14 0     0 1   my ($class, %args) = @_;
15              
16             my $self =
17             {
18 0   0       model => $args{model} // 'llama3.2:1b',
19             };
20              
21 0           return bless $self, $class;
22             }
23              
24             # --------------------------------------------------
25              
26             sub propose
27             {
28 0     0 1   my ($self, %args) = @_;
29              
30 0           my $prompt = $self->_build_propose_prompt(%args);
31 0           my $response = $self->_call_ollama($prompt);
32              
33 0           return $self->_extract_and_validate_operator($response);
34             }
35              
36             sub correct
37             {
38 0     0 1   my ($self, %args) = @_;
39              
40 0           my $prompt = $self->_build_correct_prompt(%args);
41 0           my $response = $self->_call_ollama($prompt);
42              
43 0           return $self->_extract_and_validate_operator($response);
44             }
45              
46             # --------------------------------------------------
47              
48             sub _call_ollama
49             {
50 0     0     my ($self, $prompt) = @_;
51              
52 0           my $model = $self->{model};
53              
54 0           my ($pfh, $prompt_file) = tempfile();
55 0           my ($ofh, $output_file) = tempfile();
56 0           my ($efh, $err_file) = tempfile();
57              
58 0           print {$pfh} $prompt;
  0            
59 0           close $pfh;
60              
61 0           close $ofh; # written by ollama
62 0           close $efh; # written by shell redirection
63              
64 0           my $cmd = join ' ',
65             'ollama', 'run', $self->_sh_quote($model),
66             '--format', 'json',
67             '--nowordwrap',
68             '<', $self->_sh_quote($prompt_file),
69             '>', $self->_sh_quote($output_file),
70             '2>', $self->_sh_quote($err_file);
71              
72 0           system($cmd);
73              
74 0           my $raw_status = $?;
75 0 0         my $exit_code = ($raw_status == -1) ? -1 : ($raw_status >> 8);
76 0           my $signal = ($raw_status & 127);
77              
78 0           my $stderr = $self->_slurp_file($err_file);
79              
80 0 0         if ($raw_status != 0)
81             {
82 0 0 0       my $extra = (defined $stderr && length $stderr) ? " stderr=$stderr" : "";
83 0           die "Ollama call failed (exit_code=$exit_code signal=$signal).$extra";
84             }
85              
86 0           my $output = $self->_slurp_file($output_file);
87              
88 0           unlink $prompt_file;
89 0           unlink $output_file;
90 0           unlink $err_file;
91              
92 0 0 0       die "Empty response from Ollama" unless defined $output && length $output;
93              
94 0           return $output;
95             }
96              
97             sub _slurp_file
98             {
99 0     0     my ($self, $path) = @_;
100              
101 0 0         open my $fh, '<', $path or return '';
102 0           local $/;
103 0           my $s = <$fh>;
104 0           close $fh;
105              
106 0 0         return defined($s) ? $s : '';
107             }
108              
109             sub _extract_and_validate_operator
110             {
111 0     0     my ($self, $json_text) = @_;
112              
113 0           my $decoded;
114              
115             eval
116 0           {
117 0           $decoded = decode_json($json_text);
118             };
119              
120 0 0         die "Invalid JSON from model: $json_text" if $@;
121              
122             my $operator = $decoded->{operator}
123 0 0         or die "No operator field in response: $json_text";
124              
125 0           my %allowed = map { $_ => 1 }
  0            
126             Sim::AgentSoar::Engine->allowed_operators;
127              
128             die "Invalid operator returned: $operator"
129 0 0         unless $allowed{$operator};
130              
131 0           return $operator;
132             }
133              
134             sub _extract_inner_hash
135             {
136 0     0     my ($self, $raw_text) = @_;
137              
138             # 1) single JSON object in full output
139 0           my $obj = $self->_try_decode_json($raw_text);
140 0 0         if (defined $obj)
141             {
142 0           my $inner = $self->_inner_from_decoded_obj($obj);
143 0 0         return $inner if defined $inner;
144             }
145              
146             # 2) streaming NDJSON (one JSON object per line)
147 0           my @lines = grep { /\S/ } split /\R/, $raw_text;
  0            
148 0           my $accum = '';
149 0           my $last_hash;
150              
151 0           for my $line (@lines) {
152 0           my $o = $self->_try_decode_json($line);
153 0 0 0       next unless defined $o && ref($o) eq 'HASH';
154              
155 0           $last_hash = $o;
156              
157 0 0         die "Ollama error: $o->{error}" if exists $o->{error};
158              
159 0 0 0       if (exists $o->{response} && defined $o->{response})
160             {
161 0           $accum .= $o->{response};
162 0           next;
163             }
164              
165 0 0 0       if (exists $o->{message} && ref($o->{message}) eq 'HASH')
166             {
167 0           my $c = $o->{message}{content};
168 0 0         $accum .= $c if defined $c;
169 0           next;
170             }
171             }
172              
173 0 0         if (length $accum)
174             {
175 0           my $inner = $self->_try_decode_json($accum);
176 0 0 0       die "Invalid model JSON (from streaming): $accum"
177             unless defined $inner && ref($inner) eq 'HASH';
178 0           return $inner;
179             }
180              
181 0 0         if (defined $last_hash)
182             {
183 0           my $inner = $self->_inner_from_decoded_obj($last_hash);
184 0 0         return $inner if defined $inner;
185             }
186              
187 0           die "Could not extract model JSON from Ollama output: $raw_text";
188             }
189              
190             sub _inner_from_decoded_obj
191             {
192 0     0     my ($self, $obj) = @_;
193              
194 0 0         return undef unless ref($obj) eq 'HASH';
195              
196             # CLI may print inner JSON directly
197 0 0         return $obj if exists $obj->{operator};
198              
199 0 0         die "Ollama error: $obj->{error}" if exists $obj->{error};
200              
201             # API-ish envelope: { response: "" } or { response: {..} }
202 0 0 0       if (exists $obj->{response} && defined $obj->{response})
203             {
204 0           my $r = $obj->{response};
205              
206 0 0         return $r if ref($r) eq 'HASH';
207              
208 0           my $inner = $self->_try_decode_json($r);
209 0 0 0       die "Invalid model JSON in response field: $r"
210             unless defined $inner && ref($inner) eq 'HASH';
211              
212 0           return $inner;
213             }
214              
215             # Chat envelope: { message: { content: "" } }
216 0 0 0       if (exists $obj->{message} && ref($obj->{message}) eq 'HASH')
217             {
218 0           my $c = $obj->{message}{content};
219 0 0         return undef unless defined $c;
220              
221 0           my $inner = $self->_try_decode_json($c);
222 0 0 0       die "Invalid model JSON in message.content: $c"
223             unless defined $inner && ref($inner) eq 'HASH';
224              
225 0           return $inner;
226             }
227              
228 0           return undef;
229             }
230              
231             sub _try_decode_json
232             {
233 0     0     my ($self, $text) = @_;
234              
235 0           my $decoded;
236 0 0         eval { $decoded = decode_json($text); 1 } or return undef;
  0            
  0            
237              
238 0           return $decoded;
239             }
240              
241             sub _sh_quote
242             {
243 0     0     my ($self, $s) = @_;
244 0 0         $s = '' unless defined $s;
245 0           $s =~ s/'/'"'"'/g;
246 0           return "'$s'";
247             }
248              
249             # --------------------------------------------------
250              
251             sub _build_propose_prompt
252             {
253 0     0     my ($self, %args) = @_;
254              
255 0           my $value = $args{value};
256 0           my $target = $args{target};
257 0           my $metric = $args{metric};
258              
259 0           return <<"PROMPT";
260             Output ONLY this JSON object and nothing else.
261              
262             {"operator":"add_1"}
263              
264             Replace add_1 with exactly one of:
265             add_1
266             sub_1
267             add_3
268             sub_3
269             mul_2
270             div_2_if_even
271              
272             Current value: $value
273             Target value: $target
274             Current distance: $metric
275             PROMPT
276             }
277              
278             sub _build_correct_prompt
279             {
280 0     0     my ($self, %args) = @_;
281              
282 0           return <<"PROMPT";
283             Output ONLY this JSON object and nothing else.
284              
285             {"operator":"add_1"}
286              
287             Replace add_1 with exactly one of:
288             add_1
289             sub_1
290             add_3
291             sub_3
292             mul_2
293             div_2_if_even
294              
295             Current value: $args{value}
296             Target value: $args{target}
297             Proposed operator: $args{operator}
298             Old distance: $args{old_metric}
299             New distance: $args{new_metric}
300             Regression tolerance: $args{regression_tolerance}
301             PROMPT
302             }
303              
304             1;
305              
306             =pod
307              
308             =head1 NAME
309              
310             Sim::AgentSoar::Worker - Constrained LLM-backed heuristic proposal engine
311              
312             =head1 SYNOPSIS
313              
314             use Sim::AgentSoar::Worker;
315              
316             my $worker = Sim::AgentSoar::Worker->new(
317             model => 'llama3.2:1b',
318             );
319              
320             my $operator = $worker->propose(
321             value => 8,
322             target => 19,
323             metric => 11,
324             );
325              
326             =head1 DESCRIPTION
327              
328             Sim::AgentSoar::Worker provides a strictly constrained interface between
329             the deterministic search controller and a locally hosted Large Language Model
330             (LLM) via Ollama.
331              
332             The Worker does not perform search, evaluation, or structural reasoning.
333             It is limited to proposing candidate operators under tightly controlled prompts.
334              
335             =head2 Design Constraints
336              
337             The Worker is intentionally restricted:
338              
339             =over 4
340              
341             =item * It may only propose operators from the Engine's allowed list.
342              
343             =item * It does not evaluate goal satisfaction.
344              
345             =item * It cannot alter search ordering.
346              
347             =item * It cannot introduce new operators.
348              
349             =item * It cannot mutate search topology.
350              
351             =back
352              
353             All outputs are validated against deterministic constraints before being
354             accepted by the search controller.
355              
356             =head2 Interaction Model
357              
358             The Worker operates in two phases:
359              
360             =over 4
361              
362             =item 1. Proposal phase (propose)
363              
364             Given current state and target, the model proposes one operator.
365              
366             =item 2. Optional correction phase (correct)
367              
368             If regression exceeds a specified tolerance, the model may revise the proposal
369             once. This constitutes bounded internal recursion.
370              
371             =back
372              
373             The recursion is strictly limited to one correction pass to prevent
374             narrative drift or uncontrolled self-reflection.
375              
376             =head2 LLM Containment Philosophy
377              
378             This module embodies a containment model:
379              
380             =over 4
381              
382             =item * Structural recursion lives in C.
383              
384             =item * Deterministic evaluation lives in C.
385              
386             =item * Heuristic intuition lives in the LLM.
387              
388             =back
389              
390             The LLM is treated as a stochastic heuristic oracle, not as a controller.
391             All invariant enforcement remains deterministic.
392              
393             =head1 METHODS
394              
395             =head2 new
396              
397             my $worker = Sim::AgentSoar::Worker->new(
398             model => 'llama3.2:1b',
399             );
400              
401             Creates a new worker instance.
402              
403             =head2 propose
404              
405             my $op = $worker->propose(
406             value => $value,
407             target => $target,
408             metric => $metric,
409             );
410              
411             Returns a single operator string.
412              
413             =head2 correct
414              
415             my $op = $worker->correct(
416             value => $value,
417             target => $target,
418             operator => $previous_op,
419             old_metric => $old_metric,
420             new_metric => $new_metric,
421             regression_tolerance => $tolerance,
422             );
423              
424             Performs a single bounded correction pass.
425              
426             =head1 DEPENDENCIES
427              
428             Requires:
429              
430             =over 4
431              
432             =item * Ollama installed locally
433              
434             =item * A running Ollama daemon (C)
435              
436             =item * A local model compatible with JSON output
437              
438             =back
439              
440             =head1 SECURITY AND VALIDATION
441              
442             All LLM output is parsed as strict JSON and validated against the
443             Engine's operator list. Invalid or unexpected output causes immediate failure.
444              
445             =head1 RESEARCH NOTES
446              
447             This module explores a hybrid architecture in which LLMs serve as
448             heuristic bias mechanisms within deterministic symbolic search.
449              
450             The design explicitly prevents:
451              
452             =over 4
453              
454             =item * Topological mutation
455              
456             =item * Self-modifying search logic
457              
458             =item * Operator invention
459              
460             =item * Unbounded recursive reflection
461              
462             =back
463              
464             The containment boundary is intentional and architectural.
465              
466             =head1 AUTHOR
467              
468             Gian Luca Brunetti (2026), gianluca.brunetti@gmail.com
469              
470             =cut