Skip to content

feat(managed-agents-adapter): implement ManagedAgentsSession adapter #6

Description

@dangazineu

Goal

Add packages/managed-agents-adapter/ to codespar-core. This package bridges the Anthropic Managed Agents SDK to the SessionBase interface defined in @codespar/session-contract (issue #2). Callers typed against SessionBase work unchanged regardless of which runtime backs the session.

This is a pre-GA implementation. The Anthropic Managed Agents API is in beta; the adapter stubs the AgentRuntime type locally and will switch to the official SDK export at GA without changing the public surface.


Context

SessionBase (delivered in issue #2) is a runtime-agnostic interface for agent sessions: execute, send, sendStream, connections, close. This adapter provides the Managed Agents-backed implementation of that interface.

The adapter is an internal package — it does not bump @codespar/session-contract when updated. Its only public export is createManagedAgentsSession plus the five error classes.

Commerce agents call tools with financial consequence (Pix transfers, NF-e issuance). The adapter enforces two non-optional safety properties before any tool execution reaches the Managed Agents API:

  1. Tool name validation — rejects any name that does not match /^[a-zA-Z0-9_-]+$/. Whitespace or newline characters in a tool name would be serialized into the synthesized JSON message and could inject instructions into the runtime.
  2. Policy evaluation order — the PolicyHook runs on the original params before sanitizeParams transforms them. Reversing the order would let a caller strip an amount field and bypass the fund-transfer cap check.

A mutex guards each state-mutating method (execute, send, sendStream). The mutex is reset in a finally block in all three paths. Without the finally, a single rejected operation permanently blocks the mutex and every subsequent call hangs.

DrainTimeoutError means the session state is indeterminate. The adapter documents in JSDoc that callers must not automatically retry commerce tools after this error — the tool call may or may not have executed on the remote side.


Acceptance Criteria

Package scaffold

  • packages/managed-agents-adapter/package.json sets name: "@codespar/managed-agents-adapter", version: "0.1.0", and lists @codespar/session-contract as a peer dependency.
  • packages/managed-agents-adapter/tsconfig.json extends the root tsconfig with strict: true.
  • All exports are re-exported from src/index.ts.

AgentRuntime stub

  • src/session.ts defines a local AgentRuntime interface with createSession, sendMessage, streamEvents, and getStatus methods when the official SDK does not export a stable TypeScript type.
  • A code comment marks the stub as pre-GA and names the import path to switch to at GA.

createManagedAgentsSession

  • Accepts runtime: AgentRuntime, config: { agentId: string; environmentId: string }, and optional options: { policyHook?, sanitizeParams?, drainTimeoutMs? }.
  • Calls runtime.createSession(config) and wraps the returned session ID in a ManagedAgentsSession instance.
  • Returns Promise<SessionBase>.

ManagedAgentsSessionexecute(toolName, params)

  • Throws ConcurrentOperationError immediately if _activeMutex is set.
  • Throws InvalidToolNameError if toolName does not match /^[a-zA-Z0-9_-]+$/.
  • Calls policyHook.evaluate(agentId, toolName) before sanitizeParams when both are provided. Throws PolicyViolationError if decision.allowed is false. Throws ApprovalRequiredError if decision.requiresApproval is true.
  • Applies sanitizeParams to params after the policy check.
  • Acquires the mutex, calls runtime.sendMessage, drains via _drainForToolResult, and resets _activeMutex = null in a finally block.
  • _drainForToolResult skips events where typeof raw.type !== "string". Throws DrainTimeoutError when Date.now() exceeds the deadline. Returns a ToolResult on raw.type === "tool_result". Throws on raw.type === "done" without a result.
  • JSDoc on execute states that callers must not automatically retry after DrainTimeoutError because the remote operation may have already executed.

ManagedAgentsSessionsend(message)

  • Throws ConcurrentOperationError if _activeMutex is set.
  • Acquires the mutex, calls runtime.sendMessage, drains via _drainForSendResult, and resets _activeMutex = null in a finally block.
  • Returns Promise<SendResult>.

ManagedAgentsSessionsendStream(message)

  • Throws ConcurrentOperationError if _activeMutex is set.
  • Acquires the mutex, calls runtime.sendMessage, yields mapped StreamEvent values, breaks on raw.type === "done", and resets _activeMutex = null in a finally block.
  • Skips events where typeof raw.type !== "string".
  • Returns AsyncIterable<StreamEvent>.

ManagedAgentsSessionconnections() and close()

  • connections() returns [{ id: sessionId, connected: status === "active" }].
  • close() sets _status = "closed" and awaits the active mutex (if any) before returning.

Error classes (src/errors.ts)

  • InvalidToolNameError — carries toolName: string.
  • PolicyViolationError — carries decision: PolicyDecision.
  • ApprovalRequiredError — carries decision: PolicyDecision.
  • ConcurrentOperationError — message: "session already has an operation in progress".
  • DrainTimeoutError — carries timeoutMs: number.
  • All five extend Error with correct name properties set in constructors.

Implements SessionBase

  • TypeScript compiles without error when ManagedAgentsSession is assigned to a variable typed as SessionBase.

Validation

Run from the repository root after implementing:

#!/usr/bin/env bash
set -euo pipefail

ADAPTER="packages/managed-agents-adapter"

echo "==> Build check"
npx tsc --project "$ADAPTER/tsconfig.json" --noEmit

echo "==> SessionBase assignability check"
# Create a temporary type-check file and delete it after
TMPFILE=$(mktemp /tmp/session-check-XXXXXX.ts)
cat > "$TMPFILE" <<'TS'
import type { SessionBase } from "@codespar/session-contract";
import { createManagedAgentsSession } from "@codespar/managed-agents-adapter";

// If ManagedAgentsSession does not implement SessionBase, this assignment fails at compile time.
const _check: () => Promise<SessionBase> = () =>
createManagedAgentsSession(
{} as any,
{ agentId: "a", environmentId: "e" }
);
TS
npx tsc --strict --noEmit --esModuleInterop "$TMPFILE" || (rm "$TMPFILE"; exit 1)
rm "$TMPFILE"
echo "  SessionBase assignability: PASS"

echo "==> Unit: InvalidToolNameError thrown for newline in tool name"
node --input-type=module <<'JS'
import { createManagedAgentsSession } from "./packages/managed-agents-adapter/src/index.js";
import { InvalidToolNameError } from "./packages/managed-agents-adapter/src/errors.js";

const mockRuntime = {
createSession: async () => "session-1",
sendMessage: async () => {},
streamEvents: async function*() {},
getStatus: async () => ({ state: "active" }),
};

const session = await createManagedAgentsSession(mockRuntime, { agentId: "a", environmentId: "e" });

try {
await session.execute("tool\nname", {});
console.error("FAIL: expected InvalidToolNameError");
process.exit(1);
} catch (err) {
if (err instanceof InvalidToolNameError) {
console.log("  InvalidToolNameError for newline: PASS");
} else {
console.error("FAIL: wrong error type", err);
process.exit(1);
}
}
JS

echo "==> Unit: ConcurrentOperationError thrown for overlapping calls"
node --input-type=module <<'JS'
import { createManagedAgentsSession } from "./packages/managed-agents-adapter/src/index.js";
import { ConcurrentOperationError } from "./packages/managed-agents-adapter/src/errors.js";

let resolveStream;
const mockRuntime = {
createSession: async () => "session-1",
sendMessage: async () => {},
streamEvents: async function*() {
// Hang until test resolves
await new Promise(r => { resolveStream = r; });
yield { type: "done" };
},
getStatus: async () => ({ state: "active" }),
};

const session = await createManagedAgentsSession(mockRuntime, { agentId: "a", environmentId: "e" });

// Start a send that will block in streamEvents
const first = session.send("hello");

try {
await session.send("concurrent");
console.error("FAIL: expected ConcurrentOperationError");
resolveStream?.();
process.exit(1);
} catch (err) {
if (err instanceof ConcurrentOperationError) {
console.log("  ConcurrentOperationError for concurrent calls: PASS");
} else {
console.error("FAIL: wrong error type", err);
resolveStream?.();
process.exit(1);
}
}
resolveStream?.();
await first.catch(() => {});
JS

echo "==> Unit: mutex resets after rejection"
node --input-type=module <<'JS'
import { createManagedAgentsSession } from "./packages/managed-agents-adapter/src/index.js";

const mockRuntime = {
createSession: async () => "session-1",
sendMessage: async () => { throw new Error("network error"); },
streamEvents: async function*() {},
getStatus: async () => ({ state: "active" }),
};

const session = await createManagedAgentsSession(mockRuntime, { agentId: "a", environmentId: "e" });

// First call should reject
await session.send("fail").catch(() => {});

// Second call must not throw ConcurrentOperationError — mutex must have been reset
try {
await session.send("retry").catch(() => {}); // may reject again (network), but not with concurrent error
console.log("  Mutex resets after rejection: PASS");
} catch (err) {
if (err?.constructor?.name === "ConcurrentOperationError") {
console.error("FAIL: mutex was not reset after first rejection");
process.exit(1);
}
console.log("  Mutex resets after rejection: PASS (other error expected)");
}
JS

echo ""
echo "All validation checks passed."

Dependencies


Downstream Dependencies

None. This is a leaf node in the milestone dependency graph.


Security Checklist

  • Tool name injection guardexecute() rejects any toolName that does not match /^[a-zA-Z0-9_-]+$/ before constructing or sending any message. The check runs before policy evaluation. Whitespace or newline characters in a tool name would be embedded verbatim in the JSON payload sent to the Managed Agents API.
  • PolicyHook evaluation orderpolicyHook.evaluate(agentId, toolName) runs on the original params before sanitizeParams is called. Sanitizing first could strip fields (such as amount) that the policy hook uses to enforce fund-transfer caps. The ordering is documented in a code comment at the call site.
  • PolicyHook call signature — uses positional args evaluate(agentId, toolName), not an object argument. Reads decision.allowed (boolean) and decision.requiresApproval (boolean | undefined). Does not reference decision.outcome or any other field.
  • Mutex try/finally in all three methodsexecute, send, and sendStream each set _activeMutex = null inside a finally block. This is verified: a single rejected operation must not permanently block the mutex.
  • AgentEvent type validation before consumption_drainForToolResult and sendStream skip events where typeof raw.type !== "string". A malformed event type would produce undefined in the mapped result, making a failed tool call appear successful to the caller.
  • DrainTimeoutError no-retry documentation — JSDoc on execute() states explicitly that callers must not automatically retry commerce tools (Pix transfers, NF-e issuance) after DrainTimeoutError. The remote operation may have already executed; retrying risks a duplicate transaction.
  • PII in params — params passed to execute() may contain PII (CPF, CNPJ, Pix amounts, account details). The sanitizeParams option provides adapter-level masking as a last-resort fallback; the commerce-governance layer must apply data classification upstream of this adapter before params reach it. This is documented in JSDoc on createManagedAgentsSession.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    f4-m1Feature 4, Milestone 1: Shared Session Contract Packagevalidation:criticalCritical validation: security review required

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions