Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
75540d5
feat(runtime): add Orka harness v2 ACP mode
sozercan Sep 3, 2026
d110965
fix(runtime): preserve ACP prompt commit boundary
sozercan Sep 3, 2026
d0bde57
fix(acp): drain oversized stdio frames
sozercan Sep 3, 2026
d8d4167
fix(acp): chunk assistant output frames
sozercan Sep 3, 2026
b0a763d
docs(orka): document adapter digest
sozercan Sep 3, 2026
9abf37f
docs(orka): bind adapter identity to source image
sozercan Sep 4, 2026
8e4116d
fix(acp): verify parsed config bytes atomically
sozercan Sep 4, 2026
3a4ae0f
docs(orka): document controller epoch rotation
sozercan Sep 4, 2026
86563f9
fix(runtime): scope failed session rollback to ACP
sozercan Sep 4, 2026
700e6c8
chore(runtime): clarify ACP auth value naming
sozercan Sep 4, 2026
37d00de
fix(runtime): complete ACP v1 interoperability
sozercan Sep 4, 2026
82671b5
fix(acp): keep cancellation responsive under backpressure
sozercan Sep 4, 2026
50a7529
docs(orka): clarify harness v2 runtime policy
sozercan Sep 4, 2026
7ada12e
test(acp): cover canceled stdio write ownership
sozercan Sep 4, 2026
80d10b7
fix(runtime): accept Unicode model names
sozercan Sep 4, 2026
76e3d5f
fix(runtime): pin Foundry responses SDK contract
sozercan Sep 4, 2026
f8bfda4
test(acp): cache recorded stdio messages
sozercan Sep 4, 2026
56d88e0
fix(runtime): report tool outcomes and fail closed on MCP errors
sozercan Sep 5, 2026
c9a1807
fix(runtime): harden provider failures and v2 cancellation
sozercan Sep 5, 2026
b8685f4
ci: use latest Vekil image for live E2E
sozercan Sep 10, 2026
ffb456f
fix(ci): report Vekil readiness failures
sozercan Sep 10, 2026
8608924
ci: test live Copilot E2E with Vekil v0.14.1
sozercan Sep 10, 2026
e2433f7
ci: update Vekil to v0.14.3
sozercan Sep 11, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ jobs:
timeout-minutes: 75
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
VEKIL_IMAGE: ghcr.io/sozercan/vekil@sha256:d13edeedf7bec319da8eb3ea4949a4d0802e244c14765a347e62e1b8b7be8e3d
VEKIL_IMAGE: ghcr.io/sozercan/vekil:v0.14.3@sha256:996b628fbe8c7a35d33e9d6bb855f2613228fc5c9b09498dae6ea6b208a0071b
steps:
- name: Decide whether live Vekil/Copilot E2E can run
id: gate
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ The image also exposes:

## Select a protocol surface

AgentKit builds one custom-agent image and selects the HTTP protocol at runtime:
AgentKit builds one custom-agent image and selects the protocol at runtime:

```sh
# default standalone OpenAI-compatible surface
Expand All @@ -78,6 +78,10 @@ docker run \
-e AGENTKIT_PROTOCOL=orka \
-e AGENTKIT_AUTH_TOKEN=dev-token \
... image

# ACP stdio child for an Orka harness v2 supervisor. The supervisor supplies
# the provider proxy, MCP broker, model, and image-bound configuration digest.
agentkit-serve --config /agent/agent.yaml --protocol acp
```

Protocol endpoints:
Expand All @@ -87,6 +91,7 @@ Protocol endpoints:
| `openai` | `/healthz`, `/v1/models`, `/v1/chat/completions` | Default, non-streaming Chat Completions. |
| `foundry` | `/readiness`, `/invocations`, `/responses` | `/responses` is `foundry-responses-minimal`: synchronous/non-streaming only. |
| `orka` | `/v1/health`, `/v1/capabilities`, `/v1/turns`, `/v1/turns/{turnID}/events`, `/v1/turns/{turnID}/continue`, `/v1/turns/{turnID}/cancel` | Observed-mode `orka.harness.v1` over HTTP+SSE by default. AgentKit reports frames; Orka enforces policy. Brokered read/write/coordination are feature-gated for conformance. |
| `acp` | stdin/stdout | ACP protocol v1 child mode for Orka `orka.harness.v2`. It opens no network listener and accepts only the supervisor's loopback provider proxy and prompt-scoped HTTP MCP server. |

