Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions native/aa-ffi-node/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 7 additions & 6 deletions native/aa-ffi-node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ crate-type = ["cdylib"]
# the agent-assembly workspace; `query_policy` builds a `CheckActionRequest`
# and reads its `CheckActionResponse` directly from these generated types.
#
# Pinned at agent-assembly master 9cde1d83 (AAASM-3396), which adds
# Pinned at the agent-assembly PR #1160 squash-merge commit, which adds the
# `team_id` / `parent_agent_id` fields to `AssemblyConfig` that `register`
# now forwards onto the native gRPC Register (team-budget attribution +
# topology lineage). The base 9cde1d83 (AAASM-3396) added
# `AssemblyClient::register()` (the one direct SDK→gateway gRPC call, per ADR
# 0004) and makes `query_policy()` attach the registration `credential_token`
# so the gateway does not deny a registered agent. That commit also adds the
# `gateway_endpoint` field to `AssemblyConfig`, consumed by `register`.
aa-sdk-client = { git = "https://github.com/ai-agent-assembly/agent-assembly.git", rev = "9cde1d83e4114114bd49c9546d4fa5d86d6cbd32", package = "aa-sdk-client" }
aa-proto = { git = "https://github.com/ai-agent-assembly/agent-assembly.git", rev = "9cde1d83e4114114bd49c9546d4fa5d86d6cbd32", package = "aa-proto" }
# 0004) plus the `gateway_endpoint` field.
aa-sdk-client = { git = "https://github.com/ai-agent-assembly/agent-assembly.git", rev = "fdff8e402e83f01bda3fbbf1f3f7f831b391b62b", package = "aa-sdk-client" }
aa-proto = { git = "https://github.com/ai-agent-assembly/agent-assembly.git", rev = "fdff8e402e83f01bda3fbbf1f3f7f831b391b62b", package = "aa-proto" }
napi = { version = "3", default-features = false, features = ["napi8", "tokio_rt", "serde-json"] }
napi-derive = "3"
prost = "0.14"
Expand Down
7 changes: 7 additions & 0 deletions native/aa-ffi-node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,19 @@ export declare function register(handle: ClientHandle, options: RegisterOptions)
* are descriptive metadata the gateway records. `gatewayEndpoint` overrides the
* gateway gRPC endpoint (default resolved from `AA_GATEWAY_ENDPOINT` or
* `http://127.0.0.1:50051`).
*
* `teamId` and `parentAgentId` carry the agent's lineage/team scoping to the
* gateway on register (AAASM-3415): `teamId` drives team-budget attribution
* and `parentAgentId` the topology graph. Both are optional — omit for a
* team-unscoped / root agent.
*/
export interface RegisterOptions {
agentId: string
name: string
framework: string
gatewayEndpoint?: string
teamId?: string
parentAgentId?: string
}

/**
Expand Down
11 changes: 11 additions & 0 deletions native/aa-ffi-node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ pub async fn connect(socket_path: String) -> Result<ClientHandle> {
agent_id: String::new(),
socket_path: Some(socket_path),
gateway_endpoint: None,
team_id: None,
parent_agent_id: None,
};
let resolved = config.resolve_socket_path();

Expand All @@ -78,12 +80,19 @@ pub async fn connect(socket_path: String) -> Result<ClientHandle> {
/// are descriptive metadata the gateway records. `gatewayEndpoint` overrides the
/// gateway gRPC endpoint (default resolved from `AA_GATEWAY_ENDPOINT` or
/// `http://127.0.0.1:50051`).
///
/// `teamId` and `parentAgentId` carry the agent's lineage/team scoping to the
/// gateway on register (AAASM-3415): `teamId` drives team-budget attribution
/// and `parentAgentId` the topology graph. Both are optional — omit for a
/// team-unscoped / root agent.
#[napi(object)]
pub struct RegisterOptions {
pub agent_id: String,
pub name: String,
pub framework: String,
pub gateway_endpoint: Option<String>,
pub team_id: Option<String>,
pub parent_agent_id: Option<String>,
}

/// Register this agent with the governance gateway and store the issued
Expand All @@ -106,6 +115,8 @@ pub async fn register(handle: &ClientHandle, options: RegisterOptions) -> Result
agent_id: options.agent_id,
socket_path: None,
gateway_endpoint: options.gateway_endpoint,
team_id: options.team_id,
parent_agent_id: options.parent_agent_id,
};

handle
Expand Down
8 changes: 6 additions & 2 deletions src/core/init-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ function buildRegistrationEvent(config: AssemblyConfig): Record<string, string>
* falls back to `agentId`; `framework` is the first detected framework (or
* `"none"` when running without an adapter); `gatewayEndpoint` is set only when
* a gateway URL was resolved so the native default endpoint resolution is
* preserved when it was not.
* preserved when it was not. `teamId` / `parentAgentId` carry the agent's
* team-budget scoping and topology lineage to the gateway (AAASM-3415); each is
* set only when present so an unset field stays absent.
*/
function buildRegisterOptions(
config: AssemblyConfig,
Expand All @@ -72,7 +74,9 @@ function buildRegisterOptions(
agentId,
name: config.name ?? agentId,
framework: frameworks[0] ?? "none",
...(config.gatewayUrl ? { gatewayEndpoint: config.gatewayUrl } : {})
...(config.gatewayUrl ? { gatewayEndpoint: config.gatewayUrl } : {}),
...(config.teamId ? { teamId: config.teamId } : {}),
...(config.parentAgentId ? { parentAgentId: config.parentAgentId } : {})
};
}

