diff --git a/native/aa-ffi-node/Cargo.lock b/native/aa-ffi-node/Cargo.lock index 0ae8cb0f..108e0633 100644 --- a/native/aa-ffi-node/Cargo.lock +++ b/native/aa-ffi-node/Cargo.lock @@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "aa-proto" version = "0.0.1-beta.2" -source = "git+https://github.com/ai-agent-assembly/agent-assembly.git?rev=9cde1d83e4114114bd49c9546d4fa5d86d6cbd32#9cde1d83e4114114bd49c9546d4fa5d86d6cbd32" +source = "git+https://github.com/ai-agent-assembly/agent-assembly.git?rev=fdff8e402e83f01bda3fbbf1f3f7f831b391b62b#fdff8e402e83f01bda3fbbf1f3f7f831b391b62b" dependencies = [ "prost", "tonic", @@ -30,7 +30,7 @@ dependencies = [ [[package]] name = "aa-sdk-client" version = "0.0.1-beta.2" -source = "git+https://github.com/ai-agent-assembly/agent-assembly.git?rev=9cde1d83e4114114bd49c9546d4fa5d86d6cbd32#9cde1d83e4114114bd49c9546d4fa5d86d6cbd32" +source = "git+https://github.com/ai-agent-assembly/agent-assembly.git?rev=fdff8e402e83f01bda3fbbf1f3f7f831b391b62b#fdff8e402e83f01bda3fbbf1f3f7f831b391b62b" dependencies = [ "aa-proto", "aa-security", @@ -47,7 +47,7 @@ dependencies = [ [[package]] name = "aa-security" version = "0.0.1-beta.2" -source = "git+https://github.com/ai-agent-assembly/agent-assembly.git?rev=9cde1d83e4114114bd49c9546d4fa5d86d6cbd32#9cde1d83e4114114bd49c9546d4fa5d86d6cbd32" +source = "git+https://github.com/ai-agent-assembly/agent-assembly.git?rev=fdff8e402e83f01bda3fbbf1f3f7f831b391b62b#fdff8e402e83f01bda3fbbf1f3f7f831b391b62b" dependencies = [ "aho-corasick", ] diff --git a/native/aa-ffi-node/Cargo.toml b/native/aa-ffi-node/Cargo.toml index 8391bb2d..a2e3d6e2 100644 --- a/native/aa-ffi-node/Cargo.toml +++ b/native/aa-ffi-node/Cargo.toml @@ -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" diff --git a/native/aa-ffi-node/index.d.ts b/native/aa-ffi-node/index.d.ts index 0c28654b..17e47552 100644 --- a/native/aa-ffi-node/index.d.ts +++ b/native/aa-ffi-node/index.d.ts @@ -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 } /** diff --git a/native/aa-ffi-node/src/lib.rs b/native/aa-ffi-node/src/lib.rs index 7b4f0b1f..6946da48 100644 --- a/native/aa-ffi-node/src/lib.rs +++ b/native/aa-ffi-node/src/lib.rs @@ -59,6 +59,8 @@ pub async fn connect(socket_path: String) -> Result { 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(); @@ -78,12 +80,19 @@ pub async fn connect(socket_path: String) -> Result { /// 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, + pub team_id: Option, + pub parent_agent_id: Option, } /// Register this agent with the governance gateway and store the issued @@ -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 diff --git a/src/core/init-assembly.ts b/src/core/init-assembly.ts index 92c17aca..1f885281 100644 --- a/src/core/init-assembly.ts +++ b/src/core/init-assembly.ts @@ -61,7 +61,9 @@ function buildRegistrationEvent(config: AssemblyConfig): Record * 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, @@ -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 } : {}) }; } diff --git a/src/native/client.ts b/src/native/client.ts index c2425b56..273b1125 100644 --- a/src/native/client.ts +++ b/src/native/client.ts @@ -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 { diff --git a/tests/native-register.test.ts b/tests/native-register.test.ts index 3f10a8f1..df7fb219 100644 --- a/tests/native-register.test.ts +++ b/tests/native-register.test.ts @@ -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 () => { diff --git a/tests/register-lineage-forwarding.test.ts b/tests/register-lineage-forwarding.test.ts new file mode 100644 index 00000000..1851c941 --- /dev/null +++ b/tests/register-lineage-forwarding.test.ts @@ -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; + sendEvent: ReturnType; + queryPolicy: ReturnType; + register: ReturnType; + disconnect: ReturnType; +} + +function makeBinding(overrides: Partial = {}): 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 +): Promise> { + 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; +} + +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; + 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"); + }); +});