After deploying the image with `AGENTKIT_PROTOCOL=orka` and an
`AGENTKIT_AUTH_TOKEN` sourced from the Orka client-auth Secret, render an Orka
Expand Down Expand Up @@ -119,6 +124,12 @@ fields (`runtimeSessionID`, `turnID`, `correlationID`), `createdAt`,
`contentText` for runtime output, and `completed` / `failed` terminal payloads.
See [`docs/orka.md`](docs/orka.md) for complete request/response examples.

For harness v2, layer Orka's supervisor onto a digest-pinned AgentKit image and
register the resulting service as a strict-governed external `AgentRuntime`.
The supervisor starts `agentkit-serve --protocol acp` as an isolated child and
keeps provider, MCP, workspace, permission, and publication authority outside
AgentKit. See [`docs/orka.md`](docs/orka.md) for the composition contract.

Note: this repository's `agentkit-serve` runtime is distinct from OpenAI's public
AgentKit/Agents SDK product surface unless a future adapter explicitly targets it.

Expand Down
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ live model provider.

The optional live job runs `scripts/live-copilot-agent-e2e.sh` when
`COPILOT_GITHUB_TOKEN` is available and the run is allowed to access repository
secrets. It uses the pinned `ghcr.io/sozercan/vekil` image to provide an
secrets. It uses `ghcr.io/sozercan/vekil:v0.14.3`, pinned by digest, to provide an
OpenAI-compatible endpoint and validates a real built AgentKit container through
`/v1/chat/completions`.

Expand Down
96 changes: 92 additions & 4 deletions docs/orka.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,91 @@
# Register an AgentKit image with Orka
# Use an AgentKit image with Orka

AgentKit supports two distinct Orka integrations. New BYO deployments should
use `orka.harness.v2`: Orka's hardened supervisor runs AgentKit as an ACP stdio
child. The older `orka.harness.v1` mode remains an observed HTTP+SSE adapter.

## Harness v2 BYO runtime

Build the AgentKit agent image first and address it by digest. In an Orka
checkout, layer the v2 supervisor onto that immutable image:

```sh
make docker-build-acp-agentkit-runtime \
AGENTKIT_RUNTIME_IMAGE=ghcr.io/acme/fibey@sha256:<agentkit-image-digest> \
AGENTKIT_ADAPTER_DIGEST=sha256:<agentkit-image-digest> \
ACP_AGENTKIT_RUNTIME_IMG=ghcr.io/acme/fibey-orka-v2:dev
```

The composed image runs `orka-acp-runtime`. For each RuntimeSession, the
supervisor starts this child under a private UID/GID and session tree:

```sh
/opt/agentkit/bin/agentkit-serve \
--config /agent/agent.yaml \
--protocol acp
```

The v2 path is strict:

- `/agent/agent.yaml` must not contain direct `tools`, `brokeredTools`, or
context providers;
- the registered model must equal `model.name` in the baked config;
- `agentConfigurationDigest` is `sha256:` plus the SHA-256 of the exact
`/agent/agent.yaml` bytes;
- the runtime advertises only `agentkit-serve-acp`, with the digest-pinned
AgentKit source image identity as its adapter digest;
- Orka sends `AgentConfiguration: null`; the image-bound config is authoritative;
- provider calls use the supervisor's loopback proxy, and tools use its one
prompt-scoped loopback HTTP MCP server;
- the child retains successful user/assistant history for Session continuation
and discards cancelled or failed prompt history.

Deploy the composed image as an operator-owned v2 supervisor service, configure
the standard `ORKA_ACP_*` profile, fence, token-file, and runtime identity
settings, then register it with Orka's strict-governed `AgentRuntime` sample.
The profile model and `agentConfigurationDigest` must match the baked AgentKit
config. The registration's `adapterName` must be `agentkit-serve-acp`. Its
`adapterDigest` and the composition build's `AGENTKIT_ADAPTER_DIGEST` must both
equal the `sha256:` digest from `AGENTKIT_RUNTIME_IMAGE`. Set the profile's
`providerKind` to `agentkit` and advertise
`supportsAgentSessionConfiguration: false`. `approvalRequiredTools` must stay
empty because the AgentKit ACP child does not implement permission callbacks.
If the registration allows brokered tools, the Task must submit that exact
`allowedTools` list.

Set `ORKA_ACP_CONTROLLER_EPOCH` from Orka's current `ControllerEpoch` record.
Select by `spec.name` because the resource name is hashed. This lookup requires
exactly one matching record with a positive integer epoch:

