File Coverage

blib/lib/SignalWire/Agents/Agent/AgentBase.pm
Criterion Covered Total %
statement 522 570 91.5
branch 137 176 77.8
condition 77 150 51.3
subroutine 79 87 90.8
pod 0 58 0.0
total 815 1041 78.2


line stmt bran cond sub pod time code
1             package SignalWire::Agents::Agent::AgentBase;
2             # Copyright (c) 2025 SignalWire
3             # Licensed under the MIT License.
4              
5 35     35   7490413 use strict;
  35         88  
  35         1576  
6 35     35   204 use warnings;
  35         84  
  35         2400  
7 35     35   15734 use Moo;
  35         243010  
  35         228  
8 35     35   61284 use JSON qw(encode_json decode_json);
  35         282032  
  35         321  
9 35     35   21841 use MIME::Base64 qw(encode_base64 decode_base64);
  35         24119  
  35         3290  
10 35     35   19192 use Digest::SHA qw(hmac_sha256_hex);
  35         126383  
  35         4185  
11 35     35   19684 use POSIX qw(strftime);
  35         276586  
  35         235  
12 35     35   61947 use Scalar::Util qw(blessed reftype);
  35         146  
  35         2710  
13 35     35   11788 use Storable qw(dclone);
  35         81061  
  35         3236  
14 35     35   293 use Carp qw(croak);
  35         85  
  35         285886  
