Claude-Agent
view release on metacpan or search on metacpan
lib/Claude/Agent/MCP/SDKRunner.pm view on Meta::CPAN
jsonrpc => '2.0',
id => $id,
error => {
code => -32601,
message => "Method not found: $safe_method",
},
};
}
sub _generate_uuid {
# Generate a UUID v4-like string to avoid request ID collisions
# Uses cryptographically secure random bytes for robust uniqueness
require Crypt::URandom;
my $random_bytes = Crypt::URandom::urandom(16);
my $uuid = unpack('H*', $random_bytes);
$uuid =~ s/(.{8})(.{4})(.{4})(.{4})(.{12})/$1-$2-$3-$4-$5/;
return $uuid;
}
sub call_parent_handler {
my ($tool_name, $args) = @_;
# Use UUID-based request IDs to eliminate any possibility of ID collision
# even if async/threaded handling is added in the future
my $request_id = _generate_uuid();
# Send request to parent via async stream
my $request = $state->{jsonl}->encode([{
id => $request_id,
tool => $tool_name,
args => $args,
}]);
$log->debug(sprintf("SDKRunner: Sending request to parent id=%s", $request_id));
$log->trace(sprintf("SDKRunner: Request payload: %s", $request));
$state->{socket_stream}->write($request);
# Reset response flag before waiting
$state->{got_response} = 0;
# Wait for response with configurable timeout using actual elapsed time
require Time::HiRes;
my $timeout = $ENV{CLAUDE_AGENT_TOOL_TIMEOUT} // 60;
my $max_timeout = 300;
# Ensure numeric value between 1-300 seconds (1 sec to 5 minutes)
# Non-numeric, empty, zero, or out-of-range values fall back to 60s default
# Security note: Lower maximum (300s) prevents resource exhaustion attacks.
# For operations requiring longer timeouts, consider breaking them into smaller steps.
if (!defined $timeout || $timeout !~ /^\d+$/ || $timeout < 1) {
$timeout = 60;
} elsif ($timeout > $max_timeout) {
$log->warning(sprintf("CLAUDE_AGENT_TOOL_TIMEOUT=%d exceeds maximum (%d seconds), capping to %d seconds",
$timeout, $max_timeout, $max_timeout));
$timeout = $max_timeout;
}
my $start_time = Time::HiRes::time();
my $backoff = 0.1;
my $last_buffer_size = length($state->{response_buffer});
my $stall_count = 0;
my $max_stall_iterations = 100; # ~10 seconds at max backoff before declaring stall
while (!$state->{got_response}) {
# Check elapsed time before loop_once to ensure accurate timeout enforcement
my $elapsed = Time::HiRes::time() - $start_time;
last if $elapsed >= $timeout;
$state->{loop}->loop_once($backoff);
$backoff = $backoff * 1.5 if $backoff < 1.0; # Exponential backoff up to 1 second
# Detect buffer growth without complete JSON lines (malformed/incomplete data)
# Use tiered limits to detect issues early before memory spikes
my $current_buffer_size = length($state->{response_buffer});
my $warn_buffer_size = 5_000_000; # 5MB warning threshold
my $max_buffer_size = 10_000_000; # 10MB hard limit
if ($current_buffer_size > $max_buffer_size) {
$log->debug(sprintf("SDKRunner: Buffer overflow (size: %d bytes), aborting", $current_buffer_size));
# Clear buffer to reclaim memory before returning error
$state->{response_buffer} = '';
last;
}
elsif ($current_buffer_size > $warn_buffer_size) {
# Log warning at 5MB to alert before hitting hard limit
$log->debug(sprintf("SDKRunner: Buffer approaching limit (size: %d bytes, limit: %d)",
$current_buffer_size, $max_buffer_size));
}
if ($current_buffer_size > 0 && $current_buffer_size == $last_buffer_size) {
$stall_count++;
if ($stall_count >= $max_stall_iterations) {
$log->debug(sprintf("SDKRunner: Buffer stalled with incomplete data (size: %d)",
$current_buffer_size));
last;
}
} elsif ($current_buffer_size != $last_buffer_size) {
# Buffer changed - reset stall counter but don't reset backoff
$stall_count = 0;
$last_buffer_size = $current_buffer_size;
}
# Re-check elapsed time after loop_once in case it took longer than expected
last if (Time::HiRes::time() - $start_time) >= $timeout;
}
# Extract the response line from buffer, matching by request ID
my $response_line;
# First check if we have a pending response for this request ID (from previous calls)
# NOTE: pending_responses is initialized in reset_state() which is called at start of run().
# Always verify initialization to catch bugs early - uninitialized state indicates
# reset_state() was not called properly, which is a programming error.
if (!defined $state->{pending_responses}) {
require Carp;
Carp::croak("BUG: pending_responses not initialized - reset_state() was not called properly. "
. "This is a programming error that must be fixed.");
}
if (exists $state->{pending_responses}{$request_id}) {
my $pending = delete $state->{pending_responses}{$request_id};
$response_line = $pending->{line};
}
else {
# Parse all complete lines and find matching response by ID
# Store unmatched responses in a hash keyed by request ID for efficient lookup
while ($state->{response_buffer} =~ s/^(.+)\n//) {
my $line = $1;
my ($resp, $parse_err);
try {
($resp) = $state->{jsonl}->decode($line);
} catch {
$parse_err = $_;
};
if ($parse_err || !$resp) {
# Log unparseable lines at debug level to aid troubleshooting
$log->debug(sprintf("SDKRunner: Failed to parse buffered line (discarding): %s", $line));
next;
}
if ($resp->{id} && $resp->{id} eq $request_id) {
$response_line = $line;
last;
}
# Store unmatched responses in hash keyed by ID for later retrieval
# This avoids buffer corruption from re-joining partial data
if ($resp->{id}) {
$state->{pending_responses}{$resp->{id}} = { line => $line, resp => $resp };
}
}
}
# Reset flag if no more complete lines
$state->{got_response} = 0 unless $state->{response_buffer} =~ /\n/;
unless ($response_line) {
( run in 0.998 second using v1.01-cache-2.11-cpan-5a3173703d6 )