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
5 changes: 4 additions & 1 deletion .github/workflows/canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ jobs:
node-version: "22"

- name: Install bridge + test deps
run: pip install -e . pytest
run: |
pip install \
"inkbox @ git+https://github.com/inkbox-ai/inkbox.git@fdea6e55ac117f246624852aedeef0feabe08b9a#subdirectory=sdk/python" \
-e . pytest

# @alpha is the prerelease channel cut from codex main near-daily — the
# freshest main build available without compiling the host from source.
Expand Down
131 changes: 131 additions & 0 deletions .github/workflows/live-a2a.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
name: Live — Agent2Agent

# Four real protocol legs cover both roles and conversation lengths:
# inbound/outbound × single-turn/multi-turn. The plugin and remote identities
# are preconfigured to allow one another in both directions.
on:
workflow_call:
inputs:
timeout_s:
description: "Seconds to wait for each A2A transition"
required: false
type: string
default: "300"
orchestrated:
description: "True when the full-stack workflow owns the shared live lock"
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
timeout_s:
description: "Seconds to wait for each A2A transition"
required: false
default: "300"

permissions:
contents: read

concurrency:
group: ${{ inputs.orchestrated && format('inkbox-live-child-{0}', github.run_id) || 'inkbox-live-aut-tunnel' }}
cancel-in-progress: false
queue: max

jobs:
a2a:
runs-on: ubuntu-latest
timeout-minutes: 35
strategy:
fail-fast: false
max-parallel: 1
matrix:
scenario:
- inbound-single
- inbound-multi
- outbound-single
- outbound-multi

steps:
- uses: actions/checkout@v7

- uses: actions/setup-python@v6
with:
python-version: "3.12"

- uses: actions/setup-node@v7
with:
node-version: "22"

- name: Set up runtime paths
run: |
echo "CODEX_HOME=$RUNNER_TEMP/codex-home" >> "$GITHUB_ENV"
echo "CODEX_PROJECT_DIR=$RUNNER_TEMP/project" >> "$GITHUB_ENV"
mkdir -p "$RUNNER_TEMP/codex-home" "$RUNNER_TEMP/project"

- name: Install bridge and protocol driver
run: pip install -e .

- name: Install Codex
run: |
npm install -g @openai/codex@alpha
codex --version