15              
16             # ---------- attributes ----------
17              
18             has name => (is => 'rw', default => sub { 'agent' });
19             has route => (is => 'rw', default => sub { '/' });
20             has host => (is => 'rw', default => sub { '0.0.0.0' });
21             has port => (is => 'rw', default => sub { $ENV{PORT} || 3000 });
22             has basic_auth_user => (is => 'rw', lazy => 1, builder => '_build_basic_auth_user');
23             has basic_auth_password => (is => 'rw', lazy => 1, builder => '_build_basic_auth_password');
24              
25             # Call settings
26             has auto_answer => (is => 'rw', default => sub { 1 });
27             has record_call => (is => 'rw', default => sub { 0 });
28             has record_format => (is => 'rw', default => sub { 'mp4' });
29             has record_stereo => (is => 'rw', default => sub { 1 });
30              
31             # Prompt system
32             has prompt_text => (is => 'rw', default => sub { '' });
33             has post_prompt => (is => 'rw', default => sub { '' });
34             has use_pom => (is => 'rw', default => sub { 1 });
35             has pom_sections => (is => 'rw', default => sub { [] });
36              
37             # Tool registry
38             has tools => (is => 'rw', default => sub { {} });
39             has tool_order => (is => 'rw', default => sub { [] });
40              
41             # AI config
42             has hints => (is => 'rw', default => sub { [] });
43             has pattern_hints => (is => 'rw', default => sub { [] });
44             has languages => (is => 'rw', default => sub { [] });
45             has pronunciations => (is => 'rw', default => sub { [] });
46             has params => (is => 'rw', default => sub { {} });
47             has global_data => (is => 'rw', default => sub { {} });
48             has native_functions => (is => 'rw', default => sub { [] });
49              
50             # Internal settings
51             has internal_fillers => (is => 'rw', default => sub { undef });
52             has debug_events_level => (is => 'rw', default => sub { 0 });
53              
54             # Includes and LLM params
55             has function_includes => (is => 'rw', default => sub { [] });
56             has prompt_llm_params => (is => 'rw', default => sub { {} });
57             has post_prompt_llm_params => (is => 'rw', default => sub { {} });
58              
59             # Verb insertion points
60             has pre_answer_verbs => (is => 'rw', default => sub { [] });
61             has post_answer_verbs => (is => 'rw', default => sub { [] });
62             has post_ai_verbs => (is => 'rw', default => sub { [] });
63             has answer_config => (is => 'rw', default => sub { {} });
64              
65             # Context system (lazy)
66             has context_builder => (
67             is => 'rw',
68             lazy => 1,
69             builder => '_build_context_builder',
70             );
71              
72             # Callbacks
73             has dynamic_config_callback => (is => 'rw', default => sub { undef });
74             has summary_callback => (is => 'rw', default => sub { undef });
75             has debug_event_handler => (is => 'rw', default => sub { undef });
76              
77             # URLs
78             has webhook_url => (is => 'rw', default => sub { undef });
79             has post_prompt_url => (is => 'rw', default => sub { undef });
80             has proxy_url_base => (is => 'rw', lazy => 1, builder => '_build_proxy_url_base');
81             has swaig_query_params => (is => 'rw', default => sub { {} });
82              
83             # Session manager (built in BUILD)
84             has session_manager => (is => 'rw');
85              
86             # Skill manager
87             has skill_manager => (is => 'rw', lazy => 1, builder => '_build_skill_manager');
88              
89             # ---------- builders ----------
90              
91             sub _build_basic_auth_user {
92 66     66   8118 my ($self) = @_;
93 66   66     643 return $ENV{SWML_BASIC_AUTH_USER} || $self->name;
94             }
95              
96             sub _build_basic_auth_password {
97 66     66   608 my ($self) = @_;
98 66   66     407 return $ENV{SWML_BASIC_AUTH_PASSWORD} || _generate_random_password();
99             }
100              
101             sub _build_proxy_url_base {
102 78   100 78   1308 return $ENV{SWML_PROXY_URL_BASE} || undef;
103             }
104              
105             sub _build_context_builder {
106 71     71   11788 require SignalWire::Agents::Contexts::ContextBuilder;
107 71         1641 return SignalWire::Agents::Contexts::ContextBuilder->new;
108             }
109              
110             sub _build_skill_manager {
111 2     2   23 my ($self) = @_;
112 2         745 require SignalWire::Agents::Skills::SkillManager;
113 2         40 return SignalWire::Agents::Skills::SkillManager->new(agent => $self);
114             }
115              
116             sub BUILD {
117 363     363 0 2335 my ($self) = @_;
118              
119             # Strip trailing slash from route
120 363         1505 my $r = $self->route;
121 363 100       1485 $r =~ s{/+$}{} if $r ne '/';
122 363         1185 $self->route($r);
123              
124             # Initialize session manager
125 363         31722 require SignalWire::Agents::Security::SessionManager;
126 363         8662 $self->session_manager(
127             SignalWire::Agents::Security::SessionManager->new(token_expiry_secs => 3600)
128             );
129             }
130              
131             # ---------- Prompt methods ----------
132              
133             sub set_prompt_text {
134 11     11 0 215 my ($self, $text) = @_;
135 11         53 $self->prompt_text($text);
136 11         32 return $self;
137             }
138              
139             sub set_post_prompt {
140 6     6 0 149 my ($self, $text) = @_;
141 6         36 $self->post_prompt($text);
142 6         18 return $self;
143             }
144              
145             sub prompt_add_section {
146 159     159 0 910 my ($self, $title, $body, %opts) = @_;
147 159   100     628 my $section = {
148             title => $title,
149             body => $body // '',
150             };
151 159 100       458 $section->{bullets} = $opts{bullets} if $opts{bullets};
152 159         282 push @{ $self->pom_sections }, $section;
  159         484  
153 159         460 return $self;
154             }
155              
156             sub prompt_add_subsection {
157 7     7 0 53 my ($self, $parent_title, $title, $body, %opts) = @_;
158 7         13 for my $sec (@{ $self->pom_sections }) {
  7         25  
159 6 50       22 if ($sec->{title} eq $parent_title) {
160 6   100     39 $sec->{subsections} //= [];
161 6   50     23 my $sub = { title => $title, body => $body // '' };
162 6 100       24 $sub->{bullets} = $opts{bullets} if $opts{bullets};
163 6         13 push @{ $sec->{subsections} }, $sub;
  6         29  
164 6         15 last;
165             }
166             }
167 7         15 return $self;
168             }
169              
170             sub prompt_add_to_section {
171 5     5 0 46 my ($self, $title, %opts) = @_;
172 5         11 for my $sec (@{ $self->pom_sections }) {
  5         15  
173 5 50       15 if ($sec->{title} eq $title) {
174 5 100       14 if ($opts{body}) {
175 1         3 $sec->{body} .= "\n" . $opts{body};
176             }
177 5 100       15 if ($opts{bullets}) {
178 4   100     14 $sec->{bullets} //= [];
179 4         6 push @{ $sec->{bullets} }, @{ $opts{bullets} };
  4         11  
  4         9  
180             }
181 5         9 last;
182             }
183             }
184 5         10 return $self;
185             }
186              
187             sub prompt_has_section {
188 35     35 0 18906 my ($self, $title) = @_;
189 35         68 for my $sec (@{ $self->pom_sections }) {
  35         141  
190 65 100       316 return 1 if $sec->{title} eq $title;
191             }
192 7         45 return 0;
193             }
194              
195             sub get_prompt {
196 75     75 0 254 my ($self) = @_;
197 75 100 100     362 if ($self->use_pom && @{ $self->pom_sections }) {
  70         378  
198 24         118 return $self->pom_sections;
199             }
200 51         254 return $self->prompt_text;
201             }
202              
203             # ---------- Tool methods ----------
204              
205             sub define_tool {
206 163     163 0 1013 my ($self, %opts) = @_;
207 163   66     855 my $name = $opts{name} // croak("define_tool requires 'name'");
208 162   100     493 my $description = $opts{description} // '';
209 162   100     560 my $parameters = $opts{parameters} // { type => 'object', properties => {} };
210 162         320 my $handler = $opts{handler};
211              
212 162 100       842 my $tool_def = {
213             function => $name,
214             description => $description,
215             parameters => $parameters,
216             (defined $handler ? (_handler => $handler) : ()),
217             };
218              
219             # Merge any extra fields (fillers, meta_data_token, etc.)
220 162         580 for my $k (keys %opts) {
221 616 100       2708 next if $k =~ /^(name|description|parameters|handler)$/;
222 2         7 $tool_def->{$k} = $opts{$k};
223             }
224              
225 162         898 $self->tools->{$name} = $tool_def;
226             # Maintain insertion order
227 161         517 push @{ $self->tool_order }, $name
228 162 100       354 unless grep { $_ eq $name } @{ $self->tool_order };
  42         177  
  162         736  
229              
230 162         1441 return $self;
231             }
232              
233             sub register_swaig_function {
234 23     23 0 167 my ($self, $func_def) = @_;
235 23   66     328 my $name = $func_def->{function} // croak("register_swaig_function needs 'function' key");
236 22         105 $self->tools->{$name} = $func_def;
237 22         152 push @{ $self->tool_order }, $name
238 22 50       41 unless grep { $_ eq $name } @{ $self->tool_order };
  3         13  
  22         613  
239 22         90 return $self;
240             }
241              
242             sub define_tools {
243 2     2 0 51 my ($self, @tool_defs) = @_;
244 2         6 for my $t (@tool_defs) {
245 4 50       16 if (ref $t eq 'HASH') {
246 4 100       11 if (exists $t->{function}) {
247 2         8 $self->register_swaig_function($t);
248             } else {
249 2         12 $self->define_tool(%$t);
250             }
251             }
252             }
253 2         7 return $self;
254             }
255              
256             sub on_function_call {
257 24     24 0 7588 my ($self, $name, $args, $raw_data) = @_;
258 24         118 my $tool = $self->tools->{$name};
259 24 100 100     179 return undef unless $tool && $tool->{_handler};
260 21         95 return $tool->{_handler}->($args, $raw_data);
261             }
262              
263             # ---------- AI Config methods ----------
264              
265             sub add_hint {
266 10     10 0 115 my ($self, $hint) = @_;
267 10         17 push @{ $self->hints }, $hint;
  10         44  
268 10         29 return $self;
269             }
270              
271             sub add_hints {
272 4     4 0 738 my ($self, @h) = @_;
273 4         11 push @{ $self->hints }, @h;
  4         22  
274 4         29 return $self;
275             }
276              
277             sub add_pattern_hint {
278 1     1 0 9 my ($self, $pattern) = @_;
279 1         2 push @{ $self->pattern_hints }, $pattern;
  1         5  
280 1         4 return $self;
281             }
282              
283             sub add_language {
284 4     4 0 777 my ($self, %lang) = @_;
285 4         14 push @{ $self->languages }, \%lang;
  4         21  
286 4         15 return $self;
287             }
288              
289             sub set_languages {
290 1     1 0 24 my ($self, $langs) = @_;
291 1         7 $self->languages($langs);
292 1         2 return $self;
293             }
294              
295             sub add_pronunciation {
296 4     4 0 780 my ($self, %pron) = @_;
297 4         10 push @{ $self->pronunciations }, \%pron;
  4         21  
298 4         16 return $self;
299             }
300              
301             sub set_pronunciations {
302 1     1 0 26 my ($self, $prons) = @_;
303 1         6 $self->pronunciations($prons);
304 1         3 return $self;
305             }
306              
307             sub set_param {
308 7     7 0 721 my ($self, $key, $value) = @_;
309 7         32 $self->params->{$key} = $value;
310 7         23 return $self;
311             }
312              
313             sub set_params {
314 2     2 0 701 my ($self, $p) = @_;
315 2         7 $self->params({ %{ $self->params }, %$p });
  2         18  
316 2         6 return $self;
317             }
318              
319             sub set_global_data {
320 66     66 0 1148 my ($self, $data) = @_;
321 66         261 $self->global_data($data);
322 66         152 return $self;
323             }
324              
325             sub update_global_data {
326 3     3 0 693 my ($self, $data) = @_;
327 3         44 $self->global_data({ %{ $self->global_data }, %$data });
  3         22  
328 3         11 return $self;
329             }
330              
331             sub set_native_functions {
332 5     5 0 1338 my ($self, $funcs) = @_;
333 5         18 $self->native_functions($funcs);
334 5         12 return $self;
335             }
336              
337             sub set_internal_fillers {
338 3     3 0 53 my ($self, $fillers) = @_;
339 3         12 $self->internal_fillers($fillers);
340 3         6 return $self;
341             }
342              
343             sub add_internal_filler {
344 2     2 0 16 my ($self, $filler) = @_;
345 2 100       8 if (!defined $self->internal_fillers) {
346 1         3 $self->internal_fillers([]);
347             }
348 2         3 push @{ $self->internal_fillers }, $filler;
  2         5  
349 2         5 return $self;
350             }
351              
352             sub enable_debug_events {
353 5     5 0 105 my ($self, $level) = @_;
354 5   100     22 $level //= 1;
355 5         17 $self->debug_events_level($level);
356 5         13 return $self;
357             }
358              
359             sub add_function_include {
360 3     3 0 70 my ($self, $include) = @_;
361 3         7 push @{ $self->function_includes }, $include;
  3         15  
362 3         9 return $self;
363             }
364              
365             sub set_function_includes {
366 0     0 0 0 my ($self, $includes) = @_;
367 0         0 $self->function_includes($includes);
368 0         0 return $self;
369             }
370              
371             sub set_prompt_llm_params {
372 8     8 0 735 my ($self, %p) = @_;
373 8         18 $self->prompt_llm_params({ %{ $self->prompt_llm_params }, %p });
  8         52  
374 8         28 return $self;
375             }
376              
377             sub set_post_prompt_llm_params {
378 4     4 0 685 my ($self, %p) = @_;
379 4         10 $self->post_prompt_llm_params({ %{ $self->post_prompt_llm_params }, %p });
  4         29  
380 4         13 return $self;
381             }
382              
383             # ---------- Verb management ----------
384              
385             sub add_pre_answer_verb {
386 10     10 0 265 my ($self, $verb_name, $verb_config) = @_;
387 10         22 push @{ $self->pre_answer_verbs }, { $verb_name => $verb_config };
  10         57  
388 10         36 return $self;
389             }
390              
391             sub add_post_answer_verb {
392 8     8 0 756 my ($self, $verb_name, $verb_config) = @_;
393 8         18 push @{ $self->post_answer_verbs }, { $verb_name => $verb_config };
  8         41  
394 8         25 return $self;
395             }
396              
397             sub add_post_ai_verb {
398 8     8 0 691 my ($self, $verb_name, $verb_config) = @_;
399 8         19 push @{ $self->post_ai_verbs }, { $verb_name => $verb_config };
  8         43  
400 8         22 return $self;
401             }
402              
403             sub clear_pre_answer_verbs {
404 2     2 0 596 my ($self) = @_;
405 2         14 $self->pre_answer_verbs([]);
406 2         4 return $self;
407             }
408              
409             sub clear_post_answer_verbs {
410 2     2 0 616 my ($self) = @_;
411 2         12 $self->post_answer_verbs([]);
412 2         5 return $self;
413             }
414              
415             sub clear_post_ai_verbs {
416 2     2 0 617 my ($self) = @_;
417 2         12 $self->post_ai_verbs([]);
418 2         6 return $self;
419             }
420              
421             sub set_answer_config {
422 1     1 0 22 my ($self, $config) = @_;
423 1         6 $self->answer_config($config);
424 1         3 return $self;
425             }
426              
427             # ---------- Contexts ----------
428              
429             sub define_contexts {
430 2     2 0 41 my ($self) = @_;
431 2         43 return $self->context_builder;
432             }
433              
434             sub contexts {
435 0     0 0 0 my ($self) = @_;
436 0         0 return $self->context_builder;
437             }
438              
439             # ---------- Skills ----------
440              
441             sub add_skill {
442 2     2 0 32 my ($self, $skill_name, $params) = @_;
443 2   50     18 $params //= {};
444 2         55 return $self->skill_manager->load_skill($skill_name, undef, $params);
445             }
446              
447             sub remove_skill {
448 0     0 0 0 my ($self, $skill_name) = @_;
449 0         0 return $self->skill_manager->unload_skill($skill_name);
450             }
451              
452             sub list_skills {
453 0     0 0 0 my ($self) = @_;
454 0         0 return $self->skill_manager->list_skills;
455             }
456              
457             sub has_skill {
458 1     1 0 634 my ($self, $skill_name) = @_;
459 1         38 return $self->skill_manager->has_skill($skill_name);
460             }
461              
462             # ---------- Web / callback setters ----------
463              
464             sub set_dynamic_config_callback {
465 3     3 0 66 my ($self, $cb) = @_;
466 3         11 $self->dynamic_config_callback($cb);
467 3         7 return $self;
468             }
469              
470             sub set_web_hook_url {
471 3     3 0 55 my ($self, $url) = @_;
472 3         17 $self->webhook_url($url);
473 3         9 return $self;
474             }
475              
476             sub set_post_prompt_url {
477 2     2 0 811 my ($self, $url) = @_;
478 2         10 $self->post_prompt_url($url);
479 2         3 return $self;
480             }
481              
482             sub manual_set_proxy_url {
483 2     2 0 648 my ($self, $url) = @_;
484 2         39 $self->proxy_url_base($url);
485 2         14 return $self;
486             }
487              
488             sub add_swaig_query_params {
489 6     6 0 765 my ($self, %params) = @_;
490 6         12 $self->swaig_query_params({ %{ $self->swaig_query_params }, %params });
  6         44  
491 6         20 return $self;
492             }
493              
494             sub clear_swaig_query_params {
495 2     2 0 756 my ($self) = @_;
496 2         9 $self->swaig_query_params({});
497 2         4 return $self;
498             }
499              
500             sub on_summary {
501 2     2 0 48 my ($self, $cb) = @_;
502 2         10 $self->summary_callback($cb);
503 2         4 return $self;
504             }
505              
506             sub on_debug_event {
507 1     1 0 20 my ($self, $cb) = @_;
508 1         5 $self->debug_event_handler($cb);
509 1         2 return $self;
510             }
511              
512             # ---------- URL construction ----------
513              
514             sub _build_webhook_url {
515 70     70   182 my ($self, $request_env) = @_;
516             # If explicit override set, use it
517 70 100       351 return $self->webhook_url if defined $self->webhook_url;
518              
519 69         285 my $base = $self->_detect_proxy_url($request_env);
520 69 100       353 my $route = $self->route eq '/' ? '' : $self->route;
521 69         217 my $url = $base . $route . '/swaig';
522              
523             # Append query params
524 69 100       137 if (%{ $self->swaig_query_params }) {
  69         360  
525 1         2 my @parts;
526 1         2 for my $k (sort keys %{ $self->swaig_query_params }) {
  1         7  
527 2   50     9 push @parts, "$k=" . ($self->swaig_query_params->{$k} // '');
528             }
529 1         5 $url .= '?' . join('&', @parts);
530             }
531              
532 69         213 return $url;
533             }
534              
535             sub _build_post_prompt_url {
536 70     70   190 my ($self, $request_env) = @_;
537 70 50       322 return $self->post_prompt_url if defined $self->post_prompt_url;
538 70         196 my $base = $self->_detect_proxy_url($request_env);
539 70 100       323 my $route = $self->route eq '/' ? '' : $self->route;
540 70         248 return $base . $route . '/post_prompt';
541             }
542              
543             sub _detect_proxy_url {
544 142     142   322 my ($self, $env) = @_;
545              
546 142 50       3529 return $self->proxy_url_base if defined $self->proxy_url_base;
547              
548 142   50     770 $env //= {};
549              
550             # Check X-Forwarded headers
551 142         281 my $proto = $env->{HTTP_X_FORWARDED_PROTO};
552 142         263 my $fhost = $env->{HTTP_X_FORWARDED_HOST};
553 142 100 66     429 if ($proto && $fhost) {
554 1         4 return "${proto}://${fhost}";
555             }
556              
557             # Check X-Original-URL
558 141         236 my $orig = $env->{HTTP_X_ORIGINAL_URL};
559 141 100       329 return $orig if $orig;
560              
561             # Fallback to server config
562 140   50     869 my $scheme = ($env->{HTTPS} || $env->{'psgi.url_scheme'} || 'http');
563 140 50       372 $scheme = 'https' if $scheme eq 'on';
564 140   33     1039 my $host = $env->{HTTP_HOST} || $self->host . ':' . $self->port;
565 140         452 return "${scheme}://${host}";
566             }
567              
568             sub get_full_url {
569 4     4 0 1791 my ($self, %opts) = @_;
570 4   33     75 my $base = $self->proxy_url_base // ('http://' . $self->host . ':' . $self->port);
571 4 50       36 my $route = $self->route eq '/' ? '' : $self->route;
572 4         7 my $url = $base . $route;
573 4 100       13 if ($opts{include_auth}) {
574 2         30 my $user = $self->basic_auth_user;
575 2         36 my $pass = $self->basic_auth_password;
576 2         31 $url =~ s{^(https?://)}{$1${user}:${pass}\@};
577             }
578 4         40 return $url;
579             }
580              
581             # ---------- render_swml (5-phase pipeline) ----------
582              
583             sub render_swml {
584 70     70 0 1704 my ($self, $request_env) = @_;
585 70   100     450 $request_env //= {};
586              
587 70         357 my $webhook_url = $self->_build_webhook_url($request_env);
588 70         261 my $post_prompt_url = $self->_build_post_prompt_url($request_env);
589              
590             # Embed auth credentials in webhook URL
591 70         1572 my $auth_user = $self->basic_auth_user;
592 70         1492 my $auth_pass = $self->basic_auth_password;
593 70 50       1254 $webhook_url =~ s{^(https?://)}{$1${auth_user}:${auth_pass}\@}
594             unless $webhook_url =~ /\@/;
595 70 50       819 $post_prompt_url =~ s{^(https?://)}{$1${auth_user}:${auth_pass}\@}
596             unless $post_prompt_url =~ /\@/;
597              
598 70         150 my @main_section;
599              
600             # Phase 1: Pre-answer verbs
601 70         150 push @main_section, @{ $self->pre_answer_verbs };
  70         336  
602              
603             # Phase 2: Answer verb
604 70 100       334 if ($self->auto_answer) {
605 69         263 my %answer_params = (max_duration => 14400);
606 69 100       119 %answer_params = (%answer_params, %{ $self->answer_config }) if %{ $self->answer_config };
  1         7  
  69         322  
607 69         377 push @main_section, { answer => \%answer_params };
608             }
609              
610             # Record call if enabled
611 70 100       312 if ($self->record_call) {
612 4 100       52 push @main_section, { record_call => {
613             format => $self->record_format,
614             stereo => $self->record_stereo ? JSON::true : JSON::false,
615             }};
616             }
617              
618             # Phase 3: Post-answer verbs
619 70         181 push @main_section, @{ $self->post_answer_verbs };
  70         238  
620              
621             # Phase 4: AI verb
622 70         315 my $ai = $self->_build_ai_verb($webhook_url, $post_prompt_url);
623 70         272 push @main_section, { ai => $ai };
624              
625             # Phase 5: Post-AI verbs
626 70         149 push @main_section, @{ $self->post_ai_verbs };
  70         308  
627              
628 70         365 my $doc = {
629             version => '1.0.0',
630             sections => { main => \@main_section },
631             };
632              
633 70         368 return $doc;
634             }
635              
636             sub _build_ai_verb {
637 70     70   267 my ($self, $webhook_url, $post_prompt_url) = @_;
638              
639 70         130 my %ai;
640              
641             # Prompt
642 70         313 my $prompt = $self->get_prompt;
643 70 100       302 if (ref $prompt eq 'ARRAY') {
644             # POM mode
645 22         109 $ai{prompt} = { pom => $prompt };
646             } else {
647 48 100       216 $ai{prompt} = { text => $prompt } if $prompt;
648             }
649              
650             # Merge prompt LLM params
651 70 100       134 if (%{ $self->prompt_llm_params }) {
  70         361  
652 3   50     15 $ai{prompt} //= {};
653 3         8 for my $k (keys %{ $self->prompt_llm_params }) {
  3         18  
654 5         21 $ai{prompt}{$k} = $self->prompt_llm_params->{$k};
655             }
656             }
657              
658             # Post prompt
659 70 100 66     365 if ($self->post_prompt && $self->post_prompt ne '') {
660 4         29 $ai{post_prompt} = { text => $self->post_prompt };
661 4 100       13 if (%{ $self->post_prompt_llm_params }) {
  4         27  
662 1         6 for my $k (keys %{ $self->post_prompt_llm_params }) {
  1         11  
663 1         8 $ai{post_prompt}{$k} = $self->post_prompt_llm_params->{$k};
664             }
665             }
666             }
667              
668 70 50       316 $ai{post_prompt_url} = $post_prompt_url if $post_prompt_url;
669              
670             # Params
671 70 100       185 $ai{params} = { %{ $self->params } } if %{ $self->params };
  3         38  
  70         321  
672              
673             # Hints
674 70         136 my @all_hints = @{ $self->hints };
  70         278  
675 70         149 push @all_hints, @{ $self->pattern_hints };
  70         306  
676 70 100       243 $ai{hints} = \@all_hints if @all_hints;
677              
678             # Languages
679 70 100       162 $ai{languages} = $self->languages if @{ $self->languages };
  70         308  
680              
681             # Pronunciations
682 70 100       123 $ai{pronounce} = $self->pronunciations if @{ $self->pronunciations };
  70         306  
683              
684             # SWAIG
685 70         154 my $swaig = {};
686              
687             # Build function list
688 70         129 my @functions;
689 70         139 for my $fname (@{ $self->tool_order }) {
  70         286  
690 20         80 my $tool = $self->tools->{$fname};
691 20 50       99 next unless $tool;
692 20         113 my %func = %$tool;
693 20         60 delete $func{_handler}; # Don't include handler in SWML
694 20   33     150 $func{web_hook_url} //= $webhook_url;
695 20         66 push @functions, \%func;
696             }
697 70 100       234 $swaig->{functions} = \@functions if @functions;
698              
699             # Native functions
700             $swaig->{native_functions} = $self->native_functions
701 70 100       151 if @{ $self->native_functions };
  70         295  
702              
703             # Includes
704             $swaig->{includes} = $self->function_includes
705 70 100       132 if @{ $self->function_includes };
  70         281  
706              
707 70 100       281 $ai{SWAIG} = $swaig if %$swaig;
708              
709             # Global data
710 10         59 $ai{global_data} = { %{ $self->global_data } }
711 70 100       157 if %{ $self->global_data };
  70         299  
712              
713             # Internal fillers
714 70 100       270 if (defined $self->internal_fillers) {
715 2   50     16 $ai{params} //= {};
716 2         9 $ai{params}{internal_fillers} = $self->internal_fillers;
717             }
718              
719             # Debug events
720 70 100       330 if ($self->debug_events_level > 0) {
721 2   50     18 $ai{params} //= {};
722 2         12 $ai{params}{debug_events} = $self->debug_events_level;
723             }
724              
725             # Contexts
726 70 100 66     2409 if ($self->context_builder && $self->context_builder->has_contexts) {
727 1         24 $ai{context_switch} = $self->context_builder->to_hashref;
728             }
729              
730 70         449 return \%ai;
731             }
732              
733             # ---------- PSGI / Plack ----------
734              
735             sub psgi_app {
736 22     22 0 4889 my ($self) = @_;
737 22         106 return $self->_build_psgi_app;
738             }
739              
740             sub _build_psgi_app {
741 22     22   51 my ($self) = @_;
742 22         3843 require Plack::Request;
743              
744 22         401345 my $route = $self->route;
745 22 100       128 $route = '' if $route eq '/';
746              
747 22         65 my $agent = $self;
748              
749             # Build the core app as a plain PSGI sub
750             my $core_app = sub {
751 20     20   30 my $env = shift;
752 20         128 my $req = Plack::Request->new($env);
753 20         201 my $path = $req->path_info;
754              
755             # Normalize path
756 20 100       218 $path =~ s{/+$}{} unless $path eq '/';
757              
758             # Health/ready endpoints (no auth)
759 20 100       49 if ($path eq '/health') {
760 4         101 return [200, ['Content-Type' => 'application/json'],
761             [encode_json({ status => 'healthy', agent => $agent->name })]];
762             }
763 16 100       36 if ($path eq '/ready') {
764 2         27 return [200, ['Content-Type' => 'application/json'],
765             [encode_json({ status => 'ready' })]];
766             }
767              
768             # Auth check for protected routes
769 14 100       44 my $expected_route = $route eq '' ? '/' : $route;
770 14         30 my $is_swaig = ($path eq "$route/swaig");
771 14         32 my $is_post_prompt = ($path eq "$route/post_prompt");
772 14   66     42 my $is_main = ($path eq $expected_route || ($route ne '' && $path eq "$route/"));
773              
774             # Root agent: treat '/' as main
775 14 100 100     55 if ($route eq '' && $path eq '/') {
776 10         24 $is_main = 1;
777             }
778              
779 14 50 66     44 if ($is_main || $is_swaig || $is_post_prompt) {
      33        
780 14         41 my $auth_ok = $agent->_check_auth($env);
781 14 100       47 unless ($auth_ok) {
782 6         30 return [401,
783             ['Content-Type' => 'text/plain', 'WWW-Authenticate' => 'Basic realm="SignalWire Agent"'],
784             ['Unauthorized']];
785             }
786             }
787              
788             # Route dispatch
789 8 100 33     54 if ($is_main && ($req->method eq 'GET' || $req->method eq 'POST')) {
    50 66        
    0 33        
      0        
790 7         83 return $agent->_handle_swml($env, $req);
791             }
792             elsif ($is_swaig && $req->method eq 'POST') {
793 1         13 return $agent->_handle_swaig($env, $req);
794             }
795             elsif ($is_post_prompt && $req->method eq 'POST') {
796 0         0 return $agent->_handle_post_prompt($env, $req);
797             }
798              
799 0         0 return [404, ['Content-Type' => 'text/plain'], ['Not Found']];
800 22         142 };
801              
802             # Maximum request body size: 1MB
803 22         46 my $max_body_size = 1_048_576;
804              
805             # Wrap with body size limit and security headers middleware
806             my $app_with_middleware = sub {
807 20     20   8574 my $env = shift;
808              
809             # Enforce body size limit by actually reading the body
810 20 100 66     126 if ($env->{REQUEST_METHOD} eq 'POST' || $env->{REQUEST_METHOD} eq 'PUT') {
811 1         3 my $input = $env->{'psgi.input'};
812 1 50       4 if ($input) {
813 1         3 my $body = '';
814 1         37 my $total = 0;
815 1         5 my $buf;
816 1         16 while (my $read = $input->read($buf, 8192)) {
817 1         57 $total += $read;
818 1 50       4 if ($total > $max_body_size) {
819 0         0 return [413, ['Content-Type' => 'application/json',
820             'X-Content-Type-Options' => 'nosniff',
821             'X-Frame-Options' => 'DENY',
822             'Cache-Control' => 'no-store'],
823             [encode_json({ error => 'Request body too large' })]];
824             }
825 1         7 $body .= $buf;
826             }
827             # Replace psgi.input with the buffered content so handlers can re-read
828 1         17 open my $new_input, '<', \$body;
829 1         3 $env->{'psgi.input'} = $new_input;
830 1         4 $env->{CONTENT_LENGTH} = length($body);
831             }
832             }
833              
834 20         46 my $res = $core_app->($env);
835 20 50       66 if (ref $res eq 'ARRAY') {
836 20         30 push @{ $res->[1] },
  20         91  
837             'X-Content-Type-Options' => 'nosniff',
838             'X-Frame-Options' => 'DENY',
839             'Cache-Control' => 'no-store';
840             }
841 20         103 return $res;
842 22         92 };
843              
844 22         85 return $app_with_middleware;
845             }
846              
847             sub _check_auth {
848 14     14   28 my ($self, $env) = @_;
849 14   100     55 my $auth_header = $env->{HTTP_AUTHORIZATION} // '';
850 14 100       80 return 0 unless $auth_header =~ /^Basic\s+(.+)$/i;
851 11   50     23 my $decoded = eval { decode_base64($1) } // '';
  11         66  
852 11         44 my ($user, $pass) = split(/:/, $decoded, 2);
853 11 100 66     52 return 0 unless defined $user && defined $pass;
854              
855             # Timing-safe comparison using HMAC (constant-time, no length leak)
856 10         268 my $expected_user = $self->basic_auth_user;
857 10         226 my $expected_pass = $self->basic_auth_password;
858              
859 10         78 my $user_ok = _timing_safe_eq($user, $expected_user);
860 10         29 my $pass_ok = _timing_safe_eq($pass, $expected_pass);
861              
862 10 100 100     70 return ($user_ok && $pass_ok) ? 1 : 0;
863             }
864              
865             sub _timing_safe_eq {
866 27     27   9172 my ($a, $b) = @_;
867             # HMAC-based constant-time comparison: no length leak
868 27         48 my $key = 'signalwire-timing-safe-comparison';
869 27         287 my $hmac_a = hmac_sha256_hex($a, $key);
870 27         194 my $hmac_b = hmac_sha256_hex($b, $key);
871 27         164 return $hmac_a eq $hmac_b;
872             }
873              
874             sub _handle_swml {
875 7     7   18 my ($self, $env, $req) = @_;
876              
877 7         11 my $agent = $self;
878              
879             # If dynamic config callback is set, clone and apply
880 7 100       80 if ($self->dynamic_config_callback) {
881 1         3 $agent = $self->_clone_for_request;
882 1         4 my $query_params = $req->query_parameters->as_hashref_mixed;
883 1         138 my $body_params = {};
884 1 50 33     3 if ($req->method eq 'POST' && $req->content_length) {
885 0         0 eval { $body_params = decode_json($req->content) };
  0         0  
886             }
887 1         9 my $headers = {};
888 1         3 for my $k (keys %$env) {
889 10 100       18 if ($k =~ /^HTTP_(.+)/) {
890 1         6 $headers->{lc($1)} = $env->{$k};
891             }
892             }
893 1         5 $self->dynamic_config_callback->($query_params, $body_params, $headers, $agent);
894             }
895              
896 7         32 my $swml = $agent->render_swml($env);
897 7         114 my $json = encode_json($swml);
898              
899 7         90 return [200, ['Content-Type' => 'application/json'], [$json]];
900             }
901              
902             sub _handle_swaig {
903 1     1   38 my ($self, $env, $req) = @_;
904              
905 1         3 my $body = eval { decode_json($req->content) };
  1         5  
906 1 50 33     523 unless ($body && ref $body eq 'HASH') {
907 0         0 return [400, ['Content-Type' => 'application/json'],
908             [encode_json({ error => 'Invalid JSON' })]];
909             }
910              
911 1         4 my $func_name = $body->{function};
912 1 50 33     17 unless ($func_name && exists $self->tools->{$func_name}) {
913 0         0 return [404, ['Content-Type' => 'application/json'],
914             [encode_json({ error => 'Function not found' })]];
915             }
916              
917             # Extract args
918 1         3 my $args = {};
919 1 50 33     15 if ($body->{argument} && ref $body->{argument}{parsed} eq 'ARRAY'
      33        
920 1         5 && @{ $body->{argument}{parsed} }) {
921 1   50     5 $args = $body->{argument}{parsed}[0] // {};
922             }
923              
924 1         5 my $result = $self->on_function_call($func_name, $args, $body);
925 1 50       12 unless (defined $result) {
926 0         0 return [500, ['Content-Type' => 'application/json'],
927             [encode_json({ error => 'Handler returned no result' })]];
928             }
929              
930             # Serialize result
931 1         3 my $response;
932 1 50 33     7 if (blessed($result) && $result->can('to_hash')) {
    50          
933 0         0 $response = $result->to_hash;
934             } elsif (ref $result eq 'HASH') {
935 1         3 $response = $result;
936             } else {
937 0         0 $response = { response => "$result" };
938             }
939              
940 1         22 return [200, ['Content-Type' => 'application/json'], [encode_json($response)]];
941             }
942              
943             sub _handle_post_prompt {
944 0     0   0 my ($self, $env, $req) = @_;
945              
946 0         0 my $body = eval { decode_json($req->content) };
  0         0  
947 0   0     0 $body //= {};
948              
949 0 0       0 if ($self->summary_callback) {
950 0         0 my $summary = undef;
951 0 0       0 if ($body->{post_prompt_data}) {
952             $summary = $body->{post_prompt_data}{parsed}
953 0   0     0 // $body->{post_prompt_data}{raw};
954             }
955 0         0 $self->summary_callback->($summary, $body);
956             }
957              
958 0         0 return [200, ['Content-Type' => 'application/json'],
959             [encode_json({ status => 'ok' })]];
960             }
961              
962             # ---------- Clone for dynamic config ----------
963              
964             sub _clone_for_request {
965 3     3   17 my ($self) = @_;
966 3         6 my %init;
967 3         12 for my $attr (qw(name route host port auto_answer record_call record_format
968             record_stereo prompt_text post_prompt use_pom
969             debug_events_level)) {
970 36         131 $init{$attr} = $self->$attr;
971             }
972             # Deep copy complex attributes
973 3         266 $init{pom_sections} = dclone($self->pom_sections);
974 3         71 $init{tools} = dclone($self->tools);
975 3         8 $init{tool_order} = [ @{ $self->tool_order } ];
  3         16  
976 3         6 $init{hints} = [ @{ $self->hints } ];
  3         13  
977 3         7 $init{pattern_hints} = [ @{ $self->pattern_hints } ];
  3         13  
978 3         33 $init{languages} = dclone($self->languages);
979 3         35 $init{pronunciations} = dclone($self->pronunciations);
980 3         6 $init{params} = { %{ $self->params } };
  3         17  
981 3         33 $init{global_data} = dclone($self->global_data);
982 3         5 $init{native_functions} = [ @{ $self->native_functions } ];
  3         17  
983 3         30 $init{function_includes} = dclone($self->function_includes);
984 3         6 $init{prompt_llm_params} = { %{ $self->prompt_llm_params } };
  3         10  
985 3         6 $init{post_prompt_llm_params} = { %{ $self->post_prompt_llm_params } };
  3         13  
986 3         48 $init{pre_answer_verbs} = dclone($self->pre_answer_verbs);
987 3         30 $init{post_answer_verbs} = dclone($self->post_answer_verbs);
988 3         42 $init{post_ai_verbs} = dclone($self->post_ai_verbs);
989 3         7 $init{answer_config} = { %{ $self->answer_config } };
  3         13  
990 3         5 $init{swaig_query_params} = { %{ $self->swaig_query_params } };
  3         13  
991 3         78 $init{basic_auth_user} = $self->basic_auth_user;
992 3         56 $init{basic_auth_password} = $self->basic_auth_password;
993 3         23 $init{webhook_url} = $self->webhook_url;
994 3         27 $init{post_prompt_url} = $self->post_prompt_url;
995 3         86 $init{proxy_url_base} = $self->proxy_url_base;
996 3 50       24 $init{internal_fillers} = defined $self->internal_fillers
997             ? dclone($self->internal_fillers) : undef;
998 3         13 $init{session_manager} = $self->session_manager;
999              
1000 3         129 my $clone = (ref $self)->new(%init);
1001 3         65 return $clone;
1002             }
1003              
1004             # ---------- run / serve ----------
1005              
1006             sub run {
1007 0     0 0 0 my ($self, %opts) = @_;
1008 0         0 $self->serve(%opts);
1009             }
1010              
1011             sub serve {
1012 0     0 0 0 my ($self, %opts) = @_;
1013 0         0 my $app = $self->psgi_app;
1014 0   0     0 my $host = $opts{host} // $self->host;
1015 0   0     0 my $port = $opts{port} // $self->port;
1016              
1017 0         0 require Plack::Runner;
1018 0         0 my $runner = Plack::Runner->new;
1019 0         0 $runner->parse_options(
1020             '--host' => $host,
1021             '--port' => $port,
1022             '--server' => 'HTTP::Server::PSGI',
1023             );
1024 0         0 $runner->run($app);
1025             }
1026              
1027             # ---------- helpers ----------
1028              
1029             sub _generate_random_password {
1030             # Use /dev/urandom for cryptographically secure random bytes.
1031             # Die on failure rather than falling back to a weak password.
1032 65     65   177 my $bytes = '';
1033 65 50       3047 if (open my $fh, '<:raw', '/dev/urandom') {
1034 65         4315 my $read = read($fh, $bytes, 32);
1035 65         783 close $fh;
1036 65 50 33     454 if (defined $read && $read == 32) {
1037             # Convert to hex string (64 chars)
1038 65         844 return unpack('H*', $bytes);
1039             }
1040             }
1041 0           die "FATAL: Cannot generate secure random password - /dev/urandom unavailable or read failed. "
1042             . "Set SWML_BASIC_AUTH_PASSWORD environment variable instead.\n";
1043             }
1044              
1045             sub extract_sip_username {
1046 0     0 0   my ($class_or_self, $body) = @_;
1047             # Extract SIP username from a request body (hashref).
1048             # Looks in standard SignalWire fields for the SIP caller identity.
1049 0 0         return undef unless ref $body eq 'HASH';
1050              
1051             # Check call.from field (e.g., "sip:user@domain")
1052             my $from = $body->{call}{from}
1053             // $body->{sip_from}
1054             // $body->{from}
1055 0   0       // '';
      0        
      0        
1056              
1057 0 0         if ($from =~ m{^sip:([^@]+)\@}i) {
1058 0           return $1;
1059             }
1060              
1061             # Check for a direct caller_id_number
1062 0 0 0       if (my $cid = $body->{call}{caller_id_number} // $body->{caller_id_number}) {
1063 0           return $cid;
1064             }
1065              
1066 0           return undef;
1067             }
1068              
1069             1;