airbyte_ops_mcp.mcp.devin_ops
MCP tools for Devin agent-session operations: reminders, on-demand secret requests, session feedback, and session naming.
MCP reference
MCP primitives registered by the devin_ops module of the airbyte-internal-ops server: 7 tool(s), 0 prompt(s), 0 resource(s).
Tools (7)
cancel_devin_reminder
Cancel pending Devin reminders by session URL and specific GUIDs.
Removes matching reminders so they will not fire. Use this when instructed to stop reminders, or when a reminder is no longer needed.
Both agent_session_url and cancel_guids are required. Only reminders matching the session URL AND present in the GUID list are cancelled.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
agent_session_url |
string |
yes | — | Your Devin session URL. Use the session URL from your system prompt. Required together with cancel_guids. |
cancel_guids |
array<string> |
yes | — | List of reminder GUIDs to cancel. You can get GUIDs from the reminder creation response or from the reminders list. |
Show input JSON schema
{
"additionalProperties": false,
"properties": {
"agent_session_url": {
"description": "Your Devin session URL. Use the session URL from your system prompt. Required together with cancel_guids.",
"type": "string"
},
"cancel_guids": {
"description": "List of reminder GUIDs to cancel. You can get GUIDs from the reminder creation response or from the reminders list.",
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"agent_session_url",
"cancel_guids"
],
"type": "object"
}
Show output JSON schema
{
"description": "Response from the cancel_devin_reminder tool.",
"properties": {
"success": {
"description": "Whether the cancel workflow was triggered successfully",
"type": "boolean"
},
"message": {
"description": "Human-readable status message",
"type": "string"
},
"workflow_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "URL to view the GitHub Actions workflow file"
},
"run_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "GitHub Actions workflow run ID"
},
"run_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Direct URL to the GitHub Actions workflow run"
}
},
"required": [
"success",
"message"
],
"type": "object"
}
devin_session_feedback
Report structured feedback about a Devin session experience via Slack.
Posts a formatted feedback message to the #hydra-feedback Slack channel
and, when thread_url is set, as a reply in the originating Slack thread.
For negative feedback, triage findings are posted in both destinations.
The report tags the reporting user and the @oc-hydra and @oc-internal-ai
groups (unless feedback_area overrides the default groups).
The message includes a clickable
button for the Devin session link. For negative feedback, a triage workflow
is automatically dispatched to launch a Devin session with v3 analyze mode
that can inspect the original session's full conversation history.
For negative feedback, the caller supplies any existing Linear tracking issue ID and URL. This tool transports those values to Slack and the triage workflow; it does not read or write Linear.
IMPORTANT: This feedback will be logged publicly in Slack. Inform the user that their feedback is visible to the team and they may be contacted for additional details.
Use this tool when a user explicitly asks to report a positive or negative experience with their Devin session. Before calling this tool, let the user know:
- Their feedback will be posted publicly in the #hydra-feedback Slack channel
- They may be contacted by the team for more details
- The reporting user and the @oc-hydra and @oc-internal-ai groups will be tagged in the message, unless
feedback_areaoverrides the default groups - For negative feedback, a triage session will be automatically launched to inspect the reported session
Depending on the path, the Slack message is posted by this tool or a GitHub Actions workflow; Slack credentials are never exposed to the calling agent.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
feedback_type |
enum("positive", "negative") |
yes | — | Type of feedback: 'positive' for a good experience or 'negative' for a bad experience. Use 'positive' when the user expresses satisfaction, praise, or a success story. Use 'negative' when the user reports a problem, frustration, or failure. |
category |
enum("tool_failure", "missing_guidance", "suspected_hallucination", "bad_approach", "excessive_iteration", "poor_quality", "other_concern", "great_results", "exceeded_expectations", "fast_completion", "good_communication", "other_positive_feedback") |
yes | — | Feedback category. For NEGATIVE feedback, use one of: 'tool_failure' (a specific tool/integration broke), 'missing_guidance' (Devin lacked instructions or context), 'suspected_hallucination' (Devin fabricated information or made incorrect claims), 'bad_approach' (Devin took a fundamentally wrong strategy), 'excessive_iteration' (too many loops/retries before success), 'poor_quality' (output quality below expectations), 'other_concern'. For POSITIVE feedback, use one of: 'great_results' (task completed with high quality), 'exceeded_expectations' (went above and beyond), 'fast_completion' (completed quickly and efficiently), 'good_communication' (kept user well-informed), 'other_positive_feedback'. |
task_description |
string |
yes | — | Brief description of what the user asked Devin to do. This sets the context for the feedback. |
reporting_user |
string |
yes | — | The person providing the feedback. Accepts an email address (e.g. 'aj@airbyte.io'), a GitHub handle prefixed with @ (e.g. '@aaronsteers'), or a Slack user ID (e.g. 'U05AKF1BCC9'). |
session_playbook |
string |
yes | — | ID of the Devin playbook associated with the session (e.g. 'devin_feedback_triage'), or 'none' when no playbook is associated. Required so feedback can identify whether playbook instructions may need updates. |
agent_session_url |
string | null |
no | null |
Optional URL of the reporting Devin session. Use the session URL from your system prompt when reporting your own session. |
related_skill_name |
string | null |
no | null |
Optional skill ID associated with the feedback (e.g. 'delete-declarative-source-def') when a related skill may need updates or is suspected of having issues. |
feedback_area |
string | null |
no | null |
Optional domain of the reported task. db_sources = database source connectors (source-postgres/mysql/mssql/mongodb-v2/oracle, db-harness-lib); routes the Slack notification to the DB Sources owner instead of @oc-hydra. |
expected_behavior |
string | null |
no | null |
What should have happened. REQUIRED for negative feedback. Describe the expected outcome clearly. |
observed_behavior |
string | null |
no | null |
What actually happened. REQUIRED for negative feedback. Describe the actual outcome, including any error messages or unexpected results. |
what_went_well |
string | null |
no | null |
What specifically was good about the experience. REQUIRED for positive feedback. Be specific about what Devin did well. |
severity |
enum("low", "medium", "high", "critical") | null |
no | null |
Severity of the issue. Recommended for negative feedback. 'low' = minor inconvenience, 'medium' = notable impact, 'high' = significant blocker, 'critical' = complete failure. |
steps_to_reproduce |
string | null |
no | null |
Optional steps to reproduce the issue. Helpful for negative feedback to enable the team to investigate. |
session_to_evaluate |
string | null |
no | null |
Optional Devin session URL to evaluate/triage. Use this when reporting feedback about a different session (not your own). If omitted, agent_session_url is used as the session to triage (i.e., the reporter is reporting on itself). |
linear_issue_id |
string | null |
no | null |
Optional Linear issue identifier for the issue already tracking this feedback, used by the triage session for mutations. Pass the identifier, such as HYD-123; a UUID is also accepted if available. |
linear_issue_url |
string | null |
no | null |
Optional URL of the Linear issue already tracking this feedback. When provided, Slack links to this exact URL. |
post_only |
boolean |
no | false |
Post the report without dispatching triage. Set this explicitly for a repeat report; do not infer it from a ticket ID. |
thread_url |
string | null |
no | null |
Optional URL of the Slack thread where the feedback request originated. When set, the report and triage findings are also posted as replies in this thread, in addition to the top-level #hydra-feedback post. |
Show input JSON schema
{
"additionalProperties": false,
"properties": {
"feedback_type": {
"description": "Type of feedback: 'positive' for a good experience or 'negative' for a bad experience. Use 'positive' when the user expresses satisfaction, praise, or a success story. Use 'negative' when the user reports a problem, frustration, or failure.",
"enum": [
"positive",
"negative"
],
"type": "string"
},
"category": {
"description": "Feedback category. For NEGATIVE feedback, use one of: 'tool_failure' (a specific tool/integration broke), 'missing_guidance' (Devin lacked instructions or context), 'suspected_hallucination' (Devin fabricated information or made incorrect claims), 'bad_approach' (Devin took a fundamentally wrong strategy), 'excessive_iteration' (too many loops/retries before success), 'poor_quality' (output quality below expectations), 'other_concern'. For POSITIVE feedback, use one of: 'great_results' (task completed with high quality), 'exceeded_expectations' (went above and beyond), 'fast_completion' (completed quickly and efficiently), 'good_communication' (kept user well-informed), 'other_positive_feedback'.",
"enum": [
"tool_failure",
"missing_guidance",
"suspected_hallucination",
"bad_approach",
"excessive_iteration",
"poor_quality",
"other_concern",
"great_results",
"exceeded_expectations",
"fast_completion",
"good_communication",
"other_positive_feedback"
],
"type": "string"
},
"task_description": {
"description": "Brief description of what the user asked Devin to do. This sets the context for the feedback.",
"type": "string"
},
"reporting_user": {
"description": "The person providing the feedback. Accepts an email address (e.g. 'aj@airbyte.io'), a GitHub handle prefixed with @ (e.g. '@aaronsteers'), or a Slack user ID (e.g. 'U05AKF1BCC9').",
"type": "string"
},
"session_playbook": {
"description": "ID of the Devin playbook associated with the session (e.g. 'devin_feedback_triage'), or 'none' when no playbook is associated. Required so feedback can identify whether playbook instructions may need updates.",
"type": "string"
},
"agent_session_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional URL of the reporting Devin session. Use the session URL from your system prompt when reporting your own session."
},
"related_skill_name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional skill ID associated with the feedback (e.g. 'delete-declarative-source-def') when a related skill may need updates or is suspected of having issues."
},
"feedback_area": {
"anyOf": [
{
"const": "db_sources",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional domain of the reported task. `db_sources` = database source connectors (source-postgres/mysql/mssql/mongodb-v2/oracle, db-harness-lib); routes the Slack notification to the DB Sources owner instead of @oc-hydra."
},
"expected_behavior": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "What should have happened. REQUIRED for negative feedback. Describe the expected outcome clearly."
},
"observed_behavior": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "What actually happened. REQUIRED for negative feedback. Describe the actual outcome, including any error messages or unexpected results."
},
"what_went_well": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "What specifically was good about the experience. REQUIRED for positive feedback. Be specific about what Devin did well."
},
"severity": {
"anyOf": [
{
"enum": [
"low",
"medium",
"high",
"critical"
],
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Severity of the issue. Recommended for negative feedback. 'low' = minor inconvenience, 'medium' = notable impact, 'high' = significant blocker, 'critical' = complete failure."
},
"steps_to_reproduce": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional steps to reproduce the issue. Helpful for negative feedback to enable the team to investigate."
},
"session_to_evaluate": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional Devin session URL to evaluate/triage. Use this when reporting feedback about a *different* session (not your own). If omitted, agent_session_url is used as the session to triage (i.e., the reporter is reporting on itself)."
},
"linear_issue_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional Linear issue identifier for the issue already tracking this feedback, used by the triage session for mutations. Pass the identifier, such as `HYD-123`; a UUID is also accepted if available."
},
"linear_issue_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional URL of the Linear issue already tracking this feedback. When provided, Slack links to this exact URL."
},
"post_only": {
"default": false,
"description": "Post the report without dispatching triage. Set this explicitly for a repeat report; do not infer it from a ticket ID.",
"type": "boolean"
},
"thread_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional URL of the Slack thread where the feedback request originated. When set, the report and triage findings are also posted as replies in this thread, in addition to the top-level #hydra-feedback post."
}
},
"required": [
"feedback_type",
"category",
"task_description",
"reporting_user",
"session_playbook"
],
"type": "object"
}
Show output JSON schema
{
"description": "Response from the session feedback tool.",
"properties": {
"success": {
"description": "Whether the workflow was triggered successfully",
"type": "boolean"
},
"message": {
"description": "Human-readable status message",
"type": "string"
},
"workflow_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "URL to view the GitHub Actions workflow file"
},
"run_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "GitHub Actions workflow run ID"
},
"run_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Direct URL to the GitHub Actions workflow run"
},
"triage_run_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "URL to the auto-triage workflow run"
},
"linear_issue_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "URL of the Linear issue tracking this feedback"
},
"linear_issue_identifier": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Human-readable Linear issue key, e.g. `HYD-123`, recovered from `linear_issue_url`. Falls back to `linear_issue_id` when the URL carries no key."
}
},
"required": [
"success",
"message"
],
"type": "object"
}
devin_session_feedback_followup
Post a follow-up to an existing feedback thread in #hydra-feedback.
This is the "second call" in the feedback workflow: after
devin_session_feedback creates the initial report, this tool appends
triage findings or additional context as a threaded reply.
Each reply is wrapped with a disclaimer clarifying that the thread is non-interactive and not monitored by any agent.
Workspace validation ensures only URLs from the expected Slack workspace are accepted.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
thread_url |
string |
yes | — | Slack thread URL from the original feedback post in #hydra-feedback. This is the thread where follow-up context will be appended. Example: https://airbytehq-team.slack.com/archives/C0ACUHRP6B1/p1773062711122019 |
message |
string |
yes | — | Follow-up message text in Slack mrkdwn format. Typically a triage report or additional context about the feedback being investigated. Supports bold, _italic_, code, code blocks, > blockquotes, and |
agent_session_url |
string |
yes | — | Your agent session URL for audit trail. Use the session URL from your system prompt. |
Show input JSON schema
{
"additionalProperties": false,
"properties": {
"thread_url": {
"description": "Slack thread URL from the original feedback post in #hydra-feedback. This is the thread where follow-up context will be appended. Example: https://airbytehq-team.slack.com/archives/C0ACUHRP6B1/p1773062711122019",
"type": "string"
},
"message": {
"description": "Follow-up message text in Slack mrkdwn format. Typically a triage report or additional context about the feedback being investigated. Supports *bold*, _italic_, `code`, ```code blocks```, > blockquotes, and <url|label> links.",
"type": "string"
},
"agent_session_url": {
"description": "Your agent session URL for audit trail. Use the session URL from your system prompt.",
"type": "string"
}
},
"required": [
"thread_url",
"message",
"agent_session_url"
],
"type": "object"
}
Show output JSON schema
{
"description": "Response from the session feedback follow-up tool.",
"properties": {
"success": {
"description": "Whether the follow-up was posted successfully",
"type": "boolean"
},
"message": {
"description": "Human-readable status message",
"type": "string"
},
"reply_ts": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Timestamp of the posted reply (Slack ts format)"
}
},
"required": [
"success",
"message"
],
"type": "object"
}
get_devin_session_name
Deterministically look up a Devin session name from its ID or URL.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
session_id |
string |
yes | — | Bare session ID or session URL. |
Show input JSON schema
{
"additionalProperties": false,
"properties": {
"session_id": {
"description": "Bare session ID or session URL.",
"type": "string"
}
},
"required": [
"session_id"
],
"type": "object"
}
Show output JSON schema
{
"description": "Response from the Devin session naming tool.",
"properties": {
"session_id": {
"description": "Resolved session ID",
"type": "string"
},
"name": {
"description": "Two-word session name",
"type": "string"
},
"full_name": {
"description": "Session name with the Devin suffix",
"type": "string"
}
},
"required": [
"session_id",
"name",
"full_name"
],
"type": "object"
}
list_devin_secrets
List all available secret names in the 1Password vault.
Returns the sorted list of item titles from the 'devin-on-demand-secrets' vault. Use this to discover valid secret aliases before calling request_devin_secret.
This dispatches a GitHub Actions workflow (which has the 1Password credentials), waits for it to complete, then reads the list from the job logs.
Parameters:
_No parameters._
Show input JSON schema
{
"additionalProperties": false,
"properties": {},
"type": "object"
}
Show output JSON schema
{
"description": "Response from the list_devin_secrets tool.",
"properties": {
"success": {
"description": "Whether the operation succeeded",
"type": "boolean"
},
"message": {
"description": "Human-readable status message",
"type": "string"
},
"available_secrets": {
"description": "Sorted list of available secret names in the vault",
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"success",
"message"
],
"type": "object"
}
request_devin_secret
Request a secret on demand via an approval workflow.
This tool operates in two phases:
Phase 1 (no approval_evidence_url): Dispatches a GitHub Actions workflow that validates the secret name against the 1Password vault and, if valid, sends a Slack approval request. If the secret name is not found, returns immediately with the list of available secret names so you can correct any typos.
Phase 2 (with approval_evidence_url): After a human approves the request, call this tool again with the approval evidence URL. This triggers a GitHub Actions workflow that reads the secret from 1Password and sends you a time-limited share link. Open the link in your browser to view and copy the secret.
Typical workflow:
- (Optional) Call list_devin_secrets first to see available names.
- Call this tool without approval_evidence_url to request approval.
- Note the
request_idin the response. - Wait for a human to approve the request in Slack.
- Obtain the approval evidence URL (Slack approval record URL).
- Call this tool again with the approval_evidence_url and the request_id from step 2.
- You will receive a 1Password share link -- open it in your browser to view and copy the secret values.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
secret_alias |
string |
yes | — | The name of the secret to request. This must exactly match an item title in the 'devin-on-demand-secrets' 1Password vault. |
session_url |
string |
yes | — | Your Devin session URL (e.g. 'https://app.devin.ai/sessions/abc123...'). Use the session URL from your system prompt. |
approval_evidence_url |
string | null |
no | null |
Slack approval record URL (https:// |
target_approver |
string | null |
no | null |
Person to notify for approval (GitHub handle, email, or Slack user ID). Required for Phase 1 (approval request). |
request_id |
string | null |
no | null |
Request ID returned by Phase 1. Pass it back in Phase 2 so the approval record can be validated against the original request. Leave empty for Phase 1. |
Show input JSON schema
{
"additionalProperties": false,
"properties": {
"secret_alias": {
"description": "The name of the secret to request. This must exactly match an item title in the 'devin-on-demand-secrets' 1Password vault.",
"type": "string"
},
"session_url": {
"description": "Your Devin session URL (e.g. 'https://app.devin.ai/sessions/abc123...'). Use the session URL from your system prompt.",
"type": "string"
},
"approval_evidence_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Slack approval record URL (https://<workspace>.slack.com/archives/...). Leave empty for Phase 1 (requesting approval). Provide the Slack URL for Phase 2 (delivering the secret after approval)."
},
"target_approver": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Person to notify for approval (GitHub handle, email, or Slack user ID). Required for Phase 1 (approval request)."
},
"request_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Request ID returned by Phase 1. Pass it back in Phase 2 so the approval record can be validated against the original request. Leave empty for Phase 1."
}
},
"required": [
"secret_alias",
"session_url"
],
"type": "object"
}
Show output JSON schema
{
"description": "Response from the request_devin_secret tool.",
"properties": {
"success": {
"description": "Whether the operation succeeded",
"type": "boolean"
},
"phase": {
"description": "Current phase: 'approval_requested' (Phase 1) or 'delivery_dispatched' (Phase 2)",
"type": "string"
},
"message": {
"description": "Human-readable status message",
"type": "string"
},
"slack_channel_url": {
"default": "https://airbytehq-team.slack.com/archives/C0AEXV81Q7N",
"description": "Direct URL to the #human-in-the-loop Slack channel",
"type": "string"
},
"secret_alias": {
"description": "The requested secret alias",
"type": "string"
},
"session_id": {
"description": "The Devin session ID",
"type": "string"
},
"workflow_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "URL to the GitHub Actions workflow"
},
"run_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "GitHub Actions workflow run ID"
},
"run_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Direct URL to the GitHub Actions workflow run"
},
"request_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Unique request identifier (UUID). Returned in Phase 1; pass it back in Phase 2 for replay-protection validation."
}
},
"required": [
"success",
"phase",
"message",
"secret_alias",
"session_id"
],
"type": "object"
}
set_devin_reminder
Schedule a reminder that fires at a specified time or after a delay.
Creates a reminder that will be delivered back to your Devin session and posted to the #devin-reminders Slack channel when the time arrives. Reminders are checked every 30 minutes via a cron schedule.
Exactly one of delay_minutes or remind_at_local_time must be provided.
Prefer remind_at_local_time (Pacific local time) over delay_minutes
to avoid timezone-conversion mistakes — unless the user explicitly
asks for a reminder in N minutes.
The reminder is stored as a GitHub Actions artifact and processed by the devin-reminders-action. When the reminder is due, it injects a message into the originating Devin session and sends a Slack notification.
Use this tool when you need to schedule a follow-up action, check on a long-running process, or remind yourself about a task.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
reminder_message |
string |
yes | — | The reminder message to deliver. Should clearly describe what you need to be reminded about. |
agent_session_url |
string |
yes | — | Your Devin session URL so the reminder can be injected back into your session. Use the session URL from your system prompt. |
delay_minutes |
integer | null |
no | null |
Number of minutes until the reminder fires. Must be a positive multiple of 30, up to 10080 (7 days). Examples: 30, 60, 120, 1440. Mutually exclusive with remind_at_local_time. |
remind_at_local_time |
string | null |
no | null |
Date-time in local time when the reminder should fire. At Airbyte, local time is always Pacific (America/Los_Angeles). Accepts '2026-04-02 09:00' (24-hour), '2026-04-02 9:00 AM' (12-hour), or ISO-like 'YYYY-MM-DDTHH:MM'. Must be in the future and within 7 days. Mutually exclusive with delay_minutes. PREFERRED — use this instead of delay_minutes to avoid timezone-conversion errors. |
slack_users_cc |
string | null |
no | null |
Optional comma-delimited list of Slack user tags to CC on the reminder notification. Example: '<@U12345>, <@U67890>'. |
Show input JSON schema
{
"additionalProperties": false,
"properties": {
"reminder_message": {
"description": "The reminder message to deliver. Should clearly describe what you need to be reminded about.",
"type": "string"
},
"agent_session_url": {
"description": "Your Devin session URL so the reminder can be injected back into your session. Use the session URL from your system prompt.",
"type": "string"
},
"delay_minutes": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "Number of minutes until the reminder fires. Must be a positive multiple of 30, up to 10080 (7 days). Examples: 30, 60, 120, 1440. Mutually exclusive with remind_at_local_time."
},
"remind_at_local_time": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Date-time in local time when the reminder should fire. At Airbyte, local time is always Pacific (America/Los_Angeles). Accepts '2026-04-02 09:00' (24-hour), '2026-04-02 9:00 AM' (12-hour), or ISO-like 'YYYY-MM-DDTHH:MM'. Must be in the future and within 7 days. Mutually exclusive with delay_minutes. PREFERRED \u2014 use this instead of delay_minutes to avoid timezone-conversion errors."
},
"slack_users_cc": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional comma-delimited list of Slack user tags to CC on the reminder notification. Example: '<@U12345>, <@U67890>'."
}
},
"required": [
"reminder_message",
"agent_session_url"
],
"type": "object"
}
Show output JSON schema
{
"description": "Response from the set_devin_reminder tool.",
"properties": {
"success": {
"description": "Whether the workflow was triggered successfully",
"type": "boolean"
},
"message": {
"description": "Human-readable status message",
"type": "string"
},
"workflow_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "URL to view the GitHub Actions workflow file"
},
"run_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "GitHub Actions workflow run ID"
},
"run_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Direct URL to the GitHub Actions workflow run"
}
},
"required": [
"success",
"message"
],
"type": "object"
}
1# Copyright (c) 2025 Airbyte, Inc., all rights reserved. 2"""MCP tools for Devin agent-session operations: reminders, on-demand secret requests, session feedback, and session naming. 3 4## MCP reference 5 6.. include:: ../../../docs/mcp-generated/devin_ops.md 7 :start-line: 2 8""" 9 10# NOTE: We intentionally do NOT use `from __future__ import annotations` here. 11# FastMCP has issues resolving forward references when PEP 563 deferred annotations 12# are used. See: https://github.com/jlowin/fastmcp/issues/905 13# Python 3.12+ supports modern type hint syntax natively, so this is not needed. 14 15__all__: list[str] = [] 16 17import json 18import logging 19import re 20import zipfile 21from enum import StrEnum 22from typing import Annotated, Literal 23 24import requests 25from fastmcp import FastMCP 26from fastmcp_extensions import mcp_tool, register_mcp_tools 27from pydantic import BaseModel, Field 28 29from airbyte_ops_mcp.devin_reminders import dispatch_cancel_reminder, dispatch_reminder 30from airbyte_ops_mcp.github_actions import ( 31 WorkflowDispatchResult, 32 download_job_logs, 33 get_workflow_jobs, 34 resolve_default_workflow_branch, 35 trigger_workflow_dispatch, 36 wait_for_workflow_completion, 37) 38from airbyte_ops_mcp.github_api import resolve_ci_trigger_github_token 39from airbyte_ops_mcp.human_in_the_loop import ( 40 HITL_SLACK_CHANNEL_URL, 41 dispatch_escalation, 42) 43from airbyte_ops_mcp.session_namer import ( 44 extract_session_id, 45 generate_friendly_name, 46) 47from airbyte_ops_mcp.slack_api import SlackAPIError, SlackURLParseError 48from airbyte_ops_mcp.slack_posting import ( 49 SlackPostResult, 50 parse_slack_thread_url, 51 post_channel_message, 52 post_thread_reply, 53 send_hitl_notification, 54) 55 56 57class SetDevinReminderResponse(BaseModel): 58 """Response from the set_devin_reminder tool.""" 59 60 success: bool = Field(description="Whether the workflow was triggered successfully") 61 message: str = Field(description="Human-readable status message") 62 workflow_url: str | None = Field( 63 default=None, 64 description="URL to view the GitHub Actions workflow file", 65 ) 66 run_id: int | None = Field( 67 default=None, 68 description="GitHub Actions workflow run ID", 69 ) 70 run_url: str | None = Field( 71 default=None, 72 description="Direct URL to the GitHub Actions workflow run", 73 ) 74 75 76@mcp_tool( 77 read_only=False, 78 idempotent=False, 79 open_world=True, 80) 81def set_devin_reminder( 82 reminder_message: Annotated[ 83 str, 84 "The reminder message to deliver. Should clearly describe what " 85 "you need to be reminded about.", 86 ], 87 agent_session_url: Annotated[ 88 str, 89 "Your Devin session URL so the reminder can be injected back into " 90 "your session. Use the session URL from your system prompt.", 91 ], 92 delay_minutes: Annotated[ 93 int | None, 94 "Number of minutes until the reminder fires. Must be a positive " 95 "multiple of 30, up to 10080 (7 days). Examples: 30, 60, 120, 1440. " 96 "Mutually exclusive with remind_at_local_time.", 97 ] = None, 98 remind_at_local_time: Annotated[ 99 str | None, 100 "Date-time in local time when the reminder should fire. " 101 "At Airbyte, local time is always Pacific (America/Los_Angeles). " 102 "Accepts '2026-04-02 09:00' (24-hour), " 103 "'2026-04-02 9:00 AM' (12-hour), or ISO-like 'YYYY-MM-DDTHH:MM'. " 104 "Must be in the future and within 7 days. " 105 "Mutually exclusive with delay_minutes. " 106 "PREFERRED — use this instead of delay_minutes to avoid " 107 "timezone-conversion errors.", 108 ] = None, 109 slack_users_cc: Annotated[ 110 str | None, 111 "Optional comma-delimited list of Slack user tags to CC on the " 112 "reminder notification. Example: '<@U12345>, <@U67890>'.", 113 ] = None, 114) -> SetDevinReminderResponse: 115 """Schedule a reminder that fires at a specified time or after a delay. 116 117 Creates a reminder that will be delivered back to your Devin session 118 and posted to the #devin-reminders Slack channel when the time arrives. 119 Reminders are checked every 30 minutes via a cron schedule. 120 121 Exactly one of `delay_minutes` or `remind_at_local_time` must be provided. 122 Prefer `remind_at_local_time` (Pacific local time) over `delay_minutes` 123 to avoid timezone-conversion mistakes — unless the user explicitly 124 asks for a reminder in N minutes. 125 126 The reminder is stored as a GitHub Actions artifact and processed by 127 the devin-reminders-action. When the reminder is due, it injects a 128 message into the originating Devin session and sends a Slack notification. 129 130 Use this tool when you need to schedule a follow-up action, check on 131 a long-running process, or remind yourself about a task. 132 """ 133 try: 134 result = dispatch_reminder( 135 delay_minutes=delay_minutes, 136 remind_at_local_time=remind_at_local_time, 137 reminder_message=reminder_message, 138 agent_session_url=agent_session_url, 139 slack_users_cc=slack_users_cc, 140 ) 141 except ValueError as e: 142 return SetDevinReminderResponse( 143 success=False, 144 message=f"Invalid input: {e}", 145 ) 146 147 if remind_at_local_time: 148 time_desc = f"at {remind_at_local_time} Pacific" 149 else: 150 time_desc = f"in {delay_minutes} minutes" 151 152 view_url = result.run_url or result.workflow_url 153 return SetDevinReminderResponse( 154 success=True, 155 message=( 156 f"Reminder scheduled to fire {time_desc}. " 157 f"View progress at: {view_url}\n\n" 158 f"To cancel pending reminders for this session, use the " 159 f"`cancel_devin_reminder` tool." 160 ), 161 workflow_url=result.workflow_url, 162 run_id=result.run_id, 163 run_url=result.run_url, 164 ) 165 166 167class CancelDevinReminderResponse(BaseModel): 168 """Response from the cancel_devin_reminder tool.""" 169 170 success: bool = Field( 171 description="Whether the cancel workflow was triggered successfully" 172 ) 173 message: str = Field(description="Human-readable status message") 174 workflow_url: str | None = Field( 175 default=None, 176 description="URL to view the GitHub Actions workflow file", 177 ) 178 run_id: int | None = Field( 179 default=None, 180 description="GitHub Actions workflow run ID", 181 ) 182 run_url: str | None = Field( 183 default=None, 184 description="Direct URL to the GitHub Actions workflow run", 185 ) 186 187 188@mcp_tool( 189 read_only=False, 190 idempotent=False, 191 open_world=True, 192) 193def cancel_devin_reminder( 194 agent_session_url: Annotated[ 195 str, 196 "Your Devin session URL. Use the session URL from your system prompt. " 197 "Required together with cancel_guids.", 198 ], 199 cancel_guids: Annotated[ 200 list[str], 201 "List of reminder GUIDs to cancel. You can get GUIDs from " 202 "the reminder creation response or from the reminders list.", 203 ], 204) -> CancelDevinReminderResponse: 205 """Cancel pending Devin reminders by session URL and specific GUIDs. 206 207 Removes matching reminders so they will not fire. Use this when instructed 208 to stop reminders, or when a reminder is no longer needed. 209 210 Both agent_session_url and cancel_guids are required. Only reminders 211 matching the session URL AND present in the GUID list are cancelled. 212 """ 213 try: 214 result = dispatch_cancel_reminder( 215 agent_session_url=agent_session_url, 216 cancel_guids=cancel_guids, 217 ) 218 except ValueError as e: 219 return CancelDevinReminderResponse( 220 success=False, 221 message=f"Invalid input: {e}", 222 ) 223 224 view_url = result.run_url or result.workflow_url 225 guid_list = ", ".join(cancel_guids) 226 return CancelDevinReminderResponse( 227 success=True, 228 message=( 229 f"Cancel workflow triggered for GUIDs [{guid_list}] " 230 f"in session {agent_session_url}. View progress at: {view_url}" 231 ), 232 workflow_url=result.workflow_url, 233 run_id=result.run_id, 234 run_url=result.run_url, 235 ) 236 237 238logger = logging.getLogger(__name__) 239 240WORKFLOW_REPO_OWNER = "airbytehq" 241 242WORKFLOW_REPO_NAME = "airbyte-ops-mcp" 243 244WORKFLOW_FILE = "devin-secret-request.yml" 245 246WORKFLOW_DEFAULT_BRANCH = "main" 247 248_SESSION_ID_PATTERN = re.compile(r"[0-9a-fA-F]{32}") 249 250 251class SecretListResponse(BaseModel): 252 """Response from the list_devin_secrets tool.""" 253 254 success: bool = Field(description="Whether the operation succeeded") 255 message: str = Field(description="Human-readable status message") 256 available_secrets: list[str] = Field( 257 default_factory=list, 258 description="Sorted list of available secret names in the vault", 259 ) 260 261 262class SecretRequestResponse(BaseModel): 263 """Response from the request_devin_secret tool.""" 264 265 success: bool = Field(description="Whether the operation succeeded") 266 phase: str = Field( 267 description=( 268 "Current phase: 'approval_requested' (Phase 1) or " 269 "'delivery_dispatched' (Phase 2)" 270 ), 271 ) 272 message: str = Field(description="Human-readable status message") 273 slack_channel_url: str = Field( 274 default=HITL_SLACK_CHANNEL_URL, 275 description="Direct URL to the #human-in-the-loop Slack channel", 276 ) 277 secret_alias: str = Field(description="The requested secret alias") 278 session_id: str = Field(description="The Devin session ID") 279 workflow_url: str | None = Field( 280 default=None, 281 description="URL to the GitHub Actions workflow", 282 ) 283 run_id: int | None = Field( 284 default=None, 285 description="GitHub Actions workflow run ID", 286 ) 287 run_url: str | None = Field( 288 default=None, 289 description="Direct URL to the GitHub Actions workflow run", 290 ) 291 request_id: str | None = Field( 292 default=None, 293 description=( 294 "Unique request identifier (UUID). Returned in Phase 1; " 295 "pass it back in Phase 2 for replay-protection validation." 296 ), 297 ) 298 299 300@mcp_tool( 301 read_only=False, 302 idempotent=False, 303 open_world=True, 304) 305def list_devin_secrets() -> SecretListResponse: 306 """List all available secret names in the 1Password vault. 307 308 Returns the sorted list of item titles from the 309 'devin-on-demand-secrets' vault. Use this to discover valid 310 secret aliases before calling request_devin_secret. 311 312 This dispatches a GitHub Actions workflow (which has the 313 1Password credentials), waits for it to complete, then reads 314 the list from the job logs. 315 """ 316 return _list_secrets_via_workflow() 317 318 319@mcp_tool( 320 read_only=False, 321 idempotent=False, 322 open_world=True, 323) 324def request_devin_secret( 325 secret_alias: Annotated[ 326 str, 327 "The name of the secret to request. This must exactly match an item " 328 "title in the 'devin-on-demand-secrets' 1Password vault.", 329 ], 330 session_url: Annotated[ 331 str, 332 "Your Devin session URL (e.g. 'https://app.devin.ai/sessions/abc123...'). " 333 "Use the session URL from your system prompt.", 334 ], 335 approval_evidence_url: Annotated[ 336 str | None, 337 "Slack approval record URL " 338 "(https://<workspace>.slack.com/archives/...). " 339 "Leave empty for Phase 1 (requesting approval). Provide the " 340 "Slack URL for Phase 2 (delivering the secret after approval).", 341 ] = None, 342 target_approver: Annotated[ 343 str | None, 344 "Person to notify for approval (GitHub handle, email, or Slack user ID). " 345 "Required for Phase 1 (approval request).", 346 ] = None, 347 request_id: Annotated[ 348 str | None, 349 "Request ID returned by Phase 1. Pass it back in Phase 2 " 350 "so the approval record can be validated against the original request. " 351 "Leave empty for Phase 1.", 352 ] = None, 353) -> SecretRequestResponse: 354 """Request a secret on demand via an approval workflow. 355 356 This tool operates in two phases: 357 358 **Phase 1** (no approval_evidence_url): Dispatches a GitHub Actions 359 workflow that validates the secret name against the 1Password vault 360 and, if valid, sends a Slack approval request. If the secret name is 361 not found, returns immediately with the list of available secret 362 names so you can correct any typos. 363 364 **Phase 2** (with approval_evidence_url): After a human approves the 365 request, call this tool again with the approval evidence URL. This 366 triggers a GitHub Actions workflow that reads the secret from 367 1Password and sends you a time-limited share link. 368 Open the link in your browser to view and copy the secret. 369 370 Typical workflow: 371 0. (Optional) Call list_devin_secrets first to see available names. 372 1. Call this tool without approval_evidence_url to request approval. 373 2. Note the `request_id` in the response. 374 3. Wait for a human to approve the request in Slack. 375 4. Obtain the approval evidence URL (Slack approval record URL). 376 5. Call this tool again with the approval_evidence_url **and** the 377 request_id from step 2. 378 6. You will receive a 1Password share link -- open it in your 379 browser to view and copy the secret values. 380 """ 381 # Extract session ID from URL 382 match = _SESSION_ID_PATTERN.search(session_url) 383 if not match: 384 return SecretRequestResponse( 385 success=False, 386 phase="error", 387 message=( 388 f"No valid session ID found in URL: {session_url}. " 389 "Expected a 32-character hex string." 390 ), 391 secret_alias=secret_alias, 392 session_id="", 393 ) 394 session_id = match.group(0) 395 396 if not approval_evidence_url: 397 # Phase 1: Dispatch the request workflow (validates secret name 398 # inline using op CLI, then sends Slack approval if valid). 399 if not target_approver: 400 return SecretRequestResponse( 401 success=False, 402 phase="error", 403 message=( 404 "target_approver is required when requesting approval " 405 "(no approval_evidence_url provided)." 406 ), 407 secret_alias=secret_alias, 408 session_id=session_id, 409 ) 410 411 return _request_secret_via_workflow( 412 secret_alias=secret_alias, 413 session_id=session_id, 414 session_url=session_url, 415 target_approver=target_approver, 416 ) 417 418 # Phase 2: Deliver secret via GitHub Actions workflow 419 token = resolve_ci_trigger_github_token() 420 421 workflow_inputs: dict[str, str] = { 422 "action": "deliver", 423 "secret_alias": secret_alias, 424 "session_id": session_id, 425 "approval_evidence_url": approval_evidence_url, 426 } 427 if request_id: 428 workflow_inputs["expected_request_id"] = request_id 429 430 result = trigger_workflow_dispatch( 431 owner=WORKFLOW_REPO_OWNER, 432 repo=WORKFLOW_REPO_NAME, 433 workflow_file=WORKFLOW_FILE, 434 ref=resolve_default_workflow_branch(WORKFLOW_DEFAULT_BRANCH), 435 inputs=workflow_inputs, 436 token=token, 437 ) 438 439 view_url = result.run_url or result.workflow_url 440 return SecretRequestResponse( 441 success=True, 442 phase="delivery_dispatched", 443 message=( 444 f"Secret delivery workflow dispatched for '{secret_alias}'. " 445 f"The workflow will read the secret from 1Password and send " 446 f"you a time-limited share link. Once you receive the link, " 447 f"open it in your browser to view and copy the secret. " 448 f"View progress: {view_url}" 449 ), 450 secret_alias=secret_alias, 451 session_id=session_id, 452 workflow_url=result.workflow_url, 453 run_id=result.run_id, 454 run_url=result.run_url, 455 request_id=request_id, 456 ) 457 458 459def _request_secret_via_workflow( 460 secret_alias: str, 461 session_id: str, 462 session_url: str, 463 target_approver: str, 464) -> SecretRequestResponse: 465 """Dispatch the request workflow, wait, and parse the result from job logs. 466 467 The workflow validates the secret alias against the vault inline, 468 then sends the Slack approval if valid. On a bad alias the workflow 469 fails and the job logs contain a JSON object with `available_secrets`. 470 """ 471 token = resolve_ci_trigger_github_token() 472 473 dispatch_result = trigger_workflow_dispatch( 474 owner=WORKFLOW_REPO_OWNER, 475 repo=WORKFLOW_REPO_NAME, 476 workflow_file=WORKFLOW_FILE, 477 ref=resolve_default_workflow_branch(WORKFLOW_DEFAULT_BRANCH), 478 inputs={ 479 "action": "request", 480 "secret_alias": secret_alias, 481 "session_id": session_id, 482 "target_approver": target_approver, 483 }, 484 token=token, 485 ) 486 if not dispatch_result.run_id: 487 return SecretRequestResponse( 488 success=False, 489 phase="error", 490 message=( 491 "Workflow dispatched but no run ID returned. " 492 f"Check: {dispatch_result.workflow_url}" 493 ), 494 secret_alias=secret_alias, 495 session_id=session_id, 496 workflow_url=dispatch_result.workflow_url, 497 ) 498 499 run_status = wait_for_workflow_completion( 500 owner=WORKFLOW_REPO_OWNER, 501 repo=WORKFLOW_REPO_NAME, 502 run_id=dispatch_result.run_id, 503 token=token, 504 ) 505 506 # Download logs from the validation job (multi-job workflow) 507 raw_logs = _download_run_logs( 508 dispatch_result.run_id, token, job_name="Validate Secret Name" 509 ) 510 511 if run_status.succeeded: 512 # Parse the approval-requested JSON from the logs 513 result_data = _find_json_in_logs(raw_logs, "phase") if raw_logs else None 514 request_id = result_data.get("request_id") if result_data else None 515 view_url = run_status.run_url or dispatch_result.workflow_url 516 return SecretRequestResponse( 517 success=True, 518 phase="approval_requested", 519 message=( 520 f"Approval request for secret '{secret_alias}' sent to " 521 f"#human-in-the-loop ({HITL_SLACK_CHANNEL_URL}). " 522 f"Waiting for human approval. " 523 f"Once approved, call this tool again with the " 524 f"approval_evidence_url to deliver the secret. " 525 f"View progress: {view_url}" 526 ), 527 secret_alias=secret_alias, 528 session_id=session_id, 529 workflow_url=dispatch_result.workflow_url, 530 run_id=dispatch_result.run_id, 531 run_url=run_status.run_url, 532 request_id=request_id, 533 ) 534 535 # Workflow failed — check if it was a validation failure 536 error_data = _find_json_in_logs(raw_logs, "available_secrets") if raw_logs else None 537 if error_data: 538 available = error_data.get("available_secrets", []) 539 formatted = ", ".join(f"`{s}`" for s in available) 540 return SecretRequestResponse( 541 success=False, 542 phase="validation_failed", 543 message=( 544 f"Secret '{secret_alias}' not found in the vault. " 545 f"Available secrets: {formatted}" 546 ), 547 secret_alias=secret_alias, 548 session_id=session_id, 549 workflow_url=dispatch_result.workflow_url, 550 run_id=dispatch_result.run_id, 551 run_url=run_status.run_url, 552 ) 553 554 # Generic workflow failure 555 return SecretRequestResponse( 556 success=False, 557 phase="error", 558 message=( 559 f"Request workflow failed (conclusion={run_status.conclusion}). " 560 f"See: {run_status.run_url}" 561 ), 562 secret_alias=secret_alias, 563 session_id=session_id, 564 workflow_url=dispatch_result.workflow_url, 565 run_id=dispatch_result.run_id, 566 run_url=run_status.run_url, 567 ) 568 569 570def _download_run_logs( 571 run_id: int, 572 token: str, 573 *, 574 job_name: str | None = None, 575) -> str | None: 576 """Best-effort download of a job's logs for a workflow run. 577 578 Args: 579 run_id: GitHub Actions workflow run ID. 580 token: GitHub API token used for log download. Note: job listing 581 uses `get_workflow_jobs` which resolves its own token via 582 `resolve_ci_trigger_github_token()`. 583 job_name: If provided, find the job whose name contains this 584 substring (case-insensitive). Skipped jobs are always 585 excluded. Falls back to the first non-skipped job. 586 """ 587 try: 588 jobs = get_workflow_jobs( 589 owner=WORKFLOW_REPO_OWNER, 590 repo=WORKFLOW_REPO_NAME, 591 run_id=run_id, 592 ) 593 # Filter out skipped jobs (common in multi-job conditional workflows) 594 active_jobs = [j for j in jobs if j.conclusion != "skipped"] 595 if not active_jobs: 596 return None 597 598 target = active_jobs[0] # default: first non-skipped job 599 if job_name: 600 needle = job_name.lower() 601 for j in active_jobs: 602 if needle in j.name.lower(): 603 target = j 604 break 605 606 return download_job_logs( 607 owner=WORKFLOW_REPO_OWNER, 608 repo=WORKFLOW_REPO_NAME, 609 job_id=target.job_id, 610 token=token, 611 ) 612 except (requests.HTTPError, ValueError) as exc: 613 logger.warning("Failed to download job logs for run %s: %s", run_id, exc) 614 return None 615 616 617def _list_secrets_via_workflow() -> SecretListResponse: 618 """Dispatch the list workflow, wait for completion, and parse titles from job logs.""" 619 token = resolve_ci_trigger_github_token() 620 621 # 1. Dispatch the workflow with action="list" 622 dispatch_result = trigger_workflow_dispatch( 623 owner=WORKFLOW_REPO_OWNER, 624 repo=WORKFLOW_REPO_NAME, 625 workflow_file=WORKFLOW_FILE, 626 ref=resolve_default_workflow_branch(WORKFLOW_DEFAULT_BRANCH), 627 inputs={"action": "list", "session_id": "0" * 32}, 628 token=token, 629 ) 630 if not dispatch_result.run_id: 631 return SecretListResponse( 632 success=False, 633 message=( 634 "Workflow dispatched but no run ID returned. " 635 f"Check: {dispatch_result.workflow_url}" 636 ), 637 ) 638 639 # 2. Wait for the workflow to complete 640 run_status = wait_for_workflow_completion( 641 owner=WORKFLOW_REPO_OWNER, 642 repo=WORKFLOW_REPO_NAME, 643 run_id=dispatch_result.run_id, 644 token=token, 645 ) 646 if not run_status.succeeded: 647 return SecretListResponse( 648 success=False, 649 message=( 650 f"Workflow run failed (conclusion={run_status.conclusion}). " 651 f"See: {run_status.run_url}" 652 ), 653 ) 654 655 # 3. Find the job and download its logs 656 jobs = get_workflow_jobs( 657 owner=WORKFLOW_REPO_OWNER, 658 repo=WORKFLOW_REPO_NAME, 659 run_id=dispatch_result.run_id, 660 ) 661 if not jobs: 662 return SecretListResponse( 663 success=False, 664 message="Workflow completed but no jobs found.", 665 ) 666 667 # Find the list job (multi-job workflow; skip skipped jobs) 668 active_jobs = [j for j in jobs if j.conclusion != "skipped"] 669 if not active_jobs: 670 return SecretListResponse( 671 success=False, 672 message="Workflow completed but all jobs were skipped.", 673 ) 674 675 target_job = active_jobs[0] 676 for j in active_jobs: 677 if "list" in j.name.lower(): 678 target_job = j 679 break 680 681 raw_logs = download_job_logs( 682 owner=WORKFLOW_REPO_OWNER, 683 repo=WORKFLOW_REPO_NAME, 684 job_id=target_job.job_id, 685 token=token, 686 ) 687 688 # 4. Parse JSON output from the logs 689 data = _find_json_in_logs(raw_logs, "available_secrets") 690 if data is None: 691 return SecretListResponse( 692 success=False, 693 message=( 694 "Could not parse secret list from workflow logs. " 695 f"See: {run_status.run_url}" 696 ), 697 ) 698 699 secrets = data.get("available_secrets", []) 700 titles = [str(t) for t in secrets] if isinstance(secrets, list) else [] 701 return SecretListResponse( 702 success=True, 703 message=f"Found {len(titles)} available secrets in the vault.", 704 available_secrets=sorted(titles), 705 ) 706 707 708def _find_json_in_logs(raw_logs: str, required_key: str) -> dict | None: 709 """Find the first JSON object in job logs that contains *required_key*. 710 711 GitHub Actions job logs prefix each line with a timestamp. We scan 712 every line looking for a JSON object that contains the given key. 713 Returns the parsed dict, or `None` if not found. 714 """ 715 for line in raw_logs.splitlines(): 716 stripped = line.strip() 717 if not stripped.startswith("{"): 718 idx = stripped.find("{") 719 if idx < 0: 720 continue 721 stripped = stripped[idx:] 722 try: 723 data = json.loads(stripped) 724 except json.JSONDecodeError: 725 continue 726 if isinstance(data, dict) and required_key in data: 727 return data 728 return None 729 730 731_FEEDBACK_CHANNEL = "C0ACUHRP6B1" 732 733_FEEDBACK_CC_USERGROUPS = [ 734 "S0BJ4K3LC4X", # @oc-hydra 735 "S0BKR63VAN5", # @oc-internal-ai 736] 737 738FeedbackArea = Literal["db_sources"] 739 740_FEEDBACK_AREA_CC: dict[str, list[str]] = { 741 # DB source connector feedback goes to Sophie Cui instead of @oc-hydra. 742 "db_sources": ["U098P1QAELQ", "S0BKR63VAN5"], # Sophie Cui, @oc-internal-ai 743} 744 745 746def _feedback_cc(feedback_area: str | None) -> list[str]: 747 if feedback_area: 748 return list(_FEEDBACK_AREA_CC[feedback_area]) 749 return list(_FEEDBACK_CC_USERGROUPS) 750 751 752_TRIAGE_REPO_OWNER = "airbytehq" 753 754_TRIAGE_REPO_NAME = "airbyte-ops-mcp" 755 756_TRIAGE_WORKFLOW_FILE = "devin-session-triage.yml" 757 758_TRIAGE_DEFAULT_BRANCH = "main" 759 760_AI_SKILLS_REPO_URL = "https://github.com/airbytehq/ai-skills" 761 762_INTERNAL_SKILLS_URL = ( 763 "https://internal.airbyte.ai/docs/internal-docs/ai-engineering/skills" 764) 765 766_PLAYBOOK_ID_PATTERN = re.compile(r"^[a-z0-9_-]+$") 767 768_SKILL_ID_PATTERN = re.compile(r"^[a-z0-9-]+$") 769 770_CATEGORY_DISPLAY: dict[str, str] = { 771 "tool_failure": "Tool Failure", 772 "missing_guidance": "Missing Guidance", 773 "suspected_hallucination": "Suspected Hallucination", 774 "bad_approach": "Bad Approach", 775 "excessive_iteration": "Excessive Iteration", 776 "poor_quality": "Poor Quality", 777 "other_concern": "Other Concern", 778 "great_results": "Great Results", 779 "exceeded_expectations": "Exceeded Expectations", 780 "fast_completion": "Fast Completion", 781 "good_communication": "Good Communication", 782 "other_positive_feedback": "Other Positive Feedback", 783} 784 785 786class FeedbackCategory(StrEnum): 787 """Feedback categories for Devin session reports.""" 788 789 # Negative categories 790 TOOL_FAILURE = "tool_failure" 791 MISSING_GUIDANCE = "missing_guidance" 792 SUSPECTED_HALLUCINATION = "suspected_hallucination" 793 BAD_APPROACH = "bad_approach" 794 EXCESSIVE_ITERATION = "excessive_iteration" 795 POOR_QUALITY = "poor_quality" 796 OTHER_CONCERN = "other_concern" 797 798 # Positive categories 799 GREAT_RESULTS = "great_results" 800 EXCEEDED_EXPECTATIONS = "exceeded_expectations" 801 FAST_COMPLETION = "fast_completion" 802 GOOD_COMMUNICATION = "good_communication" 803 OTHER_POSITIVE_FEEDBACK = "other_positive_feedback" 804 805 def is_negative(self) -> bool: 806 """Return True if this is a negative feedback category.""" 807 return self in _NEGATIVE_MEMBERS 808 809 def display_name(self) -> str: 810 """Return the human-readable display name for this category.""" 811 return _CATEGORY_DISPLAY.get(self.value, self.value) 812 813 814_NEGATIVE_MEMBERS = frozenset( 815 { 816 FeedbackCategory.TOOL_FAILURE, 817 FeedbackCategory.MISSING_GUIDANCE, 818 FeedbackCategory.SUSPECTED_HALLUCINATION, 819 FeedbackCategory.BAD_APPROACH, 820 FeedbackCategory.EXCESSIVE_ITERATION, 821 FeedbackCategory.POOR_QUALITY, 822 FeedbackCategory.OTHER_CONCERN, 823 } 824) 825 826_SEVERITY_DISPLAY: dict[str, str] = { 827 "low": "Low", 828 "medium": "Medium", 829 "high": "High", 830 "critical": "Critical", 831} 832 833 834def _feedback_emoji(feedback_type: str) -> str: 835 """Return the header emoji for the given feedback type.""" 836 return ":tada:" if feedback_type == "positive" else ":warning:" 837 838 839def _feedback_label(feedback_type: str) -> str: 840 """Return the header label for the given feedback type.""" 841 type_display = "Positive" if feedback_type == "positive" else "Negative" 842 return f"Devin Session Feedback ({type_display})" 843 844 845def _format_playbook_link(playbook_id: str) -> str: 846 """Return Slack mrkdwn for a playbook identifier.""" 847 if playbook_id == "none": 848 return "none" 849 return f"<{_AI_SKILLS_REPO_URL}/blob/main/devin/playbooks/{playbook_id}.md|{playbook_id}>" 850 851 852def _format_skill_link(skill_id: str) -> str: 853 """Return Slack mrkdwn for a skill identifier.""" 854 return f"<{_INTERNAL_SKILLS_URL}/#{skill_id}|{skill_id}>" 855 856 857def _validate_playbook_id(playbook_id: str) -> str | None: 858 """Return an error message if `playbook_id` is not a valid playbook identifier.""" 859 if playbook_id == "none" or _PLAYBOOK_ID_PATTERN.fullmatch(playbook_id): 860 return None 861 return "session_playbook must be 'none' or a lowercase playbook ID using only letters, numbers, '-' and '_'." 862 863 864def _validate_skill_id(skill_id: str | None) -> str | None: 865 """Return an error message if `skill_id` is not a valid skill identifier.""" 866 if skill_id is None or _SKILL_ID_PATTERN.fullmatch(skill_id): 867 return None 868 return "related_skill_name must be a lowercase skill ID using only letters, numbers, and '-'." 869 870 871def _build_feedback_body( 872 *, 873 feedback_type: str, 874 category: str, 875 task_description: str, 876 session_playbook: str, 877 related_skill_name: str | None, 878 expected_behavior: str | None, 879 observed_behavior: str | None, 880 what_went_well: str | None, 881 severity: str | None, 882 steps_to_reproduce: str | None, 883 session_to_evaluate: str | None = None, 884 thread_url: str | None = None, 885) -> str: 886 """Build a Slack mrkdwn message body from structured feedback fields.""" 887 lines: list[str] = [] 888 889 cat = FeedbackCategory(category) 890 lines.append(f"*Category:* {cat.display_name()}") 891 892 if severity: 893 sev_display = _SEVERITY_DISPLAY.get(severity, severity) 894 lines.append(f"*Severity:* {sev_display}") 895 896 lines.append("") 897 lines.append(f"*Task:* {task_description}") 898 lines.append(f"*Session Playbook:* {_format_playbook_link(session_playbook)}") 899 if related_skill_name: 900 lines.append(f"*Related Skill:* {_format_skill_link(related_skill_name)}") 901 if not session_to_evaluate: 902 lines.extend( 903 [ 904 "", 905 "*Session link missing:* Please provide the Devin session URL so " 906 "the team can inspect it.", 907 ] 908 ) 909 if thread_url: 910 lines.append(f"*Originating thread:* <{thread_url}|Slack thread>") 911 912 if feedback_type == "negative": 913 if expected_behavior: 914 lines.append("") 915 lines.append(f"*Expected Behavior:* {expected_behavior}") 916 if observed_behavior: 917 lines.append("") 918 lines.append(f"*Observed Behavior:* {observed_behavior}") 919 if steps_to_reproduce: 920 lines.append("") 921 lines.append(f"*Steps to Reproduce:* {steps_to_reproduce}") 922 else: 923 if what_went_well: 924 lines.append("") 925 lines.append(f"*What Went Well:* {what_went_well}") 926 927 if feedback_type == "negative": 928 lines.append("") 929 lines.append( 930 "_Auto-triage: a Devin session with v3 analyze mode will inspect the " 931 "linked session when one is provided._" 932 ) 933 934 return "\n".join(lines) 935 936 937def _validate_negative_fields( 938 expected_behavior: str | None, 939 observed_behavior: str | None, 940) -> str | None: 941 """Return an error message if required negative feedback fields are missing.""" 942 missing: list[str] = [] 943 if not expected_behavior: 944 missing.append("expected_behavior") 945 if not observed_behavior: 946 missing.append("observed_behavior") 947 if missing: 948 return f"Negative feedback requires: {', '.join(missing)}." 949 return None 950 951 952def _validate_positive_fields( 953 what_went_well: str | None, 954) -> str | None: 955 """Return an error message if required positive feedback fields are missing.""" 956 if not what_went_well: 957 return "Positive feedback requires: what_went_well." 958 return None 959 960 961def _dispatch_triage_workflow( 962 session_url: str, 963 feedback_context: str, 964 reporting_user: str, 965 session_playbook: str, 966 related_skill_name: str | None = None, 967 cc_persons: str = "", 968 header_emoji: str = "", 969 header_label: str = "", 970 linear_issue_url: str = "", 971 linear_issue_id: str = "", 972 thread_url: str = "", 973) -> WorkflowDispatchResult | None: 974 """Dispatch the v3 session triage workflow. 975 976 The triage workflow launches a Devin session with v3 analyze mode and 977 posts a single Slack notification via the HITL reusable workflow. 978 Formatting params (emoji, header, cc) are passed through to the HITL 979 notification so the caller doesn't need to post separately. 980 981 Returns the dispatch result, or None if dispatch fails. 982 """ 983 token = resolve_ci_trigger_github_token() 984 inputs: dict[str, str] = { 985 "session_url": session_url, 986 "feedback_context": feedback_context, 987 "reporting_user": reporting_user, 988 "session_playbook": session_playbook, 989 } 990 if related_skill_name: 991 inputs["related_skill_name"] = related_skill_name 992 if cc_persons: 993 inputs["cc_persons"] = cc_persons 994 if header_emoji: 995 inputs["header_emoji"] = header_emoji 996 if header_label: 997 inputs["header_label"] = header_label 998 if linear_issue_url: 999 inputs["linear_issue_url"] = linear_issue_url 1000 if linear_issue_id: 1001 inputs["linear_issue_id"] = linear_issue_id 1002 if thread_url: 1003 inputs["thread_url"] = thread_url 1004 try: 1005 return trigger_workflow_dispatch( 1006 owner=_TRIAGE_REPO_OWNER, 1007 repo=_TRIAGE_REPO_NAME, 1008 workflow_file=_TRIAGE_WORKFLOW_FILE, 1009 ref=resolve_default_workflow_branch(_TRIAGE_DEFAULT_BRANCH), 1010 inputs=inputs, 1011 token=token, 1012 ) 1013 except requests.HTTPError: 1014 logger.exception("Failed to dispatch triage workflow") 1015 return None 1016 1017 1018_LINEAR_ISSUE_KEY_PATTERN = re.compile(r"/issue/([A-Z][A-Z0-9]*-\d+)") 1019 1020 1021def _linear_issue_key(issue_url: str | None, issue_id: str | None) -> str | None: 1022 """Human-readable issue key such as `HYD-137`, taken from the issue URL. 1023 1024 The issue identifier is recovered from the URL when possible and otherwise 1025 uses the supplied identifier. 1026 """ 1027 if issue_url: 1028 match = _LINEAR_ISSUE_KEY_PATTERN.search(issue_url) 1029 if match: 1030 return match.group(1) 1031 return issue_id or None 1032 1033 1034def _originating_thread_note( 1035 permalink: str | None, 1036 error: SlackAPIError | SlackURLParseError | requests.RequestException | None, 1037) -> str: 1038 if permalink: 1039 return f"Thread reply posted at {permalink}." 1040 if error: 1041 return f"Posting to the originating thread failed: {error}." 1042 return "" 1043 1044 1045def _post_feedback_report( 1046 message: str, 1047 thread_url: str | None = None, 1048 *, 1049 target_person: str | None = None, 1050 agent_session_url: str | None = None, 1051 cc_persons: list[str] | None = None, 1052 issue_url: str | None = None, 1053 header_emoji: str = "\U0001f64b", 1054 header_label: str = "Human-in-the-loop request", 1055) -> str: 1056 """Post feedback to the channel or an existing Slack thread.""" 1057 thread_ts: str | None = None 1058 if thread_url: 1059 channel_id, thread_ts = parse_slack_thread_url(thread_url) 1060 else: 1061 channel_id = _FEEDBACK_CHANNEL 1062 1063 def post_plain() -> SlackPostResult: 1064 if thread_ts: 1065 return post_thread_reply( 1066 channel_id=channel_id, 1067 thread_ts=thread_ts, 1068 message=message, 1069 ) 1070 return post_channel_message(channel_id, message) 1071 1072 if target_person is None: 1073 return post_plain().permalink 1074 1075 try: 1076 result = send_hitl_notification( 1077 target_person=target_person, 1078 message=message, 1079 agent_session_url=agent_session_url or "", 1080 cc_persons=cc_persons, 1081 issue_url=issue_url, 1082 channel_override=channel_id, 1083 header_emoji=header_emoji, 1084 header_label=header_label, 1085 thread_ts=thread_ts, 1086 ) 1087 except ( 1088 SlackAPIError, 1089 RuntimeError, 1090 requests.RequestException, 1091 zipfile.BadZipFile, 1092 ) as exc: 1093 logger.warning( 1094 "Rich feedback notification failed; falling back to plain-text posting: %s", 1095 exc, 1096 ) 1097 result = post_plain() 1098 return result.permalink 1099 1100 1101class SessionFeedbackResponse(BaseModel): 1102 """Response from the session feedback tool.""" 1103 1104 success: bool = Field(description="Whether the workflow was triggered successfully") 1105 message: str = Field(description="Human-readable status message") 1106 workflow_url: str | None = Field( 1107 default=None, 1108 description="URL to view the GitHub Actions workflow file", 1109 ) 1110 run_id: int | None = Field( 1111 default=None, 1112 description="GitHub Actions workflow run ID", 1113 ) 1114 run_url: str | None = Field( 1115 default=None, 1116 description="Direct URL to the GitHub Actions workflow run", 1117 ) 1118 triage_run_url: str | None = Field( 1119 default=None, 1120 description="URL to the auto-triage workflow run", 1121 ) 1122 linear_issue_url: str | None = Field( 1123 default=None, 1124 description="URL of the Linear issue tracking this feedback", 1125 ) 1126 linear_issue_identifier: str | None = Field( 1127 default=None, 1128 description=( 1129 "Human-readable Linear issue key, e.g. `HYD-123`, recovered from " 1130 "`linear_issue_url`. Falls back to `linear_issue_id` when the URL " 1131 "carries no key." 1132 ), 1133 ) 1134 1135 1136@mcp_tool( 1137 read_only=False, 1138 idempotent=False, 1139 open_world=True, 1140) 1141def devin_session_feedback( 1142 feedback_type: Annotated[ 1143 Literal["positive", "negative"], 1144 Field( 1145 description=( 1146 "Type of feedback: 'positive' for a good experience or 'negative' for a " 1147 "bad experience. Use 'positive' when the user expresses satisfaction, " 1148 "praise, or a success story. Use 'negative' when the user reports a problem, " 1149 "frustration, or failure." 1150 ), 1151 ), 1152 ], 1153 category: Annotated[ 1154 FeedbackCategory, 1155 Field( 1156 description=( 1157 "Feedback category. " 1158 "For NEGATIVE feedback, use one of: " 1159 "'tool_failure' (a specific tool/integration broke), " 1160 "'missing_guidance' (Devin lacked instructions or context), " 1161 "'suspected_hallucination' (Devin fabricated information or made incorrect claims), " 1162 "'bad_approach' (Devin took a fundamentally wrong strategy), " 1163 "'excessive_iteration' (too many loops/retries before success), " 1164 "'poor_quality' (output quality below expectations), " 1165 "'other_concern'. " 1166 "For POSITIVE feedback, use one of: " 1167 "'great_results' (task completed with high quality), " 1168 "'exceeded_expectations' (went above and beyond), " 1169 "'fast_completion' (completed quickly and efficiently), " 1170 "'good_communication' (kept user well-informed), " 1171 "'other_positive_feedback'." 1172 ), 1173 ), 1174 ], 1175 task_description: Annotated[ 1176 str, 1177 Field( 1178 description=( 1179 "Brief description of what the user asked Devin to do. " 1180 "This sets the context for the feedback." 1181 ), 1182 ), 1183 ], 1184 reporting_user: Annotated[ 1185 str, 1186 Field( 1187 description=( 1188 "The person providing the feedback. Accepts an email address " 1189 "(e.g. 'aj@airbyte.io'), a GitHub handle prefixed with @ " 1190 "(e.g. '@aaronsteers'), or a Slack user ID (e.g. 'U05AKF1BCC9')." 1191 ), 1192 ), 1193 ], 1194 session_playbook: Annotated[ 1195 str, 1196 Field( 1197 description=( 1198 "ID of the Devin playbook associated with the session (e.g. " 1199 "'devin_feedback_triage'), or 'none' when no playbook is associated. " 1200 "Required so feedback can identify whether playbook instructions may need updates." 1201 ), 1202 ), 1203 ], 1204 agent_session_url: Annotated[ 1205 str | None, 1206 Field( 1207 default=None, 1208 description=( 1209 "Optional URL of the reporting Devin session. Use the session URL " 1210 "from your system prompt when reporting your own session." 1211 ), 1212 ), 1213 ] = None, 1214 related_skill_name: Annotated[ 1215 str | None, 1216 Field( 1217 default=None, 1218 description=( 1219 "Optional skill ID associated with the feedback (e.g. " 1220 "'delete-declarative-source-def') when a related skill may need updates " 1221 "or is suspected of having issues." 1222 ), 1223 ), 1224 ] = None, 1225 feedback_area: Annotated[ 1226 FeedbackArea | None, 1227 Field( 1228 default=None, 1229 description=( 1230 "Optional domain of the reported task. `db_sources` = database " 1231 "source connectors (source-postgres/mysql/mssql/mongodb-v2/oracle, " 1232 "db-harness-lib); routes the Slack notification to the DB Sources " 1233 "owner instead of @oc-hydra." 1234 ), 1235 ), 1236 ] = None, 1237 expected_behavior: Annotated[ 1238 str | None, 1239 Field( 1240 default=None, 1241 description=( 1242 "What should have happened. REQUIRED for negative feedback. " 1243 "Describe the expected outcome clearly." 1244 ), 1245 ), 1246 ] = None, 1247 observed_behavior: Annotated[ 1248 str | None, 1249 Field( 1250 default=None, 1251 description=( 1252 "What actually happened. REQUIRED for negative feedback. " 1253 "Describe the actual outcome, including any error messages or unexpected results." 1254 ), 1255 ), 1256 ] = None, 1257 what_went_well: Annotated[ 1258 str | None, 1259 Field( 1260 default=None, 1261 description=( 1262 "What specifically was good about the experience. REQUIRED for positive feedback. " 1263 "Be specific about what Devin did well." 1264 ), 1265 ), 1266 ] = None, 1267 severity: Annotated[ 1268 Literal["low", "medium", "high", "critical"] | None, 1269 Field( 1270 default=None, 1271 description=( 1272 "Severity of the issue. Recommended for negative feedback. " 1273 "'low' = minor inconvenience, 'medium' = notable impact, " 1274 "'high' = significant blocker, 'critical' = complete failure." 1275 ), 1276 ), 1277 ] = None, 1278 steps_to_reproduce: Annotated[ 1279 str | None, 1280 Field( 1281 default=None, 1282 description=( 1283 "Optional steps to reproduce the issue. Helpful for negative feedback " 1284 "to enable the team to investigate." 1285 ), 1286 ), 1287 ] = None, 1288 session_to_evaluate: Annotated[ 1289 str | None, 1290 Field( 1291 default=None, 1292 description=( 1293 "Optional Devin session URL to evaluate/triage. Use this when reporting " 1294 "feedback about a *different* session (not your own). If omitted, " 1295 "agent_session_url is used as the session to triage (i.e., the reporter " 1296 "is reporting on itself)." 1297 ), 1298 ), 1299 ] = None, 1300 linear_issue_id: Annotated[ 1301 str | None, 1302 Field( 1303 default=None, 1304 description=( 1305 "Optional Linear issue identifier for the issue already tracking " 1306 "this feedback, used by the triage session for mutations. Pass " 1307 "the identifier, such as `HYD-123`; a UUID is also accepted if " 1308 "available." 1309 ), 1310 ), 1311 ] = None, 1312 linear_issue_url: Annotated[ 1313 str | None, 1314 Field( 1315 default=None, 1316 description=( 1317 "Optional URL of the Linear issue already tracking this feedback. " 1318 "When provided, Slack links to this exact URL." 1319 ), 1320 ), 1321 ] = None, 1322 post_only: Annotated[ 1323 bool, 1324 Field( 1325 description=( 1326 "Post the report without dispatching triage. Set this explicitly " 1327 "for a repeat report; do not infer it from a ticket ID." 1328 ), 1329 ), 1330 ] = False, 1331 thread_url: Annotated[ 1332 str | None, 1333 Field( 1334 default=None, 1335 description=( 1336 "Optional URL of the Slack thread where the feedback request " 1337 "originated. When set, the report and triage findings are also " 1338 "posted as replies in this thread, in addition to the top-level " 1339 "#hydra-feedback post." 1340 ), 1341 ), 1342 ] = None, 1343) -> SessionFeedbackResponse: 1344 """Report structured feedback about a Devin session experience via Slack. 1345 1346 Posts a formatted feedback message to the #hydra-feedback Slack channel 1347 and, when `thread_url` is set, as a reply in the originating Slack thread. 1348 For negative feedback, triage findings are posted in both destinations. 1349 The report tags the reporting user and the @oc-hydra and @oc-internal-ai 1350 groups (unless `feedback_area` overrides the default groups). 1351 The message includes a clickable 1352 button for the Devin session link. For negative feedback, a triage workflow 1353 is automatically dispatched to launch a Devin session with v3 analyze mode 1354 that can inspect the original session's full conversation history. 1355 1356 For negative feedback, the caller supplies any existing Linear tracking 1357 issue ID and URL. This tool transports those values to Slack and the 1358 triage workflow; it does not read or write Linear. 1359 1360 IMPORTANT: This feedback will be logged publicly in Slack. Inform the user 1361 that their feedback is visible to the team and they may be contacted for 1362 additional details. 1363 1364 Use this tool when a user explicitly asks to report a positive or negative 1365 experience with their Devin session. Before calling this tool, let the user 1366 know: 1367 - Their feedback will be posted publicly in the #hydra-feedback Slack channel 1368 - They may be contacted by the team for more details 1369 - The reporting user and the @oc-hydra and @oc-internal-ai groups will be tagged in the message, unless `feedback_area` overrides the default groups 1370 - For negative feedback, a triage session will be automatically launched to inspect the reported session 1371 1372 Depending on the path, the Slack message is posted by this tool or a GitHub 1373 Actions workflow; Slack credentials are never exposed to the calling agent. 1374 """ 1375 # Validate category matches feedback type. 1376 cat = FeedbackCategory(category) 1377 is_negative_feedback = feedback_type == "negative" 1378 id_validation_error = _validate_playbook_id(session_playbook) or _validate_skill_id( 1379 related_skill_name 1380 ) 1381 if id_validation_error: 1382 return SessionFeedbackResponse( 1383 success=False, 1384 message=id_validation_error, 1385 ) 1386 1387 if cat.is_negative() != is_negative_feedback: 1388 expected_kind = "negative" if is_negative_feedback else "positive" 1389 valid = [ 1390 c.value for c in FeedbackCategory if c.is_negative() == is_negative_feedback 1391 ] 1392 return SessionFeedbackResponse( 1393 success=False, 1394 message=( 1395 f"Invalid category '{category}' for {feedback_type} feedback. " 1396 f"Valid {expected_kind} categories: {', '.join(valid)}." 1397 ), 1398 ) 1399 1400 # Validate required fields based on feedback type. 1401 if feedback_type == "negative": 1402 validation_error = _validate_negative_fields( 1403 expected_behavior=expected_behavior, 1404 observed_behavior=observed_behavior, 1405 ) 1406 else: 1407 validation_error = _validate_positive_fields( 1408 what_went_well=what_went_well, 1409 ) 1410 1411 if validation_error: 1412 return SessionFeedbackResponse( 1413 success=False, 1414 message=validation_error, 1415 ) 1416 1417 cc = _feedback_cc(feedback_area) 1418 session_under_investigation = session_to_evaluate or agent_session_url or "" 1419 message_body = _build_feedback_body( 1420 feedback_type=feedback_type, 1421 category=category, 1422 task_description=task_description, 1423 session_playbook=session_playbook, 1424 related_skill_name=related_skill_name, 1425 expected_behavior=expected_behavior, 1426 observed_behavior=observed_behavior, 1427 what_went_well=what_went_well, 1428 severity=severity, 1429 steps_to_reproduce=steps_to_reproduce, 1430 session_to_evaluate=session_under_investigation or None, 1431 thread_url=thread_url or None, 1432 ) 1433 1434 def post_to_originating_thread( 1435 message: str, 1436 ) -> tuple[ 1437 str | None, 1438 SlackAPIError | SlackURLParseError | requests.RequestException | None, 1439 ]: 1440 if not thread_url: 1441 return None, None 1442 try: 1443 return ( 1444 _post_feedback_report( 1445 message, 1446 thread_url, 1447 target_person=reporting_user, 1448 agent_session_url=session_under_investigation or "", 1449 cc_persons=cc, 1450 issue_url=linear_issue_url or None, 1451 header_emoji=_feedback_emoji(feedback_type), 1452 header_label=_feedback_label(feedback_type), 1453 ), 1454 None, 1455 ) 1456 except ( 1457 SlackAPIError, 1458 SlackURLParseError, 1459 requests.RequestException, 1460 ) as exc: 1461 return None, exc 1462 1463 issue_key = _linear_issue_key(linear_issue_url, linear_issue_id) 1464 tracking_response_note = "" 1465 1466 if is_negative_feedback: 1467 issue_note = ( 1468 ( 1469 f"\n\nTracking issue: <{linear_issue_url}|{issue_key}>" 1470 if linear_issue_url 1471 else f"\n\nTracking issue: {issue_key}" 1472 ) 1473 if issue_key 1474 else ( 1475 "\n\n⚠️ No Linear ticket recorded for this report. " 1476 "This report is not on the Linear list." 1477 ) 1478 ) 1479 tracking_response_note = ( 1480 f"Tracking issue: {issue_key}. " 1481 if issue_key 1482 else ( 1483 "⚠️ No Linear ticket recorded for this report; " 1484 "it is not on the Linear list. " 1485 ) 1486 ) 1487 report_message = message_body + issue_note 1488 1489 if post_only: 1490 try: 1491 permalink = _post_feedback_report( 1492 report_message, 1493 target_person=reporting_user, 1494 agent_session_url=session_under_investigation or "", 1495 cc_persons=cc, 1496 issue_url=linear_issue_url or None, 1497 header_emoji=_feedback_emoji(feedback_type), 1498 header_label=_feedback_label(feedback_type), 1499 ) 1500 except SlackAPIError as exc: 1501 return SessionFeedbackResponse( 1502 success=False, 1503 message=f"{tracking_response_note}Feedback posting failed: {exc}", 1504 linear_issue_url=linear_issue_url, 1505 linear_issue_identifier=issue_key, 1506 ) 1507 thread_permalink, thread_post_error = post_to_originating_thread( 1508 report_message 1509 ) 1510 thread_note = _originating_thread_note( 1511 thread_permalink, 1512 thread_post_error, 1513 ) 1514 return SessionFeedbackResponse( 1515 success=True, 1516 message=( 1517 f"{tracking_response_note}" 1518 "Feedback posted to Slack without dispatching triage: " 1519 f"{permalink}{' ' + thread_note if thread_note else ''}" 1520 ), 1521 linear_issue_url=linear_issue_url, 1522 linear_issue_identifier=issue_key, 1523 ) 1524 1525 thread_permalink, thread_post_error = post_to_originating_thread(report_message) 1526 thread_note = _originating_thread_note( 1527 thread_permalink, 1528 thread_post_error, 1529 ) 1530 triage_result = _dispatch_triage_workflow( 1531 session_url=session_under_investigation, 1532 feedback_context=message_body, 1533 reporting_user=reporting_user, 1534 session_playbook=session_playbook, 1535 related_skill_name=related_skill_name, 1536 cc_persons=",".join(cc), 1537 header_emoji=_feedback_emoji(feedback_type), 1538 header_label=_feedback_label(feedback_type), 1539 linear_issue_url=linear_issue_url or "", 1540 linear_issue_id=linear_issue_id or "", 1541 thread_url=thread_url or "", 1542 ) 1543 if triage_result is not None: 1544 view_url = triage_result.run_url or triage_result.workflow_url 1545 notification_note = ( 1546 "A Slack notification will be posted to #hydra-feedback once the " 1547 "triage session starts." 1548 ) 1549 return SessionFeedbackResponse( 1550 success=True, 1551 message=( 1552 "Feedback submitted. Auto-triage workflow launched. " 1553 f"{tracking_response_note}" 1554 f"{notification_note} " 1555 f"{thread_note + ' ' if thread_note else ''}" 1556 f"View workflow progress at: {view_url}" 1557 ), 1558 workflow_url=triage_result.workflow_url, 1559 run_id=triage_result.run_id, 1560 run_url=triage_result.run_url, 1561 triage_run_url=view_url, 1562 linear_issue_url=linear_issue_url, 1563 linear_issue_identifier=issue_key, 1564 ) 1565 logger.warning( 1566 "Triage workflow dispatch failed; falling back to direct HITL dispatch." 1567 ) 1568 else: 1569 thread_permalink, thread_post_error = post_to_originating_thread(message_body) 1570 thread_note = _originating_thread_note( 1571 thread_permalink, 1572 thread_post_error, 1573 ) 1574 1575 # Positive feedback (or negative feedback fallback): dispatch HITL directly 1576 result = dispatch_escalation( 1577 target_person=reporting_user, 1578 message=report_message if is_negative_feedback else message_body, 1579 agent_session_url=session_under_investigation, 1580 cc=cc, 1581 channel_override=_FEEDBACK_CHANNEL, 1582 header_emoji=_feedback_emoji(feedback_type), 1583 header_label=_feedback_label(feedback_type), 1584 ) 1585 1586 view_url = result.run_url or result.workflow_url 1587 return SessionFeedbackResponse( 1588 success=True, 1589 message=( 1590 f"{tracking_response_note}" 1591 f"Feedback submitted and posted to #hydra-feedback. " 1592 "The reporting user and the designated reviewers " 1593 "have been tagged." 1594 f" {thread_note + ' ' if thread_note else ''}" 1595 f"View progress at: {view_url}" 1596 ), 1597 workflow_url=result.workflow_url, 1598 run_id=result.run_id, 1599 run_url=result.run_url, 1600 linear_issue_url=linear_issue_url if is_negative_feedback else None, 1601 linear_issue_identifier=issue_key if is_negative_feedback else None, 1602 ) 1603 1604 1605_FOLLOWUP_HEADER = "🤖 *Automated Triage Update*" 1606 1607_FOLLOWUP_FOOTER_TEMPLATE = ( 1608 "_ℹ️ This thread is not monitored by Devin. " 1609 "Replies here will not be seen by any agent. " 1610 "For follow-up, use the <{agent_session_url}|linked session> or create a new task._" 1611) 1612 1613 1614def _wrap_followup_message(message: str, *, agent_session_url: str) -> str: 1615 """Wrap a follow-up message with session link and non-interactive disclaimer.""" 1616 footer = _FOLLOWUP_FOOTER_TEMPLATE.format(agent_session_url=agent_session_url) 1617 return f"{_FOLLOWUP_HEADER}\n\n{message}\n\n{footer}" 1618 1619 1620class SessionFeedbackFollowupResponse(BaseModel): 1621 """Response from the session feedback follow-up tool.""" 1622 1623 success: bool = Field(description="Whether the follow-up was posted successfully") 1624 message: str = Field(description="Human-readable status message") 1625 reply_ts: str | None = Field( 1626 default=None, 1627 description="Timestamp of the posted reply (Slack ts format)", 1628 ) 1629 1630 1631@mcp_tool( 1632 read_only=False, 1633 idempotent=False, 1634 open_world=True, 1635) 1636def devin_session_feedback_followup( 1637 thread_url: Annotated[ 1638 str, 1639 Field( 1640 description=( 1641 "Slack thread URL from the original feedback post in #hydra-feedback. " 1642 "This is the thread where follow-up context will be appended. " 1643 "Example: https://airbytehq-team.slack.com/archives/C0ACUHRP6B1/p1773062711122019" 1644 ), 1645 ), 1646 ], 1647 message: Annotated[ 1648 str, 1649 Field( 1650 description=( 1651 "Follow-up message text in Slack mrkdwn format. " 1652 "Typically a triage report or additional context about the " 1653 "feedback being investigated. " 1654 "Supports *bold*, _italic_, `code`, ```code blocks```, " 1655 "> blockquotes, and <url|label> links." 1656 ), 1657 ), 1658 ], 1659 agent_session_url: Annotated[ 1660 str, 1661 Field( 1662 description=( 1663 "Your agent session URL for audit trail. " 1664 "Use the session URL from your system prompt." 1665 ), 1666 ), 1667 ], 1668) -> SessionFeedbackFollowupResponse: 1669 """Post a follow-up to an existing feedback thread in #hydra-feedback. 1670 1671 This is the "second call" in the feedback workflow: after 1672 `devin_session_feedback` creates the initial report, this tool appends 1673 triage findings or additional context as a threaded reply. 1674 1675 Each reply is wrapped with a disclaimer clarifying that the thread is 1676 non-interactive and not monitored by any agent. 1677 1678 Workspace validation ensures only URLs from the expected Slack 1679 workspace are accepted. 1680 """ 1681 try: 1682 channel_id, thread_ts = parse_slack_thread_url(thread_url) 1683 except SlackURLParseError as exc: 1684 return SessionFeedbackFollowupResponse( 1685 success=False, 1686 message=str(exc), 1687 ) 1688 1689 wrapped_message = _wrap_followup_message( 1690 message, agent_session_url=agent_session_url 1691 ) 1692 1693 try: 1694 result = post_thread_reply( 1695 channel_id=channel_id, 1696 thread_ts=thread_ts, 1697 message=wrapped_message, 1698 ) 1699 reply_ts = result.ts 1700 except SlackAPIError as exc: 1701 return SessionFeedbackFollowupResponse( 1702 success=False, 1703 message=f"Slack API error: {exc}", 1704 ) 1705 1706 logger.info( 1707 "Feedback follow-up posted: channel=%s thread_ts=%s agent=%s", 1708 channel_id, 1709 thread_ts, 1710 agent_session_url, 1711 ) 1712 return SessionFeedbackFollowupResponse( 1713 success=True, 1714 message=f"Follow-up posted to feedback thread in channel {channel_id}.", 1715 reply_ts=reply_ts, 1716 ) 1717 1718 1719class DevinSessionNameResponse(BaseModel): 1720 """Response from the Devin session naming tool.""" 1721 1722 session_id: str = Field(description="Resolved session ID") 1723 name: str = Field(description="Two-word session name") 1724 full_name: str = Field(description="Session name with the Devin suffix") 1725 1726 1727@mcp_tool( 1728 read_only=True, 1729 idempotent=True, 1730) 1731def get_devin_session_name( 1732 session_id: Annotated[ 1733 str, 1734 "Bare session ID or session URL.", 1735 ], 1736) -> DevinSessionNameResponse: 1737 """Deterministically look up a Devin session name from its ID or URL.""" 1738 resolved_id = extract_session_id(session_id) 1739 name = generate_friendly_name(resolved_id) 1740 full_name = f"{name} Devin" 1741 return DevinSessionNameResponse( 1742 session_id=resolved_id, 1743 name=name, 1744 full_name=full_name, 1745 ) 1746 1747 1748def register_devin_ops_tools(app: FastMCP) -> None: 1749 """Register devin_ops tools with the FastMCP app.""" 1750 register_mcp_tools(app, mcp_module=__name__)