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_area overrides 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 links.
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:

  1. (Optional) Call list_devin_secrets first to see available names.
  2. Call this tool without approval_evidence_url to request approval.
  3. Note the request_id in the response.
  4. Wait for a human to approve the request in Slack.
  5. Obtain the approval evidence URL (Slack approval record URL).
  6. Call this tool again with the approval_evidence_url and the request_id from step 2.
  7. 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://.slack.com/archives/...). Leave empty for Phase 1 (requesting approval). Provide the Slack URL for Phase 2 (delivering the secret after approval).
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__)