Skip to content

Commit 65d310b

Browse files
authored
Merge branch 'main' into p--default-model-update
2 parents 6528417 + 3951023 commit 65d310b

33 files changed

Lines changed: 2501 additions & 133 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ jobs:
1616
strategy:
1717
matrix:
1818
os: [ubuntu-latest, windows-latest, macos-latest]
19-
python-version: ['3.11', '3.13'] # the one we have in the Codespace + the latest supported one by PyO3.
19+
python-version: ['3.11', '3.14'] # the one we have in the Codespace + the latest one supported by Python 3.
2020
fail-fast: false # Continue testing other version(s) if one fails
2121

2222
steps:
2323
- name: Checkout repository
24-
uses: actions/checkout@v5
24+
uses: actions/checkout@v6
2525

2626
- name: Set up Python ${{ matrix.python-version }}
2727
uses: actions/setup-python@v6

.github/workflows/codeql.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929
build-mode: none
3030
steps:
3131
- name: Checkout repository
32-
uses: actions/checkout@v4
32+
uses: actions/checkout@v6
3333

3434
- name: Initialize CodeQL
3535
uses: github/codeql-action/init@v4

.github/workflows/publish-to-testpypi.yaml

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,17 @@ name: Publish Pre-Release to TestPyPI
33
on: workflow_dispatch
44

55
jobs:
6-
publish:
7-
name: Build
6+
build:
7+
name: Build distribution
88
runs-on: ubuntu-latest
9-
10-
# This environment is required as an input to pypa/gh-action-pypi-publish
11-
environment:
12-
name: testpypi
13-
url: https://test.pypi.org/p/seclab-taskflow-agent
9+
outputs:
10+
release_name: ${{ steps.create_version_number.outputs.RELEASE_NAME }}
1411

1512
env:
1613
GITHUB_REPO: ${{ github.repository }}
1714

1815
permissions:
19-
contents: write
20-
id-token: write # For trusted publishing
21-
attestations: write # For artifact attestation
16+
contents: read
2217

2318
steps:
2419
- name: Checkout repository
@@ -29,7 +24,7 @@ jobs:
2924
- name: Set up Python
3025
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
3126
with:
32-
python-version: "3.13"
27+
python-version: "3.14"
3328

3429
- name: Install Hatch
3530
run: pip install --upgrade hatch
@@ -52,17 +47,42 @@ jobs:
5247
- name: Build the wheel
5348
run: python3 -m hatch build
5449

