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

Hints: open-world

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

Hints: open-world

Report structured feedback about a Devin session experience via Slack.

Posts a formatted feedback message to the #hydra-feedback Slack channel, tagging the reporting user and the @oc-hydra and @oc-internal-ai 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.

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
  • For negative feedback, a triage session will be automatically launched to inspect the reported session

The Slack message is sent by a GitHub Actions workflow so that 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.
agent_session_url string yes Your agent session URL so the team can view the full context. Use the session URL from your system prompt.
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.
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.
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).

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"
    },
    "agent_session_url": {
      "description": "Your agent session URL so the team can view the full context. Use the session URL from your system prompt.",
      "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"
    },
    "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."
    },
    "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)."
    }
  },
  "required": [
    "feedback_type",
    "category",
    "task_description",
    "agent_session_url",
    "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"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

devin_session_feedback_followup

Hints: open-world

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

Hints: read-only · idempotent

Look up the deterministic friendly name for a Devin session.

Uses the silly-buddy naming scheme to generate a Title Case two-word name (e.g. "Smelly Fred") from the session ID. The output is immutable and idempotent — the same session ID always yields the same name.

If a full URL is provided instead of a bare ID, the session ID is extracted from the URL automatically.

Parameters:

Name Type Required Default Description
session_id string yes The Devin session identifier or session URL. Accepts a raw session ID (e.g. 'b2a641e838214f91b50d0f88940ac119') or a full session URL (e.g. 'https://app.devin.ai/sessions/b2a641e8...'). The ID is extracted automatically from URLs. The same ID always produces the same name — this is a deterministic lookup, not a creation.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "session_id": {
      "description": "The Devin session identifier or session URL. Accepts a raw session ID (e.g. 'b2a641e838214f91b50d0f88940ac119') or a full session URL (e.g. 'https://app.devin.ai/sessions/b2a641e8...'). The ID is extracted automatically from URLs. The same ID always produces the same name \u2014 this is a deterministic lookup, not a creation.",
      "type": "string"
    }
  },
  "required": [
    "session_id"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the Devin session naming tool.",
  "properties": {
    "session_id": {
      "description": "The input session ID",
      "type": "string"
    },
    "scheme_version": {
      "description": "The naming scheme version identifier",
      "type": "string"
    },
    "name": {
      "description": "The generated human-friendly session name in Title Case",
      "type": "string"
    },
    "full_name": {
      "description": "The contextual full name including 'Devin' suffix (e.g. 'Silly Fred Devin')",
      "type": "string"
    }
  },
  "required": [
    "session_id",
    "scheme_version",
    "name",
    "full_name"
  ],
  "type": "object"
}

list_devin_secrets

Hints: open-world

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

Hints: open-world

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

Hints: open-world

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