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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ Responses agent as an

To run the supervisor and ACP child inside Foundry itself, use the
[Hosted Agent v2 package and Kubernetes gateway](docs/foundry-hosted-v2.md).
For AgentKit agents that use Orka tools, configure the
Comment thread
sozercan marked this conversation as resolved.
[AgentKit continuation secret](docs/harness-v2.md#agentkit-tool-workflows) on the
broker and hosted agent.

The adapter calls the Hosted Agent's dedicated Responses endpoint:

Expand Down
96 changes: 96 additions & 0 deletions docs/harness-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ configuration digest, plus:
| `ORKA_FOUNDRY_BROKER_ADDR` | `127.0.0.1:8091`. |
| `ORKA_FOUNDRY_BROKER_STATE_DIR` | Absolute path on the broker-only persistent volume. |
| `ORKA_FOUNDRY_BROKER_BEARER_TOKEN` | At least 32 bytes, from a Kubernetes Secret. |
| `ORKA_FOUNDRY_BROKER_AGENTKIT_CONTINUATION_PROOF` | Optional shared secret for an AgentKit Hosted Agent's governed tool results. See below. |

Only the broker receives Azure Workload Identity or another refreshable
`DefaultAzureCredential` configuration. The initial implementation requires
Expand All @@ -106,6 +107,53 @@ contains remote identifiers and ownership metadata, never prompts, tool
arguments, provider response bodies, Azure tokens, or local bearer tokens.
Do not delete or replace this ledger while it owns remote work.

## AgentKit tool workflows

Configure the hosted AgentKit agent with static `brokeredTools` and
`AGENTKIT_FOUNDRY_BROKERED_MODEL_LOOP=1`. Use an AgentKit version that supports
sequential tool rounds and set `toolSchemaMode` to `provider-static` in the
Foundry configuration. The hosted agent and Orka runtime must use the same
tool names. Each round proposes one tool; Orka executes it and the hosted agent
receives its result before deciding whether to call another tool or answer.

For an AgentKit Hosted Agent configured with brokered tools, give the broker
`ORKA_FOUNDRY_BROKER_AGENTKIT_CONTINUATION_PROOF` and give the hosted AgentKit
process `AGENTKIT_FOUNDRY_BROKERED_CONTINUATION_PROOF` with the same value. Use a
secret of at least 32 bytes, without whitespace or control characters. Keep it
in secret-backed environment variables for those two processes only. Never
put it in `foundry.json`, an image, an Orka Task, the supervisor environment,
or the ACP child environment. Use a separate secret for each deployment pair.

When the agent requests a tool, the ACP child calls Orka's session MCP server.
The broker validates the resulting request against its owner, prompt, lease,
previous response and pending call IDs before returning the result to AgentKit.
It then attaches the secret in the top-level `brokered_continuation_proof` JSON
field. Ordinary prompts do not carry it. The broker rejects proof fields or
proof headers supplied by its caller and never persists the secret in its
ledger or returns it to the ACP child.

AgentKit expects a result envelope with `approved` and either `output` or
`error`. The broker converts Orka's validated MCP result to this format.
An explicit `isError: false` becomes `approved: true` with the text and
structured content preserved under `output`. `isError: true` becomes
`approved: false` with a `brokered_tool_error` code and the validated error
text. Malformed results and missing error flags are rejected. MCP authorization
failures abort the tool call before any result is sent to AgentKit. The
`approved` field is AgentKit's result format; it does not report human approval.
Approval-required tools remain unsupported by this adapter.

This is the existing AgentKit shared-secret contract. It authenticates the
broker's continuation route; it is not a signed execution receipt. The broker's
ownership checks and AgentKit's session, pending-call and replay checks remain
required. The Foundry Responses gateway must preserve the body extension and
deliver it to the hosted wrapper. Local contract tests do not establish that
the deployed Foundry gateway forwards it; verify the configured agent version
before enabling its tools in a live runtime.

Leave the variable unset for other Hosted Agents. Their function output format
and requests remain unchanged. AgentKit must also support repeated tool rounds
for workflows that need several lookups before answering.

## Lifecycle guarantees and limits

The broker persists a random caller-chosen remote session ID before sending
Expand Down Expand Up @@ -135,6 +183,16 @@ remote execution can survive the local container. Preserve unresolved
ownership records for investigation; do not fabricate retirement receipts
or remove finalizers to bypass them.

The broker writes one bounded JSON diagnostic to stderr when a dispatched
response fails. It records the failure stage, outer HTTP status, invocation
sequence, hashed owner, and whether a response acknowledgement was persisted.
An observed terminal frame adds its status and an allowlisted AgentKit error
code. The optional `error.upstream_status` is recorded only as an integer from
400 through 599. Unknown error codes become `unknown`; provider messages,
response bodies, URLs, remote IDs, headers, and credentials are excluded.
These diagnostics leave ownership and cleanup decisions to the existing
durable evidence and settlement checks.

An authenticated drain can replace a surviving supervisor after a controller
epoch change. After a supervisor crash, Orka cannot import the broker's
old-owner proof through the current harness contract. That recovery remains
Expand All @@ -148,3 +206,41 @@ tool allowlists, and blocked output. Broker tests cover durable ownership,
lease cleanup, repeated controls, remote stop/delete proof, and ambiguous
acceptance. Live validation additionally requires the exact configured
Hosted Agent version and Azure identity.

For local tests against an AgentKit source checkout, install its common package
in a Python environment and run the opt-in integration tests:

```sh
export AGENTKIT_SOURCE_DIR=/path/to/agentkit
uv venv /tmp/foundry-agentkit-venv
export AGENTKIT_PYTHON=/tmp/foundry-agentkit-venv/bin/python
uv pip install --python "$AGENTKIT_PYTHON" -e "$AGENTKIT_SOURCE_DIR/runtimes/common"
go test ./internal/broker -run TestBrokerAgentKitHosted -count=1 -v
```

This runs the production hosted AgentKit server and model loop, the Foundry ACP
entrypoint over pipes, and the lifecycle broker. It checks two sequential tools,
tool-error recovery, authorization denial, response identity changes, and proof
isolation. A gateway that strips the proof is also tested to verify that no model
resume occurs. Cancellation cases hold the model connection open, wait for an
early hosted response acknowledgement, and verify that disconnect and lease
expiry close the model connection and allow proven retirement. A gateway that
loses the acknowledgement must leave the broker's ownership unresolved.

To include the native Microsoft Agent Framework path without brokered tools,
install its adapter and select that Python environment as well:

```sh
uv pip install --python "$AGENTKIT_PYTHON" -e "$AGENTKIT_SOURCE_DIR/runtimes/microsoft-agent-framework"
export AGENTKIT_MAF_PYTHON="$AGENTKIT_PYTHON"
go test ./internal/broker -run TestBrokerAgentKitHostedCancellation/native_disconnect -count=1 -v
```

This case uses the real MAF runtime against a held model connection and verifies
that cancellation closes that connection after the broker records the early
response ID. It is skipped when `AGENTKIT_MAF_PYTHON` is unset.

The model, MCP backend, supervisor context stamping, and Azure
session-management API are local fixtures. It requires no Azure or model credentials
and does not validate a deployed Orka controller or the public Foundry gateway.
These tests are skipped when `AGENTKIT_SOURCE_DIR` is unset.
2 changes: 1 addition & 1 deletion internal/acp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func (m *acpMCPClient) execute(ctx context.Context, name string, args json.RawMe
IsError *bool `json:"isError,omitempty"`
StructuredContent json.RawMessage `json:"structuredContent,omitempty"`
}
if strictjson.Decode(result, &reply, false) != nil || reply.Content == nil {
if strictjson.DecodeStruct(result, &reply, false) != nil || reply.Content == nil {
return "", false, errACPMCP
}
for _, content := range reply.Content {
Expand Down
22 changes: 22 additions & 0 deletions internal/acp/mcp_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package acp

import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
Expand All @@ -11,6 +12,27 @@ import (
"github.com/orka-agents/agent-runtime-foundry/internal/foundry"
)

func TestACPToolOutputRejectsConflictingFoldedErrorFlags(t *testing.T) {
for _, flags := range []string{`"isError":true,"ISERROR":false`, `"ISERROR":true,"isError":false`} {
t.Run(flags, func(t *testing.T) {
mcp := &acpTestMCP{tools: func() []map[string]any { return acpTestTools("probe") }}
mcp.execute = func(w http.ResponseWriter, _ *http.Request, id json.RawMessage, _ string, _ json.RawMessage) {
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"tool failed"}],%s}}`, id, flags)
}
var requests atomic.Int32
peer := newACPTestPeer(t, foundry.ToolSchemaModeProviderStatic, func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
acpTestCompleted(w, "tool-response", "", acpTestCall("probe", "call-probe", `{}`))
}, mcp)
acpAssertFailure(t, peer.reply(peer.prompt("use probe")))
if requests.Load() != 1 || mcp.calls.Load() != 1 || acpOutput(peer.events) != "" {
t.Fatal("contradictory MCP error flags were converted into a successful continuation")
}
})
}
}

func TestACPToolOutputForwardsOnlyValidatedModelContent(t *testing.T) {
for _, mode := range []string{"text", "structured", "error"} {
t.Run(mode, func(t *testing.T) {
Expand Down
97 changes: 97 additions & 0 deletions internal/broker/agentkit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package broker

import (
"encoding/json"
"strings"
"unicode"
"unicode/utf8"

"github.com/orka-agents/agent-runtime-foundry/internal/foundry"
"github.com/orka-agents/agent-runtime-foundry/internal/strictjson"
)

const (
brokerAgentKitProofEnv = "ORKA_FOUNDRY_BROKER_AGENTKIT_CONTINUATION_PROOF"
brokerAgentKitProofHeader = "X-AgentKit-Brokered-Continuation-Proof"
)

func brokerAgentKitProofValid(value string) bool {
return value == "" || (len(value) >= 32 && utf8.ValidString(value) && foundry.SafeString(value, 16<<10) && strings.IndexFunc(value, unicode.IsSpace) < 0)
}

// The proof never enters the shared request type used by the ACP child. This
// wire-only extension is added after the broker's owner, lease and call checks.
func (b *lifecycleBroker) marshalResponseRequest(request foundry.ResponseRequest) ([]byte, error) {
proof := ""
if inputs, ok := request.Input.([]any); ok && len(inputs) != 0 {
proof = b.cfg.agentKitProof
}
return json.Marshal(struct {
foundry.ResponseRequest
ContinuationProof string `json:"brokered_continuation_proof,omitempty"`
}{ResponseRequest: request, ContinuationProof: proof})
}

// Orka's MCP proxy explicitly reports isError, with text content and optional
// structured output. Reject other envelopes rather than infer authorization
// from arbitrary tool data. JSON-RPC authorization failures never reach here.
func brokerAgentKitOutputs(request *foundry.ResponseRequest) error {
if _, first := request.Input.(string); first {
return nil
}
inputs, ok := request.Input.([]any)
if !ok || len(inputs) == 0 {
return errBrokerInvalid
}
for _, input := range inputs {
item, ok := input.(map[string]any)
if !ok {
return errBrokerInvalid
}
output, ok := item["output"].(string)
if !ok {
return errBrokerInvalid
}
var result struct {
Content []struct {
Type string `json:"type"`
Text *string `json:"text"`
} `json:"content"`
IsError *bool `json:"isError"`
StructuredContent json.RawMessage `json:"structuredContent,omitempty"`
}
if strictjson.DecodeStruct([]byte(output), &result, true) != nil || result.Content == nil || result.IsError == nil {
return errBrokerInvalid
}
var message strings.Builder
for i, content := range result.Content {
if content.Type != "text" || content.Text == nil {
return errBrokerInvalid
}
if i != 0 {
message.WriteByte('\n')
}
message.WriteString(*content.Text)
}
if len(result.StructuredContent) != 0 && result.StructuredContent[0] != '{' {
return errBrokerInvalid
}
var normalized any
if *result.IsError {
if message.Len() == 0 {
message.WriteString("The governed tool returned an error.")
}
normalized = map[string]any{"approved": false, "error": map[string]string{
"code": "brokered_tool_error", "message": message.String(),
}}
} else {
normalized = map[string]any{"approved": true, "output": result}
}
encoded, err := json.Marshal(normalized)
if err != nil || len(encoded) > foundry.DefaultMaxBrokeredBytes {
return errBrokerInvalid
}
item["output"] = string(encoded)
}
return nil
}
Loading