55-
- name: Attest build provenance
56-
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
57-
with:
58-
subject-path: ./dist/*
59-
6050
- name: Upload artifacts
6151
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
6252
with:
6353
name: python-package-distributions
6454
path: ./dist/
6555

56+
publish:
57+
name: Publish to TestPyPI
58+
needs: build
59+
runs-on: ubuntu-latest
60+
61+
# This environment is required as an input to pypa/gh-action-pypi-publish
62+
environment:
63+
name: testpypi
64+
url: https://test.pypi.org/p/seclab-taskflow-agent
65+
66+
env:
67+
GITHUB_REPO: ${{ github.repository }}
68+
69+
permissions:
70+
contents: write
71+
id-token: write # For trusted publishing
72+
attestations: write # For artifact attestation
73+
74+
steps:
75+
- name: Download distribution artifacts
76+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
77+
with:
78+
name: python-package-distributions
79+
path: ./dist/
80+
81+
- name: Attest build provenance
82+
uses: actions/attest@v4.1.0 # immutable release
83+
with:
84+
subject-path: ./dist/*
85+
6686
- name: Publish to TestPyPI
6787
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
6888
with:
@@ -72,5 +92,5 @@ jobs:
7292
- name: Create GitHub Release
7393
env:
7494
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
75-
RELEASE_NAME: ${{ steps.create_version_number.outputs.RELEASE_NAME }}
95+
RELEASE_NAME: ${{ needs.build.outputs.release_name }}
7696
run: gh release create $RELEASE_NAME dist/* --repo $GITHUB_REPO --prerelease --generate-notes

.github/workflows/release.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ jobs:
2424
runs-on: ubuntu-latest
2525
steps:
2626
- name: Checkout code
27-
uses: actions/checkout@v4
27+
uses: actions/checkout@v6
2828

2929
- name: Set up Python
30-
uses: actions/setup-python@v5
30+
uses: actions/setup-python@v6
3131
with:
3232
python-version: '3.11'
3333

@@ -40,7 +40,7 @@ jobs:
4040
echo "digest=$DIGEST" >> $GITHUB_OUTPUT
4141
4242
- name: Generate artifact attestation
43-
uses: actions/attest-build-provenance@v3
43+
uses: actions/attest-build-provenance@v4
4444
with:
4545
subject-name: ${{ env.REGISTRY }}/${{ env.USER }}/${{ env.IMAGE_NAME }}
4646
subject-digest: '${{ steps.docker_build.outputs.digest }}'

.github/workflows/smoketest.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131

3232
- name: Setup Python
3333
if: steps.branch-deploy.outputs.continue == 'true'
34-
uses: actions/setup-python@v5
34+
uses: actions/setup-python@v6
3535
with:
3636
python-version: '3.11'
3737

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,41 @@ Per-model `model_settings` can include:
8181
- **`endpoint`** — API base URL override for this model
8282
- **`token`** — name of an environment variable containing the API key
8383

84+
### Backends
85+
86+
The runner can drive two SDKs behind a common interface:
87+
88+
- **`openai_agents`** (default) — the OpenAI Agents Python SDK. Supports
89+
multi-personality handoffs, both `chat_completions` and `responses`
90+
`api_type`, `temperature`, `parallel_tool_calls`,
91+
`exclude_from_context`, and MCP over stdio, SSE, and streamable HTTP.
92+
- **`copilot_sdk`** (optional, `pip install seclab-taskflow-agent[copilot]`)
93+
— the GitHub Copilot Python SDK. Supports streaming, `reasoning_effort`,
94+
MCP over stdio/SSE/HTTP, and per-tool permission gating. The SDK
95+
selects its own wire protocol per model, so the YAML `api_type` field
96+
is not honoured; multi-personality handoffs, `temperature`, and
97+
`parallel_tool_calls` are likewise not available. Taskflows that use
98+
unsupported fields fail at load time with a `BackendCapabilityError`
99+
naming the offending field.
100+
101+
Selection precedence:
102+
103+
1. `backend:` field in the model config document.
104+
2. `SECLAB_TASKFLOW_BACKEND` environment variable.
105+
3. Endpoint auto-default (`api.githubcopilot.com` prefers `copilot_sdk`
106+
when the optional dependency is installed).
107+
4. `openai_agents`.
108+
109+
```yaml
110+
seclab-taskflow-agent:
111+
version: "1.0"
112+
filetype: model_config
113+
backend: copilot_sdk
114+
models:
115+
fast: gpt-5-mini
116+
slow: claude-opus-4.6
117+
```
118+
84119
### Session Recovery
85120

86121
Taskflow runs are automatically checkpointed at the task level. If a task

pyproject.toml

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ classifiers = [
2828
"Programming Language :: Python :: 3.11",
2929
"Programming Language :: Python :: 3.12",
3030
"Programming Language :: Python :: 3.13",
31+
"Programming Language :: Python :: 3.14",
3132
"Programming Language :: Python :: Implementation :: CPython",
3233
"Programming Language :: Python :: Implementation :: PyPy",
3334
"Intended Audience :: Developers",
@@ -86,9 +87,9 @@ dependencies = [
8687
"platformdirs==4.5.0",
8788
"pluggy==1.6.0",
8889
"pycparser==2.23",
89-
"pydantic==2.11.7",
90-
"pydantic-settings==2.10.1",
91-
"pydantic_core==2.33.2",
90+
"pydantic==2.13.3",
91+
"pydantic-settings==2.14.0",
92+
"pydantic_core==2.46.3",
9293
"Pygments==2.20.0",
9394
"pyperclip==1.9.0",
9495
"python-dotenv==1.1.1",
@@ -112,7 +113,7 @@ dependencies = [
112113
"tqdm==4.67.1",
113114
"typer==0.16.0",
114115
"types-requests==2.32.4.20250611",
115-
"typing-inspection==0.4.1",
116+
"typing-inspection==0.4.2",
116117
"typing_extensions==4.15.0",
117118
"ujson==5.12.0",
118119
"urllib3==2.6.3",
@@ -123,6 +124,15 @@ dependencies = [
123124
[project.scripts]
124125
seclab-taskflow-agent = "seclab_taskflow_agent.cli:app"
125126

127+
[project.optional-dependencies]
128+
# Pulls in the GitHub Copilot SDK (public preview) so the copilot_sdk
129+
# backend can be selected. Requires Python >= 3.11. Pinned to the
130+
# 0.2.x line because the SDK may ship breaking changes between minor
131+
# versions while still in preview.
132+
copilot = [
133+
"github-copilot-sdk>=0.2.2,<0.3",
134+
]
135+
126136
[project.urls]
127137
Source = "https://github.com/GitHubSecurityLab/seclab-taskflow-agent"
128138
Issues = "https://github.com/GitHubSecurityLab/seclab-taskflow-agent/issues"

src/seclab_taskflow_agent/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
__all__ = [
2727
"ApiType",
2828
"AvailableTools",
29+
"BackendSdk",
2930
"TaskAgent",
3031
"TaskRunHooks",
3132
"TaskAgentHooks",
@@ -41,6 +42,7 @@
4142
from .available_tools import AvailableTools
4243
from .models import (
4344
ApiType,
45+
BackendSdk,
4446
ModelConfigDocument,
4547
PersonalityDocument,
4648
PromptDocument,
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
4+
"""Stream-driving helpers for the runner.
5+
6+
This module owns the inner loop that consumes events from a backend
7+
adapter (`TextDelta` / `ToolEnd`), renders text deltas to the user, and
8+
bridges Copilot-side tool events into the run-hook callbacks that the
9+
runner uses to capture MCP results for ``repeat_prompt`` and session
10+
checkpointing.
11+
12+
Extracted from ``runner.py`` so the rate-limit/retry loop and the
13+
backend-event translation are independently readable and testable.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
__all__ = ["STREAM_IDLE_TIMEOUT", "bridge_copilot_tool_event", "drive_backend_stream"]
19+
20+
import asyncio
21+
import json
22+
import logging
23+
from types import SimpleNamespace
24+
from typing import Any
25+
26+
from ._watchdog import watchdog_ping
27+
from .render_utils import render_model_output
28+
from .sdk import TextDelta, ToolEnd
29+
from .sdk.errors import BackendRateLimitError, BackendTimeoutError
30+
31+
# Application-level backstop: if the backend's event stream goes silent
32+
# for this long, surface a BackendTimeoutError so the retry loop can
33+
# recover. This complements the TCP-level httpx timeouts in the
34+
# openai-agents adapter — those catch dead sockets, this catches the
35+
# subtler case where the connection stays open but nothing is flowing.
36+
STREAM_IDLE_TIMEOUT = 1800
37+
38+
39+
async def bridge_copilot_tool_event(event: ToolEnd, run_hooks: Any) -> None:
40+
"""Forward a Copilot ``ToolEnd`` into the openai-agents-style hooks.
41+
42+
The runner captures MCP tool output via ``run_hooks.on_tool_end``,
43+
which the openai-agents path drives natively. The Copilot adapter
44+
surfaces tool completions as ``ToolEnd`` events instead, so we
45+
invoke the same hooks here with:
46+
47+
* a ``SimpleNamespace(name=...)`` placeholder in lieu of the
48+
openai-agents ``Tool`` object — the hooks only read ``.name``.
49+
* a ``json.dumps({"text": ...})`` envelope around the result text,
50+
matching the wire format openai-agents uses when serialising MCP
51+
``TextContent`` lists. ``_build_prompts_to_run`` in the runner
52+
depends on that exact envelope shape, so both backends produce
53+
identical entries in ``last_mcp_tool_results``.
54+
"""
55+
if run_hooks is None:
56+
return
57+
fake_tool = SimpleNamespace(name=event.tool_name)
58+
payload = json.dumps({"text": event.text})
59+
await run_hooks.on_tool_start(None, None, fake_tool)
60+
await run_hooks.on_tool_end(None, None, fake_tool, payload)
61+
62+
63+
async def drive_backend_stream(
64+
*,
65+
backend_impl: Any,
66+
agent_handle: Any,
67+
prompt: str,
68+
max_turns: int,
69+
run_hooks: Any,
70+
async_task: bool,
71+
task_id: str,
72+
max_api_retry: int,
73+
initial_rate_limit_backoff: int,
74+
max_rate_limit_backoff: int,
75+
) -> None:
76+
"""Run the backend's event stream to completion with retry/backoff.
77+
78+
Renders ``TextDelta`` events to stdout, forwards ``ToolEnd`` events
79+
to the run-hook bridge, retries up to *max_api_retry* times on
80+
:class:`BackendTimeoutError`, and applies exponential backoff up to
81+
*max_rate_limit_backoff* seconds on :class:`BackendRateLimitError`
82+
before giving up with a :class:`BackendTimeoutError`.
83+
"""
84+
max_retry = max_api_retry
85+
rate_limit_backoff = initial_rate_limit_backoff
86+
last_rate_limit_exc: BackendRateLimitError | None = None
87+
88+
while rate_limit_backoff:
89+
try:
90+
stream = backend_impl.run_streamed(
91+
agent_handle, prompt, max_turns=max_turns
92+
)
93+
stream_iter = stream.__aiter__()
94+
try:
95+
while True:
96+
try:
97+
event = await asyncio.wait_for(
98+
stream_iter.__anext__(), timeout=STREAM_IDLE_TIMEOUT
99+
)
100+
except StopAsyncIteration:
101+
break
102+
except asyncio.TimeoutError as exc:
103+
raise BackendTimeoutError(
104+
f"Backend stream idle for {STREAM_IDLE_TIMEOUT}s"
105+
) from exc
106+
watchdog_ping()
107+
if isinstance(event, TextDelta):
108+
await render_model_output(
109+
event.text, async_task=async_task, task_id=task_id
110+
)
111+
elif isinstance(event, ToolEnd):
112+
await bridge_copilot_tool_event(event, run_hooks)
113+
finally:
114+
# Close the async generator so its finally block runs even
115+
# if we abort early (timeout / consumer break) — the
116+
# adapters use that to release backend-native resources.
117+
aclose = getattr(stream_iter, "aclose", None)
118+
if aclose is not None:
119+
try:
120+
await aclose()
121+
except Exception: # noqa: BLE001 - best-effort cleanup
122+
logging.exception("Failed to aclose backend stream iterator")
123+
await render_model_output("\n\n", async_task=async_task, task_id=task_id)
124+
return
125+
except BackendTimeoutError:
126+
if not max_retry:
127+
logging.exception("Max retries for BackendTimeoutError reached")
128+
raise
129+
max_retry -= 1
130+
except BackendRateLimitError as exc:
131+
last_rate_limit_exc = exc
132+
if rate_limit_backoff == max_rate_limit_backoff:
133+
raise BackendTimeoutError("Max rate limit backoff reached") from exc
134+
if rate_limit_backoff > max_rate_limit_backoff:
135+
rate_limit_backoff = max_rate_limit_backoff
136+
else:
137+
rate_limit_backoff += rate_limit_backoff
138+
logging.exception(f"Hit rate limit ... holding for {rate_limit_backoff}")
139+
await asyncio.sleep(rate_limit_backoff)
140+
141+
if last_rate_limit_exc is not None: # pragma: no cover - loop always returns/raises above
142+
raise BackendTimeoutError("Rate limit backoff exhausted") from last_rate_limit_exc

0 commit comments

Comments
 (0)