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
4 changes: 3 additions & 1 deletion .github/workflows/canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ jobs:

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

- name: Install latest Claude Code CLI
Expand Down
118 changes: 118 additions & 0 deletions .github/workflows/live-a2a.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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: 30
strategy:
fail-fast: false
max-parallel: 1
matrix:
scenario:
- inbound-single
- inbound-multi
- outbound-single
- outbound-multi
env:
INKBOX_API_KEY: ${{ secrets.CLAUDE_CODE_INKBOX_API_KEY }}
INKBOX_SIGNING_KEY: ${{ secrets.CLAUDE_CODE_INKBOX_SIGNING_KEY }}
CLAUDE_PROJECT_DIR: ${{ github.workspace }}
INKBOX_PERMISSION_TIMEOUT_S: "30"
DISABLE_AUTOUPDATER: "1"
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"

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: Install bridge and protocol driver
run: pip install -e .

- name: Install Claude Code CLI
run: npm install -g @anthropic-ai/claude-code

- name: Configure the real model and AUT identity
env:
ANTHROPIC_API_KEY_VALUE: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
HANDLE=$(python3 - <<'PY'
import os
from inkbox import Inkbox
client = Inkbox(api_key=os.environ["INKBOX_API_KEY"])
print(client.mailboxes.list()[0].email_address.split("@", 1)[0])
PY
)
{
echo "INKBOX_IDENTITY=$HANDLE"
echo "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY_VALUE"
} >> "$GITHUB_ENV"

- name: Start bridge gateway
run: |
nohup inkbox-claude run > "$RUNNER_TEMP/gateway.log" 2>&1 &
echo $! > "$RUNNER_TEMP/gateway.pid"
for _ in $(seq 1 36); do
grep -q "\[bridge\] ready" "$RUNNER_TEMP/gateway.log" && exit 0
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.CLAUDE_CODE_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 bridge 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 @@ -65,7 +65,10 @@ jobs:
node-version: 22

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

- name: Install Claude Code CLI
run: npm install -g @anthropic-ai/claude-code
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 @@ -63,7 +63,10 @@ jobs:
node-version: 22

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

- name: Install Claude Code CLI
run: npm install -g @anthropic-ai/claude-code
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 @@ -34,8 +34,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 @@ -68,7 +84,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 @@ -83,11 +99,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 @@ -100,14 +117,15 @@ jobs:
# One unattended page covers failures from any component or from the gate
# itself. PR authors and manual dispatchers can see their run directly.
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 @@ -56,7 +56,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 + driver 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]'

- name: Install Claude Code CLI
run: npm install -g @anthropic-ai/claude-code
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ jobs:
- name: Install bridge + latest SDK
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
uv pip install --system -U claude-agent-sdk

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,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_claude/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_CLAUDE_HOME")
or (Path.home() / ".inkbox-claude")
)
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)
2 changes: 1 addition & 1 deletion inkbox_claude/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def run_doctor() -> List[Tuple[str, bool, str]]:
import inkbox # noqa: F401
checks.append(("inkbox SDK", True, "installed"))
except ImportError:
checks.append(("inkbox SDK", False, "pip install 'inkbox>=0.5.1,<1.0.0'"))
checks.append(("inkbox SDK", False, "pip install 'inkbox>=0.5.6,<1.0.0'"))

try:
import claude_agent_sdk # noqa: F401
Expand Down
Loading