```sh
kubectl -n <orka-controller-namespace> get cepoch -o json |
jq -er '
[.items[] | select(.spec.name == "orka-controller") | .status.epoch] |
if length == 1 then
.[0] | select(type == "number") | select(. > 0 and . == floor)
else
error("expected exactly one ControllerEpoch for orka-controller")
end'
```

The supervisor reads the epoch only during startup. The operator that owns this
service must watch the record and restart or replace the supervisor whenever it
changes. Preserve `ORKA_ACP_RUNTIME_INSTANCE_ID` across that restart and issue a
new `ORKA_ACP_SUPERVISOR_BOOT_ID`. Orka keeps a stale-epoch registration not
ready and refuses new Task bindings until authenticated status reports the
current value. AgentKit itself is the ACP child and does not manage this fence.

Orka freezes the AgentRuntime UID, generation, endpoint, profile, authentication
Secret versions, and observed instance into each Task binding. It revalidates
them before dispatch and recovery mutations. `Task.spec.execution.workspace`
is not supported for external runtimes; repository input still uses
`Task.spec.workspace`.

See Orka's `website/docs/guides/bring-your-own-agent-runtime.md` and
`config/samples/core_v1alpha1_agentruntime.yaml` for the registration and
authentication contract.

## Harness v1 observed mode