Expand Down
7 changes: 7 additions & 0 deletions src/native/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,19 @@ interface NativePolicyDecision {
* Options for the native `register` primitive (AAASM-3400). `agentId` is the
* identity the gateway registers; `name` / `framework` are descriptive
* metadata; `gatewayEndpoint` overrides the gateway gRPC endpoint.
*
* `teamId` and `parentAgentId` carry the agent's lineage/team scoping to the
* gateway on register (AAASM-3415): `teamId` drives team-budget attribution and
* `parentAgentId` the topology graph. Both optional — omit for a team-unscoped
* / root agent.
*/
export interface RegisterOptions {
agentId: string;
name: string;
framework: string;
gatewayEndpoint?: string;
teamId?: string;
parentAgentId?: string;
}

interface NativeBinding {
Expand Down
38 changes: 38 additions & 0 deletions tests/native-register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,44 @@ describe("initAssembly registers the agent (AAASM-3403)", () => {
});
});

it("forwards teamId and parentAgentId to native register (AAASM-3415)", async () => {
const binding = makeBinding();
const { initAssembly } = await loadWithBinding(binding);

const ctx = await initAssembly({
gatewayUrl: "/tmp/aa.sock",
apiKey: "test-key",
agentId: "child-1",
teamId: "team-payments",
parentAgentId: "parent-42",
mode: "napi-inprocess"
});
await ctx.shutdown();

expect(binding.register.mock.calls[0]?.[1]).toMatchObject({
agentId: "child-1",
teamId: "team-payments",
parentAgentId: "parent-42"
});
});

it("omits teamId and parentAgentId when not configured (AAASM-3415)", async () => {
const binding = makeBinding();
const { initAssembly } = await loadWithBinding(binding);

const ctx = await initAssembly({
gatewayUrl: "/tmp/aa.sock",
apiKey: "test-key",
agentId: "root-1",
mode: "napi-inprocess"
});
await ctx.shutdown();

const options = binding.register.mock.calls[0]?.[1];
expect(options).not.toHaveProperty("teamId");
expect(options).not.toHaveProperty("parentAgentId");
});