- name: Configure the real model and AUT identity
env:
CODEX_INKBOX_API_KEY: ${{ secrets.CODEX_INKBOX_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
HANDLE="$(python3 - <<'PY'
import os
from inkbox import Inkbox
client = Inkbox(api_key=os.environ["CODEX_INKBOX_API_KEY"])
print(client.mailboxes.list()[0].email_address.split("@", 1)[0])
PY
)"
{
echo "INKBOX_IDENTITY=$HANDLE"
echo "INKBOX_ALLOW_ALL_USERS=true"
echo "CODEX_SANDBOX=read-only"
echo "CODEX_APPROVAL_POLICY=never"
echo "INKBOX_CODEX_AUTO_APPROVE_INKBOX_TOOLS=true"
echo "CODEX_MODEL=gpt-5.4-mini"
} >> "$GITHUB_ENV"
printenv OPENAI_API_KEY | codex login --with-api-key

- name: Start gateway
env:
INKBOX_API_KEY: ${{ secrets.CODEX_INKBOX_API_KEY }}
INKBOX_SIGNING_KEY: ${{ secrets.CODEX_INKBOX_SIGNING_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
inkbox-codex run > "$RUNNER_TEMP/gateway.log" 2>&1 &
echo $! > "$RUNNER_TEMP/gateway.pid"
for _ in $(seq 1 36); do
if grep -q "tunnel ready" "$RUNNER_TEMP/gateway.log"; then
exit 0
fi
if ! kill -0 "$(cat "$RUNNER_TEMP/gateway.pid")" 2>/dev/null; then
echo "::error::gateway exited during startup"
exit 1
fi
sleep 5
done
echo "::error::gateway did not become ready"
exit 1

- name: Run ${{ matrix.scenario }}
env:
A2A_SCENARIO: ${{ matrix.scenario }}
A2A_TIMEOUT_S: ${{ inputs.timeout_s || '300' }}
AUT_INKBOX_API_KEY: ${{ secrets.CODEX_INKBOX_API_KEY }}
REMOTE_INKBOX_API_KEY: ${{ secrets.REMOTE_INKBOX_API_KEY }}
run: python3 tests/live/a2a_driver.py

- name: Dump gateway log on failure
if: failure()
run: tail -n 300 "$RUNNER_TEMP/gateway.log" 2>/dev/null || true

- name: Stop gateway
if: always()
run: |
kill "$(cat "$RUNNER_TEMP/gateway.pid" 2>/dev/null)" 2>/dev/null || true
5 changes: 4 additions & 1 deletion .github/workflows/live-channels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ jobs:
mkdir -p "$RUNNER_TEMP/codex-home" "$RUNNER_TEMP/project"

- name: Install bridge + test deps
run: pip install -e . pytest
run: |
pip install \
"inkbox @ git+https://github.com/inkbox-ai/inkbox.git@fdea6e55ac117f246624852aedeef0feabe08b9a#subdirectory=sdk/python" \
-e . pytest

# @alpha is the prerelease channel cut from codex main near-daily — the
# freshest main build available without compiling the host from source.
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/live-external-events.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ jobs:
mkdir -p "$RUNNER_TEMP/codex-home" "$RUNNER_TEMP/project"

- name: Install bridge + test deps
run: pip install -e . pytest
run: |
pip install \
"inkbox @ git+https://github.com/inkbox-ai/inkbox.git@fdea6e55ac117f246624852aedeef0feabe08b9a#subdirectory=sdk/python" \
-e . pytest

# @alpha is the prerelease channel cut from codex main near-daily — the
# freshest main build available without compiling the host from source.
Expand Down
26 changes: 22 additions & 4 deletions .github/workflows/live-stack.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,24 @@ jobs:
orchestrated: true
secrets: inherit

voice:
a2a:
needs: channels
if: >
!cancelled() &&
((github.event_name == 'pull_request' &&
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository) ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main'))
uses: ./.github/workflows/live-a2a.yml
with:
orchestrated: true
secrets: inherit

voice:
needs: a2a
if: >
!cancelled() &&
((github.event_name == 'pull_request' &&
Expand Down Expand Up @@ -65,7 +81,7 @@ jobs:

full-stack:
name: full-stack
needs: [channels, voice, external-events]
needs: [channels, a2a, voice, external-events]
if: >
!cancelled() &&
((github.event_name == 'pull_request' &&
Expand All @@ -80,11 +96,12 @@ jobs:
- name: Require every live suite to pass
env:
CHANNELS_RESULT: ${{ needs.channels.result }}
A2A_RESULT: ${{ needs.a2a.result }}
VOICE_RESULT: ${{ needs.voice.result }}
EXTERNAL_EVENTS_RESULT: ${{ needs.external-events.result }}
run: |
failed=0
for suite in CHANNELS VOICE EXTERNAL_EVENTS; do
for suite in CHANNELS A2A VOICE EXTERNAL_EVENTS; do
result_var="${suite}_RESULT"
result="${!result_var}"
echo "$suite: $result"
Expand All @@ -93,14 +110,15 @@ jobs:
exit "$failed"

notify:
needs: [channels, voice, external-events, full-stack]
needs: [channels, a2a, voice, external-events, full-stack]
if: >
always() &&
github.event_name == 'workflow_run' &&
github.event.workflow_run.event == 'schedule' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main' &&
(needs.channels.result != 'success' ||
needs.a2a.result != 'success' ||
needs.voice.result != 'success' ||
needs.external-events.result != 'success' ||
needs.full-stack.result != 'success')
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/live-voice.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ jobs:
# uvicorn[standard] matters: the bare install can't accept WebSocket upgrades,
# and the driver's call-media endpoint is a WebSocket.
- name: Install bridge + test deps
run: pip install -e . pytest fastapi 'uvicorn[standard]'
run: |
pip install \
"inkbox @ git+https://github.com/inkbox-ai/inkbox.git@fdea6e55ac117f246624852aedeef0feabe08b9a#subdirectory=sdk/python" \
-e . pytest fastapi 'uvicorn[standard]'

# @alpha is the prerelease channel cut from codex main near-daily — the
# freshest main build available without compiling the host from source.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:
- name: Install bridge + test deps
run: |
uv pip install --system \
"inkbox @ git+https://github.com/inkbox-ai/inkbox.git@199bbd27c8dab2f70e379de52ca9cc910b0e141d#subdirectory=sdk/python" \
"inkbox @ git+https://github.com/inkbox-ai/inkbox.git@fdea6e55ac117f246624852aedeef0feabe08b9a#subdirectory=sdk/python" \
-e . pytest

# @alpha is the prerelease channel cut from codex main near-daily — the
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,11 @@ The agent reaches you (or third parties) through an in-process MCP server:
- `inkbox_list_imessage_conversations` · `inkbox_get_imessage_conversation` — browse iMessage threads and history (find the `conversation_id` to send into).
- `inkbox_lookup_contact` · `inkbox_list_contacts` · `inkbox_get_contact` — resolve and read address-book contacts (reverse-lookup by email/phone, free-text search, or full record by id).
- `inkbox_create_contact` · `inkbox_update_contact` · `inkbox_delete_contact` — save, edit, and remove organization-wide contacts. Changes affect the shared address book. vCard export/import is not exposed.
- `inkbox_a2a_call` · `inkbox_a2a_check` · `inkbox_a2a_reply` — delegate work to another agent and follow its task.
- `inkbox_list_a2a_tasks` · `inkbox_list_a2a_messages` — page and search this identity's inbound and outbound A2A history, with participant, task, context, role, state, and timestamp filters.
- `inkbox_a2a_complete` · `inkbox_a2a_ask_caller` · `inkbox_a2a_fail` — commit the outcome of a verified inbound A2A task. These tools are rejected outside that task's isolated session.

The bridge requires Inkbox SDK 0.5.6 or newer.

On a live call, the OpenAI Realtime voice agent additionally gets `consult_agent`, `register_post_call_action` / `edit_post_call_action` / `delete_post_call_action`, and `hang_up_call` — see [Voice](#voice).

Expand Down
106 changes: 106 additions & 0 deletions inkbox_codex/a2a_delegations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Durable caller-side A2A delegation routing records."""

from __future__ import annotations

import json
import os
import threading
import time
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import urlsplit

_LOCK = threading.Lock()


def _path() -> Path:
root = Path(
os.getenv("INKBOX_CODEX_HOME")
or (Path.home() / ".inkbox-codex")
)
return root / "a2a_delegations.json"


def _origin(url: str) -> str:
parsed = urlsplit(url)
return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"


def _read() -> Dict[str, Dict[str, Any]]:
try:
loaded = json.loads(_path().read_text())
return loaded if isinstance(loaded, dict) else {}
except FileNotFoundError:
return {}


def _write(records: Dict[str, Dict[str, Any]]) -> None:
path = _path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(records, indent=2, sort_keys=True) + "\n")
tmp.chmod(0o600)
os.replace(tmp, path)
path.chmod(0o600)


def record_before_send(
*,
identity_id: str,
rpc_url: str,
card_url: str,
message_id: str,
session_key: Optional[str],
context_id: Optional[str] = None,
task_id: Optional[str] = None,
) -> str:
"""Persist retry and routing data before the SendMessage request."""
origin = _origin(rpc_url)
context_key = context_id or f"pending:{message_id}"
key = f"{identity_id}|{origin}|{context_key}"
with _LOCK:
records = _read()
records[key] = {
"identity_id": identity_id,
"origin": origin,
"card_url": card_url,
"context_id": context_id,
"task_id": task_id,
"message_id": message_id,
"session_key": session_key,
"updated_at": time.time(),
}
_write(records)
return key


def promote_after_send(
pending_key: str,
*,
context_id: str,
task_id: str,
) -> None:
"""Promote a provisional record to its canonical context key."""
with _LOCK:
records = _read()
record = records.pop(pending_key, None)
if record is None:
return
record["context_id"] = context_id
record["task_id"] = task_id
record["updated_at"] = time.time()
key = (
f"{record['identity_id']}|{record['origin']}|{context_id}"
)
records[key] = record
_write(records)


def find_by_task(task_id: str) -> Optional[Dict[str, Any]]:
"""Return the newest local delegation record for a remote task."""
matches = [
record
for record in _read().values()
if str(record.get("task_id") or "") == task_id
]
return max(matches, key=lambda item: item.get("updated_at", 0), default=None)
10 changes: 10 additions & 0 deletions inkbox_codex/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import importlib.metadata
import hashlib
import os
from dataclasses import dataclass, field
from functools import lru_cache
Expand Down Expand Up @@ -56,6 +57,15 @@ def channel_hints_path() -> Path:
return root / "channel_hints.json"


def a2a_turn_context_path(chat_id: str) -> Path:
"""Return the trusted cross-process context file for one Codex session."""
root = Path(os.getenv("INKBOX_CODEX_HOME") or (Path.home() / ".inkbox-codex"))
path = root / "a2a_turn_contexts"
path.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256(chat_id.encode()).hexdigest()
return path / f"{digest}.json"


def env_flag(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
Expand Down
Loading