AgentKit images can expose observed-mode `orka.harness.v1` without rebuilding the
agent. Start the same image with Orka mode enabled:
Expand Down Expand Up @@ -240,10 +327,11 @@ For deeper local validation, run the common Python Orka protocol tests:
uv run --directory runtimes/common --extra dev pytest -q tests/test_orka_protocol.py
```

## Render an AgentRuntime manifest
## Render a harness v1 AgentRuntime manifest

The current Orka `AgentRuntime` CRD supports external endpoints first. Deploy the
AgentKit image yourself (for example as a Kubernetes Deployment/Service) with:
The AgentKit renderer currently emits the harness v1 registration shape. Deploy
the AgentKit image yourself, for example as a Kubernetes Deployment/Service,
with:

- `AGENTKIT_PROTOCOL=orka`
- `AGENTKIT_BIND=0.0.0.0` so the Kubernetes Service can reach the harness outside
Expand Down
19 changes: 16 additions & 3 deletions docs/runtime-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ must be identical across adapters:
| Module | Responsibility |
|---|---|
| `config.py` | Strict `/agent/agent.yaml` reader and ABI version check. |
| `cli.py` | `agentkit-serve --config ... --protocol openai\|foundry\|orka`, bind/port handling, auth startup gates. |
| `cli.py` | `agentkit-serve --config ... --protocol openai\|foundry\|orka\|acp`, bind/port handling, auth startup gates. |
| `server.py` | FastAPI app and OpenAI-compatible response/error envelopes. |
| `foundry.py` | Foundry `/readiness`, `/invocations`, and minimal `/responses` skin. |
| `orka.py` | Observed-mode `orka.harness.v1` HTTP+SSE skin. |
| `acp.py` | Strict ACP stdio child for an Orka `orka.harness.v2` supervisor. |
| `conversation.py` | Protocol request normalization into `RunRequest`. |
| `runtime.py` | `RuntimeFactory`, `RuntimeSession`, `RunResult`, `AgentRunError`. |
| `adapter_support.py` | API-key lookup, tool env projection, timeout parsing, error normalization. |
Expand All @@ -26,7 +27,7 @@ The protocol app factories receive an adapter module that satisfies
`RuntimeSession.run(request)`, so it never imports pydantic-ai, Microsoft Agent
Framework, LangChain, OpenAI SDK types, Azure, Foundry SDKs, or Orka controllers.

## HTTP surface
## Protocol surfaces

All adapters can serve the same selected protocol surface. `openai` is the
default. `foundry` and `orka` are selected with `--protocol` or
Expand All @@ -45,6 +46,13 @@ port; generated images expose both `8080` and `8088` in OCI metadata for that
case. Orka mode exposes `orka.harness.v1` health, capabilities, turn
acceptance, SSE replay, and cancel endpoints.

ACP mode opens no listener. It speaks newline-delimited ACP JSON-RPC on stdin
and stdout. The child verifies the configured model and SHA-256 digest of the
exact `/agent/agent.yaml` bytes before accepting a session. It rejects baked
direct tools, `brokeredTools`, and context providers. At session creation it
accepts at most one loopback HTTP MCP server with bearer authentication, which
is the prompt-scoped broker created by the Orka supervisor.

Request behavior is intentionally narrow:

- `stream: true` returns HTTP 400 with code `stream_unsupported`.
Expand Down Expand Up @@ -76,7 +84,7 @@ configured `baseURL` at runtime.

## Network posture

The generated image defaults to `AGENTKIT_BIND=127.0.0.1`. At runtime:
The generated image defaults to `AGENTKIT_BIND=127.0.0.1`. In HTTP modes:

- loopback binds need no token except in Orka mode,
- non-loopback binds such as `0.0.0.0` require `AGENTKIT_AUTH_TOKEN`, and
Expand All @@ -87,6 +95,11 @@ OpenAI `/healthz` and Orka `/v1/health` and `/v1/capabilities` are intentionally
unauthenticated so container platforms and orchestrators can probe/discover the
service. Orka turn, event, cancel, and output endpoints always require a token.

ACP mode ignores bind and port settings because it uses stdio. The Orka
supervisor injects only `AGENTKIT_ACP_PROVIDER_BASE_URL`,
`AGENTKIT_ACP_PROVIDER_TOKEN`, `AGENTKIT_ACP_MODEL`, and
`AGENTKIT_ACP_AGENT_CONFIGURATION_DIGEST` into the child.

## Tool lifecycle and env projection

Tools are MCP servers declared in the ABI. Stdio tools use `name`, `command`,
Expand Down
67 changes: 67 additions & 0 deletions pkg/agentkit/config/brokered_unicode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package config

import (
"crypto/sha256"
"fmt"
"strings"
"testing"
"unicode"
"unicode/utf8"
)

func TestLowerBrokeredTextUnicode15(t *testing.T) {
var scalarValues strings.Builder
for r := rune(0); r <= unicode.MaxRune; r++ {
if utf8.ValidRune(r) {
scalarValues.WriteRune(r)
}
}
// Recorded from Go 1.26.1 strings.ToLower, unicode.Version 15.0.0,
// over every valid Unicode scalar in ascending order.
const want = "137590953b837f1ec8b7c02b3a0425d0789df789a59ad256e7a63416f9fc4c11"
got := fmt.Sprintf("%x", sha256.Sum256([]byte(lowerBrokeredText(scalarValues.String()))))
if got != want {
t.Fatalf("brokered lowercase mapping differs from Unicode 15: got %s, want %s", got, want)
}
}

func TestValidateBrokeredDescriptionsPreservesUnicode15(t *testing.T) {
for _, test := range []struct {
description string
valid bool
}{
{description: "Count input tokens\u1c89", valid: true},
{description: "Count input tokens\ua7cb", valid: true},
{description: "Count input tokens\ua7ce", valid: true},
{description: "Count input tokens\U00010d50", valid: true},
{description: "Count input tokens\U00010d65", valid: true},
{description: "Count input tokens\U00016ea0", valid: true},
{description: "Count input tokens\U00016eb8", valid: true},
{description: "Count input Tokens\U00010d50", valid: false},
{description: "Count input tokens\u00c9", valid: false},
{description: "Count input tokens\u0130", valid: false},
{description: "Count input tokens_\U00010d50", valid: false},
{description: "Count input tokens\U00010d50 abc123", valid: false},
{description: "token\U00016ea0=abc123", valid: false},
{description: "Read {auth\U00016ea0}", valid: false},
{description: "Bas\u0130c dXNlcjpwYXNz", valid: false},
} {
t.Run(test.description, func(t *testing.T) {
cfg := validMinimalConfig()
cfg.BrokeredTools = []BrokeredTool{{
Name: safeLookupToolName,
Description: test.description,
BrokeredClass: BrokeredClassRead,
Parameters: map[string]any{jsonSchemaTypeKey: jsonSchemaTypeObject},
}}
err := cfg.Validate()
if test.valid {
if err != nil {
t.Fatalf("Unicode 15 description should be accepted: %v", err)
}
} else if err == nil || !strings.Contains(err.Error(), "brokeredTools[0].description") {
t.Fatalf("credential-shaped description should be rejected, got %v", err)
}
})
}
}
30 changes: 25 additions & 5 deletions pkg/agentkit/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"sort"
"strconv"
"strings"
"unicode"

"github.com/sozercan/agentkit/pkg/agentkit/runtimes"
"github.com/sozercan/agentkit/pkg/utils"
Expand Down Expand Up @@ -70,6 +71,21 @@ const (

var brokeredBasicValuePattern = regexp.MustCompile(`(?i)(?:^|[^A-Za-z0-9_])basic[^A-Za-z0-9_]+?([A-Za-z0-9+/]+={0,2})`)

// Brokered descriptions use the Unicode 15 casing contract shared with the
// Python ABI reader. Exclude the mappings introduced in Unicode 16 and 17;
// the full-scalar golden test detects further drift when Go is upgraded.
var brokeredUnicode15Case = unicode.SpecialCase{
{Lo: 0x1C89, Hi: 0x1C89},
{Lo: 0xA7CB, Hi: 0xA7CC},
{Lo: 0xA7CE, Hi: 0xA7CE},
{Lo: 0xA7D2, Hi: 0xA7D2},
{Lo: 0xA7D4, Hi: 0xA7D4},
{Lo: 0xA7DA, Hi: 0xA7DA},
{Lo: 0xA7DC, Hi: 0xA7DC},
{Lo: 0x10D50, Hi: 0x10D65},
{Lo: 0x16EA0, Hi: 0x16EB8},
}

// Validate reports every problem with the config at once via errors.Join (plan
// §16.2 #3 — one report-all validator, not scattered first-error-wins funcs).
//
Expand Down Expand Up @@ -799,19 +815,23 @@ func isSchemaDigest(value string) bool {
return err == nil
}

func lowerBrokeredText(value string) string {
return strings.ToLowerSpecial(brokeredUnicode15Case, value)
}

func hasUnsafeBrokeredText(value string) bool {
lowered := strings.ToLower(value)
lowered := lowerBrokeredText(value)
return hasUnsafeBrokeredDescription(value) || containsBrokeredWord(lowered, "basic") || strings.Contains(lowered, brokeredTokenWord)
}

func hasUnsafeBrokeredDescription(value string) bool {
lowered := strings.ToLower(value)
lowered := lowerBrokeredText(value)
normalized := normalizeKey(lowered)
return containsSecretPrefix(value) || strings.Contains(value, "://") || containsBrokeredWord(lowered, "bearer") || containsBrokeredWord(lowered, brokeredSensitiveWord) || containsBrokeredWord(lowered, brokeredSensitivePluralWord) || containsBrokeredBasicAuthReference(value) || containsBrokeredCredentialAssignment(value) || containsBrokeredCredentialReference(value) || strings.Contains(lowered, authorizationKey) || strings.Contains(lowered, "secret") || strings.Contains(lowered, "password") || strings.Contains(lowered, "passphrase") || strings.Contains(lowered, "pwd") || strings.Contains(lowered, "api key") || strings.Contains(lowered, "apikey") || strings.Contains(normalized, "apikey") || strings.Contains(normalized, "xapikey") || strings.Contains(normalized, "subscriptionkey") || strings.Contains(normalized, "xfunctionskey") || strings.Contains(lowered, brokeredUnsafeCookieKey) || strings.Contains(lowered, "set-cookie") || strings.Contains(lowered, "x-api-key") || strings.Contains(lowered, credentialHeaderAPIKey) || strings.Contains(lowered, "subscription-key") || strings.Contains(lowered, "x-functions-key") || strings.Contains(lowered, "ocp-apim-subscription-key") || strings.Contains(lowered, "private key") || strings.Contains(lowered, "privatekey") || strings.Contains(lowered, "key material") || strings.Contains(lowered, ".svc") || strings.Contains(lowered, "cluster.local")
}

func containsBrokeredBasicAuthReference(value string) bool {
lowered := strings.ToLower(value)
lowered := lowerBrokeredText(value)
if !containsBrokeredWord(lowered, "basic") {
return false
}
Expand All @@ -831,7 +851,7 @@ func containsBrokeredBasicAuthReference(value string) bool {
}
}
for i, field := range fields {
if !containsBrokeredWord(strings.ToLower(field), "basic") {
if !containsBrokeredWord(lowerBrokeredText(field), "basic") {
continue
}
for j, candidate := range fields[i+1:] {
Expand Down Expand Up @@ -1022,7 +1042,7 @@ func isBrokeredNumericCount(value string) bool {
}

func hasBrokeredStructuredKeyShape(value string) bool {
return strings.HasPrefix(strings.TrimLeft(value, "\"'`([{<"), "-") || strings.ContainsAny(value, "_/.[]{}()<>\"'`") || value != strings.ToLower(value)
return strings.HasPrefix(strings.TrimLeft(value, "\"'`([{<"), "-") || strings.ContainsAny(value, "_/.[]{}()<>\"'`") || value != lowerBrokeredText(value)
}

func containsBrokeredWord(value string, word string) bool {
Expand Down
Loading
Loading