it("proceeds unregistered when registration fails (fail-open)", async () => {
const binding = makeBinding({
register: vi.fn(async () => {
Expand Down
166 changes: 166 additions & 0 deletions tests/register-lineage-forwarding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { describe, expect, it, vi } from "vitest";

/**
* AAASM-3415 — lineage forwarding.
*
* `initAssembly` builds {@link RegisterOptions} from the SDK config via the
* private `buildRegisterOptions` helper and hands them to the native
* `register(handle, options)` primitive. These tests exercise that mapping
* through the public `initAssembly` entrypoint (the helper is not exported),
* capturing the exact options object forwarded to the native binding so the
* lineage / team-scoping contract is pinned down value-by-value:
*
* - `teamId` -> team-budget attribution
* - `parentAgentId` -> topology lineage edge
*
* Both are optional and must be omitted (absent key, not `undefined`) when the
* config does not carry them, so the native default resolution is preserved.
*/

interface MockBinding {
connect: ReturnType<typeof vi.fn>;
sendEvent: ReturnType<typeof vi.fn>;
queryPolicy: ReturnType<typeof vi.fn>;
register: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
}

function makeBinding(overrides: Partial<MockBinding> = {}): MockBinding {
return {
connect: vi.fn(async () => ({ id: "handle" })),
sendEvent: vi.fn(() => undefined),
queryPolicy: vi.fn(async () => ({ decision: "allow", reason: "" })),
register: vi.fn(async () => "policy-7"),
disconnect: vi.fn(async () => undefined),
...overrides
};
}

async function loadWithBinding(binding: MockBinding) {
vi.resetModules();
vi.doMock("node:module", () => ({
createRequire: () => () => binding
}));
return import("../src/index.js");
}

/** Capture the RegisterOptions forwarded to the native binding on boot. */
async function captureRegisterOptions(
config: Record<string, unknown>
): Promise<Record<string, unknown>> {
const binding = makeBinding();
const { initAssembly } = await loadWithBinding(binding);
const ctx = await initAssembly({
gatewayUrl: "/tmp/aa.sock",
apiKey: "test-key",
mode: "napi-inprocess",
...config
});
await ctx.shutdown();
expect(binding.register).toHaveBeenCalledOnce();
return binding.register.mock.calls[0]?.[1] as Record<string, unknown>;
}

describe("buildRegisterOptions config -> RegisterOptions mapping (AAASM-3415)", () => {
it("maps agentId / name / framework / gatewayEndpoint plus both lineage fields", async () => {
const options = await captureRegisterOptions({
agentId: "agent-7",
name: "Lucky Seven",
teamId: "team-payments",
parentAgentId: "parent-42"
});

// Exact, complete shape — every SDK-config-derived field accounted for.
expect(options).toEqual({
agentId: "agent-7",
name: "Lucky Seven",
framework: "none",
gatewayEndpoint: "/tmp/aa.sock",
teamId: "team-payments",
parentAgentId: "parent-42"
});
});

it("forwards only teamId when parentAgentId is absent (parent omitted)", async () => {
const options = await captureRegisterOptions({
agentId: "agent-team-only",
teamId: "team-research"
});

expect(options.teamId).toBe("team-research");
expect(options).not.toHaveProperty("parentAgentId");
});

it("forwards only parentAgentId when teamId is absent (team omitted)", async () => {
const options = await captureRegisterOptions({
agentId: "agent-child-only",
parentAgentId: "parent-99"
});

expect(options.parentAgentId).toBe("parent-99");
expect(options).not.toHaveProperty("teamId");
});

it("emits neither lineage field and registers cleanly when neither is set", async () => {
const binding = makeBinding();
const { initAssembly } = await loadWithBinding(binding);

const ctx = await initAssembly({
gatewayUrl: "/tmp/aa.sock",
apiKey: "test-key",
agentId: "root-agent",
mode: "napi-inprocess"
});
await ctx.shutdown();

const options = binding.register.mock.calls[0]?.[1] as Record<string, unknown>;
expect(options).not.toHaveProperty("teamId");
expect(options).not.toHaveProperty("parentAgentId");
// Still a well-formed registration — no crash, base fields intact.
expect(options).toMatchObject({
agentId: "root-agent",
name: "root-agent",
framework: "none"
});
});
});

describe("lineage values round-trip to native register unmangled (AAASM-3415)", () => {
it("preserves unicode team / parent identifiers byte-for-byte", async () => {
const teamId = "équipe-paiements-日本語-🚀";
const parentAgentId = "родитель-αβγ-✓";
const options = await captureRegisterOptions({
agentId: "agent-unicode",
teamId,
parentAgentId
});

expect(options.teamId).toBe(teamId);
expect(options.parentAgentId).toBe(parentAgentId);
});

it("preserves a very long parentAgentId without truncation", async () => {
const parentAgentId = `parent-${"a".repeat(4096)}`;
const options = await captureRegisterOptions({
agentId: "agent-long",
teamId: "team-x",
parentAgentId
});

expect(options.parentAgentId).toBe(parentAgentId);
expect(String(options.parentAgentId)).toHaveLength(parentAgentId.length);
});

it("treats an empty-string teamId as unset (falsy -> omitted, no empty forward)", async () => {
// buildRegisterOptions guards each lineage field with a truthiness check, so
// an empty string never reaches the gateway as a spurious empty team scope.
const options = await captureRegisterOptions({
agentId: "agent-empty-team",
teamId: "",
parentAgentId: "parent-real"
});

expect(options).not.toHaveProperty("teamId");
expect(options.parentAgentId).toBe("parent-real");
});
});
Loading