secondshift 0.1.0b12__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- secondshift/__init__.py +1 -0
- secondshift/__main__.py +3 -0
- secondshift/cli.py +17 -0
- secondshift/config/__init__.py +0 -0
- secondshift/config/api.py +23 -0
- secondshift/config/app.py +62 -0
- secondshift/config/constants.py +279 -0
- secondshift/config/container_env.py +118 -0
- secondshift/config/first_run.py +40 -0
- secondshift/config/formatting.py +5 -0
- secondshift/config/gunicorn.py +31 -0
- secondshift/config/logging.py +40 -0
- secondshift/config/settings/__init__.py +0 -0
- secondshift/config/settings/base.py +524 -0
- secondshift/config/settings/dev.py +14 -0
- secondshift/config/settings/local.py +23 -0
- secondshift/config/settings/production.py +113 -0
- secondshift/config/settings/runner.py +140 -0
- secondshift/config/settings/test.py +47 -0
- secondshift/config/static_storage.py +45 -0
- secondshift/config/urls.py +42 -0
- secondshift/connections/__init__.py +0 -0
- secondshift/connections/access.py +27 -0
- secondshift/connections/admin.py +25 -0
- secondshift/connections/agents.py +224 -0
- secondshift/connections/api.py +747 -0
- secondshift/connections/apps.py +6 -0
- secondshift/connections/catalog.py +96 -0
- secondshift/connections/checks.py +22 -0
- secondshift/connections/dao.py +305 -0
- secondshift/connections/errors.py +13 -0
- secondshift/connections/execution.py +262 -0
- secondshift/connections/external.py +258 -0
- secondshift/connections/github.py +123 -0
- secondshift/connections/http_api/__init__.py +1 -0
- secondshift/connections/http_api/client.py +161 -0
- secondshift/connections/http_api/connections.py +187 -0
- secondshift/connections/http_api/spec.py +382 -0
- secondshift/connections/mcp/__init__.py +1 -0
- secondshift/connections/mcp/base.py +139 -0
- secondshift/connections/mcp/client.py +353 -0
- secondshift/connections/mcp/custom.py +86 -0
- secondshift/connections/mcp/github.py +256 -0
- secondshift/connections/mcp/oauth.py +352 -0
- secondshift/connections/mcp/providers.py +15 -0
- secondshift/connections/mcp/schemas.py +77 -0
- secondshift/connections/mcp/sentry.py +135 -0
- secondshift/connections/mcp/signin.py +206 -0
- secondshift/connections/migration_helpers.py +28 -0
- secondshift/connections/migrations/0001_initial.py +101 -0
- secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
- secondshift/connections/migrations/0003_require_approval.py +23 -0
- secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
- secondshift/connections/migrations/0005_agentconnection.py +36 -0
- secondshift/connections/migrations/0006_remove_is_default.py +23 -0
- secondshift/connections/migrations/0007_agent_last_check.py +18 -0
- secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
- secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
- secondshift/connections/migrations/0010_connection_attribution.py +18 -0
- secondshift/connections/migrations/__init__.py +0 -0
- secondshift/connections/model_providers.py +441 -0
- secondshift/connections/models.py +246 -0
- secondshift/connections/outbound.py +317 -0
- secondshift/connections/repository_ref.py +19 -0
- secondshift/connections/schemas.py +313 -0
- secondshift/connections/serialization.py +100 -0
- secondshift/connections/services.py +94 -0
- secondshift/connections/tools.py +38 -0
- secondshift/connections/webhooks.py +107 -0
- secondshift/core/__init__.py +0 -0
- secondshift/core/adapters.py +38 -0
- secondshift/core/admin.py +5 -0
- secondshift/core/api.py +104 -0
- secondshift/core/apps.py +20 -0
- secondshift/core/launcher.py +311 -0
- secondshift/core/local_agents.py +90 -0
- secondshift/core/local_output.py +120 -0
- secondshift/core/management/__init__.py +0 -0
- secondshift/core/management/commands/__init__.py +0 -0
- secondshift/core/management/commands/bootstrap_dev.py +33 -0
- secondshift/core/management/commands/dev_reset_db.py +204 -0
- secondshift/core/management/commands/login.py +23 -0
- secondshift/core/management/commands/logs.py +58 -0
- secondshift/core/management/commands/status.py +46 -0
- secondshift/core/management/commands/up.py +244 -0
- secondshift/core/middleware.py +21 -0
- secondshift/core/migrations/__init__.py +0 -0
- secondshift/core/schemas.py +71 -0
- secondshift/core/signin.py +58 -0
- secondshift/core/static/core/account.css +149 -0
- secondshift/core/static/core/admin.css +135 -0
- secondshift/core/templatetags/__init__.py +0 -0
- secondshift/core/templatetags/accounts.py +20 -0
- secondshift/core/terminal.py +27 -0
- secondshift/core/views.py +10 -0
- secondshift/gateway/__init__.py +0 -0
- secondshift/gateway/apps.py +6 -0
- secondshift/gateway/dao.py +137 -0
- secondshift/gateway/metering.py +171 -0
- secondshift/gateway/migrations/0001_initial.py +54 -0
- secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
- secondshift/gateway/migrations/__init__.py +0 -0
- secondshift/gateway/models.py +61 -0
- secondshift/gateway/pricing.py +39 -0
- secondshift/gateway/proxy.py +369 -0
- secondshift/gateway/tokens.py +68 -0
- secondshift/gateway/urls.py +17 -0
- secondshift/gateway/views.py +11 -0
- secondshift/github/__init__.py +6 -0
- secondshift/github/app.py +109 -0
- secondshift/github/authors.py +56 -0
- secondshift/github/credentials.py +59 -0
- secondshift/github/errors.py +2 -0
- secondshift/github/git.py +56 -0
- secondshift/github/identity.py +35 -0
- secondshift/github/installations.py +74 -0
- secondshift/github/issues.py +75 -0
- secondshift/github/permissions.py +49 -0
- secondshift/github/pulls.py +49 -0
- secondshift/github/repos.py +52 -0
- secondshift/github/rest.py +93 -0
- secondshift/github/ruff.toml +16 -0
- secondshift/github/tokens.py +52 -0
- secondshift/jobs/__init__.py +0 -0
- secondshift/jobs/access.py +12 -0
- secondshift/jobs/admin.py +83 -0
- secondshift/jobs/api.py +184 -0
- secondshift/jobs/apps.py +6 -0
- secondshift/jobs/automatic_grants.py +115 -0
- secondshift/jobs/autosync.py +78 -0
- secondshift/jobs/capabilities.py +43 -0
- secondshift/jobs/dao.py +258 -0
- secondshift/jobs/drafts.py +112 -0
- secondshift/jobs/grants.py +116 -0
- secondshift/jobs/limits.py +26 -0
- secondshift/jobs/management/__init__.py +0 -0
- secondshift/jobs/management/commands/__init__.py +0 -0
- secondshift/jobs/manifest.py +364 -0
- secondshift/jobs/migrations/0001_initial.py +87 -0
- secondshift/jobs/migrations/0002_job_history.py +18 -0
- secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
- secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
- secondshift/jobs/migrations/0005_job_triggers.py +18 -0
- secondshift/jobs/migrations/0006_job_webhook.py +18 -0
- secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
- secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
- secondshift/jobs/migrations/0009_job_subagents.py +18 -0
- secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
- secondshift/jobs/migrations/__init__.py +0 -0
- secondshift/jobs/models.py +199 -0
- secondshift/jobs/needs.py +154 -0
- secondshift/jobs/plan.py +176 -0
- secondshift/jobs/planning.py +386 -0
- secondshift/jobs/readiness.py +46 -0
- secondshift/jobs/repository_sync.py +199 -0
- secondshift/jobs/scheduling.py +26 -0
- secondshift/jobs/schemas.py +207 -0
- secondshift/jobs/serialization.py +218 -0
- secondshift/jobs/skill_needs.py +189 -0
- secondshift/jobs/sync.py +252 -0
- secondshift/jobs/tool_policy.py +82 -0
- secondshift/jobs/tool_rules.py +200 -0
- secondshift/mcp_server/__init__.py +6 -0
- secondshift/mcp_server/api.py +79 -0
- secondshift/mcp_server/apps.py +7 -0
- secondshift/mcp_server/audit.py +22 -0
- secondshift/mcp_server/core.py +12 -0
- secondshift/mcp_server/dao.py +23 -0
- secondshift/mcp_server/migrations/0001_initial.py +33 -0
- secondshift/mcp_server/migrations/__init__.py +0 -0
- secondshift/mcp_server/models.py +40 -0
- secondshift/mcp_server/schemas.py +204 -0
- secondshift/mcp_server/tools/__init__.py +9 -0
- secondshift/mcp_server/tools/jobs.py +106 -0
- secondshift/mcp_server/tools/manifests.py +127 -0
- secondshift/mcp_server/tools/repositories.py +42 -0
- secondshift/mcp_server/tools/runs.py +86 -0
- secondshift/mcp_server/tools/tasks.py +103 -0
- secondshift/mcp_server/tools_registry.py +81 -0
- secondshift/repositories/__init__.py +0 -0
- secondshift/repositories/access.py +13 -0
- secondshift/repositories/admin.py +19 -0
- secondshift/repositories/api.py +296 -0
- secondshift/repositories/apps.py +6 -0
- secondshift/repositories/dao.py +114 -0
- secondshift/repositories/external_skills.py +224 -0
- secondshift/repositories/github.py +27 -0
- secondshift/repositories/github_access.py +57 -0
- secondshift/repositories/github_check.py +279 -0
- secondshift/repositories/github_sync.py +181 -0
- secondshift/repositories/github_views.py +90 -0
- secondshift/repositories/local.py +198 -0
- secondshift/repositories/management/__init__.py +0 -0
- secondshift/repositories/management/commands/__init__.py +0 -0
- secondshift/repositories/management/commands/prune_source_cache.py +15 -0
- secondshift/repositories/migrations/0001_initial.py +45 -0
- secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
- secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
- secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
- secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
- secondshift/repositories/migrations/0006_allow_issues.py +18 -0
- secondshift/repositories/migrations/__init__.py +0 -0
- secondshift/repositories/mirrors.py +50 -0
- secondshift/repositories/models.py +104 -0
- secondshift/repositories/pull_requests.py +199 -0
- secondshift/repositories/schemas.py +102 -0
- secondshift/repositories/serialization.py +133 -0
- secondshift/repositories/skill_source.py +64 -0
- secondshift/repositories/snapshots.py +238 -0
- secondshift/repositories/sources.py +256 -0
- secondshift/repositories/status.py +67 -0
- secondshift/runner/README.md +65 -0
- secondshift/runner/__init__.py +1 -0
- secondshift/runner/agent_credentials.py +89 -0
- secondshift/runner/agent_image/Dockerfile +16 -0
- secondshift/runner/agent_image/agent.py +132 -0
- secondshift/runner/agent_image/claude.py +254 -0
- secondshift/runner/agent_image/codex.py +84 -0
- secondshift/runner/agent_image/outcomes.py +120 -0
- secondshift/runner/agent_image/subagent_hook.py +71 -0
- secondshift/runner/agent_protocol.py +41 -0
- secondshift/runner/agents.py +254 -0
- secondshift/runner/api.py +123 -0
- secondshift/runner/apps.py +6 -0
- secondshift/runner/client.py +168 -0
- secondshift/runner/collection.py +276 -0
- secondshift/runner/dao.py +64 -0
- secondshift/runner/dev.py +19 -0
- secondshift/runner/images.py +57 -0
- secondshift/runner/migrations/0001_initial.py +48 -0
- secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
- secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
- secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
- secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
- secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
- secondshift/runner/migrations/__init__.py +0 -0
- secondshift/runner/models.py +53 -0
- secondshift/runner/protocol.py +97 -0
- secondshift/runner/schemas.py +36 -0
- secondshift/runner/services.py +465 -0
- secondshift/runner/sources.py +130 -0
- secondshift/runner/urls.py +7 -0
- secondshift/runs/__init__.py +0 -0
- secondshift/runs/access.py +13 -0
- secondshift/runs/admin.py +89 -0
- secondshift/runs/api.py +211 -0
- secondshift/runs/apps.py +11 -0
- secondshift/runs/artifacts.py +261 -0
- secondshift/runs/dao.py +640 -0
- secondshift/runs/deliveries.py +80 -0
- secondshift/runs/execution/__init__.py +0 -0
- secondshift/runs/execution/access_needs.py +89 -0
- secondshift/runs/execution/agent.py +91 -0
- secondshift/runs/execution/agent_broker.py +198 -0
- secondshift/runs/execution/budgets.py +177 -0
- secondshift/runs/execution/cli_agent.py +167 -0
- secondshift/runs/execution/consent.py +58 -0
- secondshift/runs/execution/deadlines.py +47 -0
- secondshift/runs/execution/external_tools.py +84 -0
- secondshift/runs/execution/issues.py +99 -0
- secondshift/runs/execution/model_gateway.py +90 -0
- secondshift/runs/execution/payload.py +63 -0
- secondshift/runs/execution/result.py +59 -0
- secondshift/runs/execution/run.py +345 -0
- secondshift/runs/execution/subagents.py +246 -0
- secondshift/runs/execution/tool_broker.py +123 -0
- secondshift/runs/execution/workspace_tools.py +248 -0
- secondshift/runs/history.py +182 -0
- secondshift/runs/inputs.py +49 -0
- secondshift/runs/issue_delivery.py +144 -0
- secondshift/runs/management/__init__.py +0 -0
- secondshift/runs/management/commands/__init__.py +0 -0
- secondshift/runs/management/commands/prune_runs.py +12 -0
- secondshift/runs/management/commands/run_dbos_worker.py +129 -0
- secondshift/runs/migration_helpers.py +313 -0
- secondshift/runs/migrations/0001_initial.py +69 -0
- secondshift/runs/migrations/0002_run_started_by.py +18 -0
- secondshift/runs/migrations/0003_agentlease.py +25 -0
- secondshift/runs/migrations/0004_task_assignment.py +39 -0
- secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
- secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
- secondshift/runs/migrations/0007_run_delivery.py +35 -0
- secondshift/runs/migrations/0008_run_outputs.py +18 -0
- secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
- secondshift/runs/migrations/0010_run_issue.py +32 -0
- secondshift/runs/migrations/0011_issues_delivery.py +23 -0
- secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
- secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
- secondshift/runs/migrations/0014_run_access_need.py +30 -0
- secondshift/runs/migrations/__init__.py +0 -0
- secondshift/runs/models.py +340 -0
- secondshift/runs/orchestration/__init__.py +0 -0
- secondshift/runs/orchestration/admission.py +15 -0
- secondshift/runs/orchestration/dbos_runtime.py +43 -0
- secondshift/runs/orchestration/dispatch.py +130 -0
- secondshift/runs/orchestration/periodic.py +39 -0
- secondshift/runs/orchestration/reconciliation.py +98 -0
- secondshift/runs/orchestration/schedules.py +51 -0
- secondshift/runs/orchestration/workflows.py +480 -0
- secondshift/runs/outputs.py +52 -0
- secondshift/runs/publishing.py +162 -0
- secondshift/runs/queue.py +26 -0
- secondshift/runs/reporting/__init__.py +0 -0
- secondshift/runs/reporting/collection.py +26 -0
- secondshift/runs/reporting/connection_usage.py +45 -0
- secondshift/runs/reporting/failures.py +150 -0
- secondshift/runs/reporting/health.py +158 -0
- secondshift/runs/reporting/notifications.py +258 -0
- secondshift/runs/reporting/queries.py +60 -0
- secondshift/runs/retention.py +35 -0
- secondshift/runs/schemas.py +192 -0
- secondshift/runs/serialization.py +245 -0
- secondshift/runs/services.py +176 -0
- secondshift/runs/signals.py +25 -0
- secondshift/runs/submissions.py +236 -0
- secondshift/runs/transitions.py +84 -0
- secondshift/templates/account/password_reset_help_text.html +1 -0
- secondshift/templates/admin/base_site.html +24 -0
- secondshift/templates/allauth/elements/provider.html +9 -0
- secondshift/templates/allauth/layouts/base.html +67 -0
- secondshift/templates/socialaccount/snippets/login.html +9 -0
- secondshift/templates/workspaces/invite_unusable.html +10 -0
- secondshift/triggers/__init__.py +0 -0
- secondshift/triggers/admin.py +19 -0
- secondshift/triggers/api.py +116 -0
- secondshift/triggers/apps.py +6 -0
- secondshift/triggers/dao.py +66 -0
- secondshift/triggers/filters.py +48 -0
- secondshift/triggers/migrations/0001_initial.py +55 -0
- secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
- secondshift/triggers/migrations/__init__.py +0 -0
- secondshift/triggers/models.py +83 -0
- secondshift/triggers/schemas.py +58 -0
- secondshift/triggers/serialization.py +91 -0
- secondshift/triggers/services.py +246 -0
- secondshift/triggers/verification.py +84 -0
- secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
- secondshift/web/assets/index-D27gB9_L.css +1 -0
- secondshift/web/favicon.svg +19 -0
- secondshift/web/index.html +16 -0
- secondshift/web/secondshift-mark.svg +16 -0
- secondshift/workspaces/__init__.py +0 -0
- secondshift/workspaces/access.py +181 -0
- secondshift/workspaces/admin.py +37 -0
- secondshift/workspaces/api.py +264 -0
- secondshift/workspaces/apps.py +6 -0
- secondshift/workspaces/context_processors.py +11 -0
- secondshift/workspaces/dao.py +281 -0
- secondshift/workspaces/invites.py +150 -0
- secondshift/workspaces/limits.py +197 -0
- secondshift/workspaces/management/__init__.py +0 -0
- secondshift/workspaces/management/commands/__init__.py +0 -0
- secondshift/workspaces/management/commands/create_workspace.py +39 -0
- secondshift/workspaces/migrations/0001_initial.py +67 -0
- secondshift/workspaces/migrations/0002_api_token.py +34 -0
- secondshift/workspaces/migrations/0003_execution_default.py +29 -0
- secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
- secondshift/workspaces/migrations/__init__.py +0 -0
- secondshift/workspaces/models.py +169 -0
- secondshift/workspaces/owners.py +58 -0
- secondshift/workspaces/schemas.py +149 -0
- secondshift/workspaces/serialization.py +138 -0
- secondshift/workspaces/server_settings.py +140 -0
- secondshift/workspaces/services.py +94 -0
- secondshift/workspaces/tokens.py +108 -0
- secondshift/workspaces/views.py +32 -0
- secondshift-0.1.0b12.dist-info/METADATA +270 -0
- secondshift-0.1.0b12.dist-info/RECORD +371 -0
- secondshift-0.1.0b12.dist-info/WHEEL +4 -0
- secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
- secondshift-0.1.0b12.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Delivering a finished run's results again, without running the agent again."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from dbos import EnqueueOptions
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.db import transaction
|
|
8
|
+
|
|
9
|
+
from secondshift.config.constants import (
|
|
10
|
+
RETRY_ISSUES_WORKFLOW,
|
|
11
|
+
RETRY_NOTIFICATION_WORKFLOW,
|
|
12
|
+
RETRY_PULL_REQUEST_WORKFLOW,
|
|
13
|
+
)
|
|
14
|
+
from secondshift.runs.dao import add_event, lock_delivery
|
|
15
|
+
from secondshift.runs.models import Run, RunDelivery
|
|
16
|
+
from secondshift.runs.orchestration.dbos_runtime import new_dbos_client
|
|
17
|
+
from secondshift.runs.services import RunRefused
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DeliveryQueueUnavailable(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def retryable(delivery: RunDelivery) -> bool:
|
|
25
|
+
# Pull requests and issues need no connection. The repository's own
|
|
26
|
+
# GitHub access sends them.
|
|
27
|
+
return delivery.status == RunDelivery.Status.FAILED and (
|
|
28
|
+
delivery.kind in {RunDelivery.Kind.PULL_REQUEST, RunDelivery.Kind.ISSUES}
|
|
29
|
+
or delivery.connection_id is not None
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@transaction.atomic
|
|
34
|
+
def retry_delivery(run: Run, delivery_id: int) -> RunDelivery:
|
|
35
|
+
"""Queue a failed delivery again. Authorization is checked when it is sent."""
|
|
36
|
+
delivery = lock_delivery(run, delivery_id)
|
|
37
|
+
if delivery.status != RunDelivery.Status.FAILED:
|
|
38
|
+
raise RunRefused("Only a failed delivery can be retried.")
|
|
39
|
+
if not retryable(delivery):
|
|
40
|
+
raise RunRefused("The connection for this delivery was removed.")
|
|
41
|
+
delivery.status = RunDelivery.Status.RETRYING
|
|
42
|
+
delivery.message = "Waiting to retry."
|
|
43
|
+
delivery.save(update_fields=["status", "message", "updated_at"])
|
|
44
|
+
# Enqueued inside the transaction, so a queue outage leaves it failed.
|
|
45
|
+
if delivery.kind == RunDelivery.Kind.PULL_REQUEST:
|
|
46
|
+
add_event(run.pk, "Retrying the pull request.")
|
|
47
|
+
enqueue_retry(RETRY_PULL_REQUEST_WORKFLOW, f"pull-request-{run.pk}", run.pk)
|
|
48
|
+
elif delivery.kind == RunDelivery.Kind.ISSUES:
|
|
49
|
+
add_event(run.pk, "Retrying the issues.")
|
|
50
|
+
enqueue_retry(RETRY_ISSUES_WORKFLOW, f"issues-{run.pk}", run.pk)
|
|
51
|
+
else:
|
|
52
|
+
add_event(run.pk, f"Retrying the notification to {delivery.target}.")
|
|
53
|
+
enqueue_retry(
|
|
54
|
+
RETRY_NOTIFICATION_WORKFLOW,
|
|
55
|
+
f"{run.pk}-{delivery.connection_id}",
|
|
56
|
+
run.pk,
|
|
57
|
+
delivery.connection_id,
|
|
58
|
+
)
|
|
59
|
+
return delivery
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def enqueue_retry(workflow: str, name: str, *args: int) -> str:
|
|
63
|
+
suffix = uuid.uuid4().hex[:12]
|
|
64
|
+
options: EnqueueOptions = {
|
|
65
|
+
"workflow_name": workflow,
|
|
66
|
+
"queue_name": settings.DBOS_QUEUE_NAME,
|
|
67
|
+
# Each retry is its own workflow. A reused ID would return the old result.
|
|
68
|
+
"workflow_id": f"secondshift-delivery-{name}-{suffix}",
|
|
69
|
+
"application_name": settings.DBOS_APPLICATION_NAME,
|
|
70
|
+
}
|
|
71
|
+
try:
|
|
72
|
+
client = new_dbos_client()
|
|
73
|
+
except Exception as error:
|
|
74
|
+
raise DeliveryQueueUnavailable from error
|
|
75
|
+
try:
|
|
76
|
+
return client.enqueue(options, *args).get_workflow_id()
|
|
77
|
+
except Exception as error:
|
|
78
|
+
raise DeliveryQueueUnavailable from error
|
|
79
|
+
finally:
|
|
80
|
+
client.destroy()
|
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Let the agent say what access it needed and this run did not have.
|
|
2
|
+
|
|
3
|
+
A skill from another repository often expects services nobody connected. The
|
|
4
|
+
agent cannot see tools it was not given, so it reports them here instead of
|
|
5
|
+
quietly doing less. The run page lists them. Reporting never grants anything.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
from pydantic_ai import ModelRetry, RunContext, Tool
|
|
12
|
+
|
|
13
|
+
from secondshift.runs.dao import (
|
|
14
|
+
access_need_for_call,
|
|
15
|
+
create_access_need,
|
|
16
|
+
run_access_needs,
|
|
17
|
+
update_access_need_tools,
|
|
18
|
+
)
|
|
19
|
+
from secondshift.runs.execution.workspace_tools import WorkspaceToolDeps
|
|
20
|
+
|
|
21
|
+
TOOL_NAME = "report_missing_access"
|
|
22
|
+
# Enough for a skill that names several services, without filling the page.
|
|
23
|
+
MAX_SERVICES = 20
|
|
24
|
+
MAX_TOOLS = 30
|
|
25
|
+
|
|
26
|
+
ServiceArgument = Annotated[str, Field(min_length=1, max_length=100)]
|
|
27
|
+
ToolsArgument = Annotated[
|
|
28
|
+
list[Annotated[str, Field(min_length=1, max_length=100)]],
|
|
29
|
+
Field(max_length=MAX_TOOLS),
|
|
30
|
+
]
|
|
31
|
+
ReasonArgument = Annotated[str, Field(max_length=1000)]
|
|
32
|
+
|
|
33
|
+
ACCESS_NOTE = f"""If the task needs a service, tool or credential this run does not have,
|
|
34
|
+
such as an MCP server or API the skill names, call {TOOL_NAME} once for each
|
|
35
|
+
service, then go on with what you can do. Do not try to reach the service
|
|
36
|
+
another way. Name the missing access in your report too."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def report_missing_access(
|
|
40
|
+
ctx: RunContext[WorkspaceToolDeps],
|
|
41
|
+
service: ServiceArgument,
|
|
42
|
+
tools: ToolsArgument | None = None,
|
|
43
|
+
reason: ReasonArgument = "",
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""Record a service or tool this task needs and this run was not given.
|
|
46
|
+
|
|
47
|
+
Name the service, such as linear or sentry, the tools needed from it, such
|
|
48
|
+
as create_issue, and why. People grant access after the run. Nothing is
|
|
49
|
+
granted now.
|
|
50
|
+
"""
|
|
51
|
+
if not ctx.tool_call_id:
|
|
52
|
+
raise RuntimeError("The model tool call has no stable identifier.")
|
|
53
|
+
service = " ".join(service.split())
|
|
54
|
+
if not service:
|
|
55
|
+
raise ModelRetry("Name the service.")
|
|
56
|
+
names = sorted({" ".join(tool.split()) for tool in tools or []} - {""})
|
|
57
|
+
run_id = ctx.deps.run_id
|
|
58
|
+
if access_need_for_call(run_id, ctx.tool_call_id):
|
|
59
|
+
return recorded()
|
|
60
|
+
needs = run_access_needs(run_id)
|
|
61
|
+
same = next(
|
|
62
|
+
(need for need in needs if need.service.casefold() == service.casefold()),
|
|
63
|
+
None,
|
|
64
|
+
)
|
|
65
|
+
if same is not None:
|
|
66
|
+
merged = sorted(set(same.tools) | set(names))[:MAX_TOOLS]
|
|
67
|
+
if merged != same.tools:
|
|
68
|
+
update_access_need_tools(same, merged)
|
|
69
|
+
return recorded()
|
|
70
|
+
if len(needs) >= MAX_SERVICES:
|
|
71
|
+
return {
|
|
72
|
+
"recorded": False,
|
|
73
|
+
"note": f"This run already reported {MAX_SERVICES} services. "
|
|
74
|
+
"Name the rest in the report.",
|
|
75
|
+
}
|
|
76
|
+
create_access_need(run_id, ctx.tool_call_id, service, names, reason.strip())
|
|
77
|
+
return recorded()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def recorded() -> dict[str, Any]:
|
|
81
|
+
return {
|
|
82
|
+
"recorded": True,
|
|
83
|
+
"note": "Recorded for people to review after the run. It is not "
|
|
84
|
+
"available in this run. Continue without it.",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def access_tool() -> Tool[WorkspaceToolDeps]:
|
|
89
|
+
return Tool(report_missing_access, name=TOOL_NAME)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from asgiref.sync import sync_to_async
|
|
6
|
+
from pydantic_ai import Agent, UsageLimits
|
|
7
|
+
from pydantic_ai.capabilities import ResolveModelId
|
|
8
|
+
from pydantic_ai.durable_exec.dbos import DBOSDurability
|
|
9
|
+
from pydantic_ai.models import Model, ModelResolutionContext
|
|
10
|
+
|
|
11
|
+
from secondshift.runs.dao import get_run
|
|
12
|
+
from secondshift.runs.execution.budgets import FinishWithinBudget
|
|
13
|
+
from secondshift.runs.execution.deadlines import check_deadline
|
|
14
|
+
from secondshift.runs.execution.model_gateway import (
|
|
15
|
+
issue_model_gateway_access,
|
|
16
|
+
pydantic_model,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
RUN_MODEL_ID = "secondshift-run-model"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class AgentLimits:
|
|
24
|
+
max_turns: int
|
|
25
|
+
max_tool_calls: int
|
|
26
|
+
max_tool_result_chars: int = 65_536
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if self.max_turns < 1:
|
|
30
|
+
raise ValueError("max_turns must be positive")
|
|
31
|
+
if self.max_tool_calls < 0:
|
|
32
|
+
raise ValueError("max_tool_calls cannot be negative")
|
|
33
|
+
if self.max_tool_result_chars < 1:
|
|
34
|
+
raise ValueError("max_tool_result_chars must be positive")
|
|
35
|
+
|
|
36
|
+
def usage_limits(self) -> UsageLimits:
|
|
37
|
+
"""Translate server policy to Pydantic AI's native run limits."""
|
|
38
|
+
return UsageLimits(
|
|
39
|
+
request_limit=self.max_turns,
|
|
40
|
+
tool_calls_limit=self.max_tool_calls,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def resolve_run_model(
|
|
45
|
+
context: ModelResolutionContext[Any], model_id: str
|
|
46
|
+
) -> Model | None:
|
|
47
|
+
"""Build gateway access inside the durable model-request step.
|
|
48
|
+
|
|
49
|
+
Only the run ID crosses the DBOS boundary. The short-lived gateway token is
|
|
50
|
+
created and consumed inside the step and is never serialized into workflow
|
|
51
|
+
inputs, outputs, or agent dependencies.
|
|
52
|
+
"""
|
|
53
|
+
if model_id != RUN_MODEL_ID:
|
|
54
|
+
return None
|
|
55
|
+
check_deadline(context.deps.deadline)
|
|
56
|
+
# Pydantic AI resolves models inside its event loop, where Django refuses
|
|
57
|
+
# synchronous database access.
|
|
58
|
+
return await sync_to_async(run_model)(int(context.deps.run_id))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_model(run_id: int) -> Model:
|
|
62
|
+
run = get_run(run_id, "job")
|
|
63
|
+
return pydantic_model(issue_model_gateway_access(run))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_agent(
|
|
67
|
+
*,
|
|
68
|
+
name: str = "secondshift",
|
|
69
|
+
instructions: str,
|
|
70
|
+
tools: Sequence[Any] = (),
|
|
71
|
+
deps_type: type[Any] = object,
|
|
72
|
+
) -> Agent[Any, str]:
|
|
73
|
+
"""Build the durable orchestrator-side agent for one run.
|
|
74
|
+
|
|
75
|
+
Tools supplied here are server-built wrappers around the runner or capability
|
|
76
|
+
broker. Repository content cannot register tools or change durability.
|
|
77
|
+
"""
|
|
78
|
+
return Agent(
|
|
79
|
+
model=RUN_MODEL_ID,
|
|
80
|
+
name=name,
|
|
81
|
+
instructions=instructions,
|
|
82
|
+
deps_type=deps_type,
|
|
83
|
+
tools=tools,
|
|
84
|
+
defer_model_check=True,
|
|
85
|
+
capabilities=[
|
|
86
|
+
ResolveModelId(resolve_run_model),
|
|
87
|
+
FinishWithinBudget(),
|
|
88
|
+
# Durable steps are named by the agent, so each agent needs its own.
|
|
89
|
+
DBOSDurability(name=name),
|
|
90
|
+
],
|
|
91
|
+
)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Stateless MCP transport over the existing, run-scoped tool policy."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.db import transaction
|
|
8
|
+
from django.http import HttpRequest, HttpResponse, JsonResponse
|
|
9
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
10
|
+
from pydantic_ai import ModelRetry, RunContext, RunUsage, Tool, ToolFailed
|
|
11
|
+
|
|
12
|
+
from secondshift.connections.agents import usable_agent_connection
|
|
13
|
+
from secondshift.connections.dao import find_agent_connection
|
|
14
|
+
from secondshift.connections.models import AgentConnection
|
|
15
|
+
from secondshift.runs.artifacts import run_payload
|
|
16
|
+
from secondshift.runs.dao import active_lease, get_run, lock_lease
|
|
17
|
+
from secondshift.runs.execution.access_needs import access_tool
|
|
18
|
+
from secondshift.runs.execution.deadlines import check_deadline, run_deadline
|
|
19
|
+
from secondshift.runs.execution.external_tools import external_tools
|
|
20
|
+
from secondshift.runs.execution.issues import issue_limit, issue_tool
|
|
21
|
+
from secondshift.runs.execution.payload import payload_tool
|
|
22
|
+
from secondshift.runs.execution.workspace_tools import (
|
|
23
|
+
MODEL_TOOL_NAMES,
|
|
24
|
+
WorkspaceToolDeps,
|
|
25
|
+
workspace_tools,
|
|
26
|
+
)
|
|
27
|
+
from secondshift.runs.models import AgentLease, Run
|
|
28
|
+
|
|
29
|
+
# Streamable HTTP MCP versions the broker speaks, newest first.
|
|
30
|
+
PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AgentAccessError(ValueError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MethodNotFound(ValueError):
|
|
38
|
+
"""Clients probe newer methods first and fall back when they are missing."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_connection(run: Run) -> AgentConnection:
|
|
42
|
+
plan = run.plan.execution
|
|
43
|
+
connection = find_agent_connection(
|
|
44
|
+
plan.connection_id,
|
|
45
|
+
run.workspace_id,
|
|
46
|
+
plan.owner_id,
|
|
47
|
+
plan.credential_key,
|
|
48
|
+
plan.backend,
|
|
49
|
+
)
|
|
50
|
+
if connection is None or not usable_agent_connection(connection, run.workspace_id):
|
|
51
|
+
raise AgentAccessError(
|
|
52
|
+
"The agent login is no longer available. Reconnect and start a new run."
|
|
53
|
+
)
|
|
54
|
+
return connection
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def lease_run(lease: AgentLease) -> Run:
|
|
58
|
+
run = get_run(lease.run_id, "job")
|
|
59
|
+
if not lease.active or run.status != Run.Status.RUNNING:
|
|
60
|
+
raise AgentAccessError("This agent run is no longer active.")
|
|
61
|
+
check_deadline(run_deadline(run))
|
|
62
|
+
run_connection(run)
|
|
63
|
+
return run
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def broker_tools(run: Run) -> dict[str, Tool[WorkspaceToolDeps]]:
|
|
67
|
+
allowed = set(run.plan.agent.tools)
|
|
68
|
+
local = {MODEL_TOOL_NAMES[name] for name in allowed if name in MODEL_TOOL_NAMES}
|
|
69
|
+
tools = [tool for tool in workspace_tools() if tool.name in local]
|
|
70
|
+
if run_payload(run):
|
|
71
|
+
tools.append(payload_tool())
|
|
72
|
+
if issue_limit(run.plan):
|
|
73
|
+
tools.append(issue_tool())
|
|
74
|
+
tools.append(access_tool())
|
|
75
|
+
return {tool.name: tool for tool in (*tools, *external_tools(run))}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def tool_result(
|
|
79
|
+
lease: AgentLease, name: str, arguments: dict[str, Any]
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
with transaction.atomic():
|
|
82
|
+
lease = lock_lease(lease.pk)
|
|
83
|
+
run = lease_run(lease)
|
|
84
|
+
if lease.tool_calls >= run.plan.agent.limits.max_tool_calls:
|
|
85
|
+
raise AgentAccessError("This run has used its tool call budget.")
|
|
86
|
+
# Resolve and validate before counting the call.
|
|
87
|
+
tool = broker_tools(run).get(name)
|
|
88
|
+
if tool is None:
|
|
89
|
+
raise AgentAccessError("This tool is not approved for the run.")
|
|
90
|
+
validated = tool.function_schema.validator.validate_python(arguments)
|
|
91
|
+
lease.tool_calls += 1
|
|
92
|
+
lease.save(update_fields=["tool_calls"])
|
|
93
|
+
# The broker numbers calls itself. Clients restart their request IDs when
|
|
94
|
+
# they reconnect, so those IDs cannot tell two calls apart.
|
|
95
|
+
call_id = f"agent-{lease.pk}-{lease.tool_calls}"
|
|
96
|
+
deps = WorkspaceToolDeps(
|
|
97
|
+
run_id=run.pk,
|
|
98
|
+
session_id=lease.session_id,
|
|
99
|
+
max_output_chars=run.plan.agent.limits.max_tool_result_chars,
|
|
100
|
+
deadline=run_deadline(run),
|
|
101
|
+
allowed_tools=run.plan.agent.tools,
|
|
102
|
+
payload=run_payload(run),
|
|
103
|
+
issue_limit=issue_limit(run.plan),
|
|
104
|
+
)
|
|
105
|
+
context = RunContext(deps=deps, model=None, usage=RunUsage(), tool_call_id=call_id)
|
|
106
|
+
try:
|
|
107
|
+
value = tool.function(context, **validated)
|
|
108
|
+
return {
|
|
109
|
+
"content": [{"type": "text", "text": json.dumps(value)}],
|
|
110
|
+
"isError": False,
|
|
111
|
+
}
|
|
112
|
+
except (ModelRetry, ToolFailed) as error:
|
|
113
|
+
# SecondShift wrote these for the agent, for example which argument to fix.
|
|
114
|
+
return {"content": [{"type": "text", "text": error.message}], "isError": True}
|
|
115
|
+
except Exception:
|
|
116
|
+
# Provider exceptions can include request contents. Return no raw errors.
|
|
117
|
+
return {
|
|
118
|
+
"content": [
|
|
119
|
+
{"type": "text", "text": "The approved tool could not complete."}
|
|
120
|
+
],
|
|
121
|
+
"isError": True,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def mcp_request(lease: AgentLease, message: dict[str, Any]) -> dict[str, Any] | None:
|
|
126
|
+
run = lease_run(lease)
|
|
127
|
+
method = message.get("method")
|
|
128
|
+
if "id" not in message:
|
|
129
|
+
return None
|
|
130
|
+
if method == "initialize":
|
|
131
|
+
requested = (message.get("params") or {}).get("protocolVersion")
|
|
132
|
+
return {
|
|
133
|
+
"protocolVersion": requested
|
|
134
|
+
if requested in PROTOCOL_VERSIONS
|
|
135
|
+
else PROTOCOL_VERSIONS[0],
|
|
136
|
+
"capabilities": {"tools": {}},
|
|
137
|
+
"serverInfo": {"name": "secondshift-run", "version": "1.0"},
|
|
138
|
+
}
|
|
139
|
+
if method == "ping":
|
|
140
|
+
return {}
|
|
141
|
+
if method == "tools/list":
|
|
142
|
+
return {
|
|
143
|
+
"tools": [
|
|
144
|
+
{
|
|
145
|
+
"name": tool.name,
|
|
146
|
+
"description": tool.description or tool.name,
|
|
147
|
+
"inputSchema": tool.function_schema.json_schema,
|
|
148
|
+
}
|
|
149
|
+
for tool in broker_tools(run).values()
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
if method == "tools/call":
|
|
153
|
+
params = message.get("params", {})
|
|
154
|
+
arguments = params.get("arguments", {})
|
|
155
|
+
if not isinstance(arguments, dict):
|
|
156
|
+
raise AgentAccessError("Invalid tool arguments.")
|
|
157
|
+
return tool_result(lease, str(params.get("name", "")), arguments)
|
|
158
|
+
raise MethodNotFound(str(method))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@csrf_exempt
|
|
162
|
+
def agent_mcp(request: HttpRequest) -> HttpResponse:
|
|
163
|
+
if request.method != "POST":
|
|
164
|
+
return HttpResponse(status=405)
|
|
165
|
+
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
|
|
166
|
+
lease = active_lease(hashlib.sha256(token.encode()).hexdigest()) if token else None
|
|
167
|
+
if lease is None:
|
|
168
|
+
return HttpResponse(status=401)
|
|
169
|
+
if len(request.body) > 1024 * 1024:
|
|
170
|
+
return HttpResponse(status=413)
|
|
171
|
+
message: dict[str, Any] = {}
|
|
172
|
+
try:
|
|
173
|
+
message = json.loads(request.body)
|
|
174
|
+
if not isinstance(message, dict):
|
|
175
|
+
return HttpResponse(status=400)
|
|
176
|
+
result = mcp_request(lease, message)
|
|
177
|
+
if "id" not in message:
|
|
178
|
+
return HttpResponse(status=202)
|
|
179
|
+
return JsonResponse({"jsonrpc": "2.0", "id": message["id"], "result": result})
|
|
180
|
+
except MethodNotFound:
|
|
181
|
+
return JsonResponse(
|
|
182
|
+
{
|
|
183
|
+
"jsonrpc": "2.0",
|
|
184
|
+
"id": message.get("id"),
|
|
185
|
+
"error": {"code": -32601, "message": "Method not found."},
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
except Exception:
|
|
189
|
+
return JsonResponse(
|
|
190
|
+
{
|
|
191
|
+
"jsonrpc": "2.0",
|
|
192
|
+
"id": message.get("id"),
|
|
193
|
+
"error": {
|
|
194
|
+
"code": -32000,
|
|
195
|
+
"message": "Agent access denied or tool request invalid.",
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Run budgets: model requests, tool calls and tokens.
|
|
2
|
+
|
|
3
|
+
The agent sees when it has to finish. On its last allowed model request, or
|
|
4
|
+
once its tool calls are used up, its tools are hidden and it is told to write
|
|
5
|
+
the report with what it has. A run that still runs out keeps what the model
|
|
6
|
+
wrote so far as a partial report. So does a run that times out.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pydantic_ai import RunContext
|
|
17
|
+
from pydantic_ai.capabilities import AbstractCapability
|
|
18
|
+
from pydantic_ai.exceptions import ModelHTTPError
|
|
19
|
+
from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart, ToolCallPart
|
|
20
|
+
from pydantic_ai.tools import ToolDefinition
|
|
21
|
+
from pydantic_ai.usage import RunUsage
|
|
22
|
+
|
|
23
|
+
from secondshift.config.constants import TOKEN_BUDGET_MESSAGE, AgentBackend
|
|
24
|
+
from secondshift.jobs.plan import ExecutionPlan
|
|
25
|
+
from secondshift.runs.models import RunFailure
|
|
26
|
+
from secondshift.runs.reporting.failures import StageFailure
|
|
27
|
+
from secondshift.workspaces.limits import RunLimit, raise_hint
|
|
28
|
+
|
|
29
|
+
BUDGET_HEADLINE = RunFailure.BUDGET.label
|
|
30
|
+
FINISH_INSTRUCTIONS = (
|
|
31
|
+
"This run has reached its budget, so no tools are available. Write the final "
|
|
32
|
+
"report now from what you have found. Say what you could not check."
|
|
33
|
+
)
|
|
34
|
+
PARTIAL_REPORT_NOTE = (
|
|
35
|
+
"_The run stopped at one of its limits before the agent finished. "
|
|
36
|
+
"This is what it wrote so far._"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Budget(StrEnum):
|
|
41
|
+
REQUESTS = "requests"
|
|
42
|
+
TOOL_CALLS = "tool_calls"
|
|
43
|
+
TOKENS = "tokens"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class BudgetLimit:
|
|
48
|
+
label: str
|
|
49
|
+
limit: RunLimit
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
BUDGET_LIMITS = {
|
|
53
|
+
Budget.REQUESTS: BudgetLimit("model requests", RunLimit.MAX_TURNS),
|
|
54
|
+
Budget.TOOL_CALLS: BudgetLimit("tool calls", RunLimit.MAX_TOOL_CALLS),
|
|
55
|
+
Budget.TOKENS: BudgetLimit("tokens", RunLimit.TOKEN_BUDGET),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def spent_budget(context: RunContext[Any]) -> Budget | None:
|
|
60
|
+
"""The budget that leaves the agent no room for another tool round."""
|
|
61
|
+
limits = context.usage_limits
|
|
62
|
+
if limits is None:
|
|
63
|
+
return None
|
|
64
|
+
usage = context.usage
|
|
65
|
+
# The request about to be made is not counted yet.
|
|
66
|
+
if limits.request_limit is not None and usage.requests + 1 >= limits.request_limit:
|
|
67
|
+
return Budget.REQUESTS
|
|
68
|
+
if (
|
|
69
|
+
limits.tool_calls_limit is not None
|
|
70
|
+
and usage.tool_calls >= limits.tool_calls_limit
|
|
71
|
+
):
|
|
72
|
+
return Budget.TOOL_CALLS
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FinishWithinBudget(AbstractCapability[Any]):
|
|
77
|
+
"""Hide the tools and ask for the report once the budget is spent."""
|
|
78
|
+
|
|
79
|
+
async def prepare_tools(
|
|
80
|
+
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
|
|
81
|
+
) -> list[ToolDefinition]:
|
|
82
|
+
return [] if spent_budget(ctx) else tool_defs
|
|
83
|
+
|
|
84
|
+
def get_instructions(self) -> Any:
|
|
85
|
+
def instructions(ctx: RunContext[Any]) -> str:
|
|
86
|
+
return FINISH_INSTRUCTIONS if spent_budget(ctx) else ""
|
|
87
|
+
|
|
88
|
+
return instructions
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def reached_budget(
|
|
92
|
+
usage: RunUsage, max_turns: int, max_tool_calls: int
|
|
93
|
+
) -> Budget | None:
|
|
94
|
+
"""The budget a finished run used in full, if any."""
|
|
95
|
+
if usage.requests >= max_turns:
|
|
96
|
+
return Budget.REQUESTS
|
|
97
|
+
if usage.tool_calls >= max_tool_calls:
|
|
98
|
+
return Budget.TOOL_CALLS
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def exhausted_budget(messages: Sequence[ModelMessage], max_turns: int) -> Budget:
|
|
103
|
+
"""Which native limit stopped the run, from the messages it produced."""
|
|
104
|
+
responses = sum(isinstance(message, ModelResponse) for message in messages)
|
|
105
|
+
return Budget.REQUESTS if responses >= max_turns else Budget.TOOL_CALLS
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_token_budget_refusal(error: BaseException) -> bool:
|
|
109
|
+
if not isinstance(error, ModelHTTPError) or error.status_code != 429:
|
|
110
|
+
return False
|
|
111
|
+
return TOKEN_BUDGET_MESSAGE in str(error.body)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def budget_reason(budget: Budget, limit: int) -> str:
|
|
115
|
+
spec = BUDGET_LIMITS[budget]
|
|
116
|
+
return (
|
|
117
|
+
f"Stopped at {limit:,} {spec.label}, the run's limit. "
|
|
118
|
+
f"{raise_hint(spec.limit, limit)}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def budget_reached_message(budget: Budget) -> str:
|
|
123
|
+
return (
|
|
124
|
+
f"The run used all its {BUDGET_LIMITS[budget].label}, so the agent "
|
|
125
|
+
"wrote its report without further tool calls."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def budget_failure(budget: Budget, limit: int) -> StageFailure:
|
|
130
|
+
reason = budget_reason(budget, limit)
|
|
131
|
+
return StageFailure(RunFailure.BUDGET, reason, f"{BUDGET_HEADLINE}: {reason}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def partial_report(messages: Sequence[ModelMessage]) -> str:
|
|
135
|
+
"""What the model wrote and which tools it called before it was stopped."""
|
|
136
|
+
texts: list[str] = []
|
|
137
|
+
calls: Counter[str] = Counter()
|
|
138
|
+
for message in messages:
|
|
139
|
+
if not isinstance(message, ModelResponse):
|
|
140
|
+
continue
|
|
141
|
+
for part in message.parts:
|
|
142
|
+
if isinstance(part, TextPart) and part.content.strip():
|
|
143
|
+
texts.append(part.content.strip())
|
|
144
|
+
elif isinstance(part, ToolCallPart):
|
|
145
|
+
calls[part.tool_name] += 1
|
|
146
|
+
if not texts and not calls:
|
|
147
|
+
return ""
|
|
148
|
+
sections = [PARTIAL_REPORT_NOTE, *texts]
|
|
149
|
+
if calls:
|
|
150
|
+
sections.append(
|
|
151
|
+
"Tool calls made:\n"
|
|
152
|
+
+ "\n".join(f"- `{name}`: {count}" for name, count in calls.items())
|
|
153
|
+
)
|
|
154
|
+
return "\n\n".join(sections)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def limits_note(
|
|
158
|
+
plan: ExecutionPlan, backend: AgentBackend, started_at: datetime.datetime | None
|
|
159
|
+
) -> str:
|
|
160
|
+
"""The prompt lines that tell the agent how much room the run has."""
|
|
161
|
+
minutes = plan.limit(RunLimit.TIMEOUT_SECONDS) // 60
|
|
162
|
+
limits = [
|
|
163
|
+
f"{minutes} minutes",
|
|
164
|
+
f"{plan.limit(RunLimit.MAX_TOOL_CALLS):,} tool calls",
|
|
165
|
+
]
|
|
166
|
+
if backend.limits_model_calls:
|
|
167
|
+
limits.append(f"{plan.limit(RunLimit.MAX_TURNS):,} model calls")
|
|
168
|
+
start = ""
|
|
169
|
+
if started_at is not None:
|
|
170
|
+
start = started_at.astimezone(datetime.UTC).strftime(
|
|
171
|
+
" It started at %H:%M:%S UTC."
|
|
172
|
+
)
|
|
173
|
+
return (
|
|
174
|
+
f"This run's limits are {', '.join(limits[:-1])} and {limits[-1]}.{start} "
|
|
175
|
+
"Sub-agents' tool calls count toward them. Plan the work to fit, and "
|
|
176
|
+
"keep enough room to write your report."
|
|
177
|
+
)
|