Runtime d’agent géré

STABILITÉ : Expérimental — Cette fonctionnalité est additive et protégée par managed-runtime. Elle n’affecte pas les Runner/LlmAgent APIs existants lorsque la fonctionnalité est désactivée. L’interface API peut changer dans les prochaines versions.

Vue d’ensemble

Le runtime d’agent géré (adk-managed) est un moteur d’exécution d’agents durable, pouvant reprendre son exécution et indépendant du fournisseur. Il prend une ManagedAgentDef déclarative, construit un agent exécutable et l’exécute comme une session d’arrière-plan dont l’exécution peut reprendre depuis un point de contrôle et qui diffuse des événements.

Le runtime est une bibliothèque, et non un service. La plateforme l’héberge. Cela signifie :

  • Testable de manière isolée : aucune dépendance à HTTP/l’authentification/la facturation
  • Intégrable : les déploiements auto-hébergés utilisent directement le même trait de runtime
  • Plateforme interchangeable : différentes plateformes peuvent héberger le même runtime
  • Indépendant du fournisseur : séquences d’événements identiques quel que soit le fournisseur de modèles

Démarrage rapide

Ajoutez la fonctionnalité à votre Cargo.toml :

[dependencies]
adk-rust = { version = "2.1.0", features = ["managed-runtime"] }

Ou utilisez directement le crate adk-managed :

[dependencies]
adk-managed = "2.1.0"
adk-session = "2.1.0"

Exemple minimal (ScriptedLlm — sans clé API)

use std::sync::Arc;
use adk_managed::{
    DefaultManagedAgentRuntime, ManagedAgentRuntime, ModelResolver,
    ScriptedLlm, ScriptedTurn,
    resolver::ResolverResult,
    types::{ContentBlock, ManagedAgentDef, ModelRef, UserEvent},
};
use adk_session::InMemorySessionService;
use async_trait::async_trait;
use futures::StreamExt;

// A resolver that returns our scripted LLM
struct MockResolver { llm: Arc<dyn adk_core::Llm> }

#[async_trait]
impl ModelResolver for MockResolver {
    async fn resolve(&self, _: &ModelRef) -> ResolverResult<Arc<dyn adk_core::Llm>> {
        Ok(self.llm.clone())
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. Create a scripted LLM (deterministic, offline, $0)
    let llm = Arc::new(ScriptedLlm::new("test-model", vec![
        ScriptedTurn { text: Some("Hello!".into()), tool_calls: vec![] },
    ]));

    // 2. Build the runtime
    let runtime = DefaultManagedAgentRuntime::new(
        Arc::new(MockResolver { llm }),
        Arc::new(InMemorySessionService::new()),
    );

    // 3. Create an agent
    let def = ManagedAgentDef::new("my-agent", ModelRef::Shorthand("test-model".into()))
        .with_system("You are helpful.");
    let agent = runtime.create(def).await?;

    // 4. Start a session
    let session = runtime.start_session(&agent, None).await?;

    // 5. Subscribe to events and send a message
    let mut stream = runtime.stream_events(&session, None).await?;
    runtime.send_event(&session, UserEvent::Message {
        content: vec![ContentBlock::Text { text: "Hi!".into() }],
    }).await?;

    // 6. Collect events
    while let Some(event) = stream.next().await {
        println!("{event:?}");
    }
    Ok(())
}

Architecture

┌─────────────────────────────────────────────────────────────┐
│                Platform Layer (ep-* crates)                  │
│    HTTP Routes │ Auth │ Billing │ Multi-tenancy              │
└──────────────────────────┬──────────────────────────────────┘
                           │ Rust trait calls (in-process)
                           ▼
┌─────────────────────────────────────────────────────────────┐
│            Runtime Layer (adk-managed)                       │
│                                                             │
│  ManagedAgentRuntime trait + DefaultManagedAgentRuntime      │
│  ───────────────────────────────────────────────────        │
│  • Builds runnable agents from ManagedAgentDef              │
│  • Runs supervised session loop (durable, resumable)        │
│  • Emits provider-neutral SessionEvent stream               │
│  • Manages custom tool parking, checkpoints, interrupts     │
│  • Resolves ModelRef → Arc<dyn Llm>                         │
│                                                             │
│  Composes existing crates:                                  │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐    │
│  │adk-runner│ │adk-session│ │adk-model │ │adk-tool    │    │
│  └──────────┘ └──────────┘ └──────────┘ └────────────┘    │
└─────────────────────────────────────────────────────────────┘

Types principaux

Trait ManagedAgentRuntime

Le trait asynchrone central qui définit l’intégralité du cycle de vie de l’agent :

MéthodeDescription
create(def)Enregistre une définition d’agent et renvoie AgentHandle
start_session(agent, env?)Démarre une nouvelle session, avec le statut initial Queued
send_event(session, event)Envoyer un UserEvent à la session
stream_events(session, from_seq?)S’abonner au flux SessionEvent
interrupt(session)S’arrêter à la prochaine limite et émettre status.idle
pause(session)Créer un point de contrôle et suspendre le traitement
resume(session)Reprendre après une pause ou redémarrer
status(session)Interroger SessionStatus actuel
archive(session)État terminal, données conservées
delete_session(session)Supprimer les données de session

ManagedAgentDef

Définition déclarative d’un agent avec le générateur API :

let def = ManagedAgentDef::new("my-agent", ModelRef::Shorthand("gemini-3.7-flash".into()))
    .with_system("You are a helpful assistant.")
    .with_description("Research agent with web search")
    .with_tools(vec![ToolConfig::BuiltIn(ManagedBuiltinTool::WebSearch)]);

SessionEvent

Flux d’événements indépendant du fournisseur avec des numéros de séquence monotones :

  • agent.message — Contenu textuel de l’assistant
  • agent.tool_use — Appel d’un outil intégré
  • agent.custom_tool_use — Outil personnalisé exécuté par le client (la boucle se met en pause)
  • agent.mcp_tool_use — Appel de l’outil MCP
  • status.running — Début du tour
  • status.idle — Tour terminé (avec stop_reason)
  • error — Erreur d’exécution

UserEvent

Événements du client vers l’agent :

  • user.message — Envoyer du contenu à l’agent
  • user.interrupt — Arrêter le tour actuel
  • user.tool_confirmation — Autoriser/refuser l’exécution de l’outil
  • user.custom_tool_result — Retourner les résultats de l’outil personnalisé
  • user.tool_result — Résultat de l’outil intégré (uniquement auto-hébergé)
  • user.define_outcome — Définir les critères de réussite

ModelRef

Référence de modèle indépendante du fournisseur prenant en charge tous les fournisseurs :

// Shorthand (provider inferred from name)
ModelRef::Shorthand("gemini-3.7-flash".into())
ModelRef::Shorthand("gpt-5.6-terra".into())
ModelRef::Shorthand("claude-sonnet-5".into())

// Structured (explicit provider)
ModelRef::Structured {
    provider: Provider::OpenaiCompatible,
    model: ModelConfig::Compatible {
        model: "my-model".into(),
        base_url: "https://my-endpoint.com/v1".into(),
        api_key: "sk-...".into(),
    },
    speed: None,
}

Fonctionnalités clés

Sessions durables

Chaque événement est enregistré de manière atomique. En cas de panne du processus, resume() se réhydrate à partir du dernier point de contrôle cohérent, sans perte d’événements :

// Before crash: events 0..5 committed
// After restart:
runtime.resume(&session).await?;
// Continues from seq=5, no gap, no duplicate

Mise en pause des outils personnalisés

Lorsque l’agent émet agent.custom_tool_use, la boucle se met en pause jusqu’à ce que le client retourne les résultats ou qu’un délai d’expiration configurable soit écoulé :

// Agent emits: agent.custom_tool_use { custom_tool_use_id: "ct_1", name: "deploy" }
// Client executes the tool, then:
runtime.send_event(&session, UserEvent::CustomToolResult {
    custom_tool_use_id: "ct_1".into(),
    content: vec![ContentBlock::Text { text: "Deployed successfully".into() }],
}).await?;

Relecture des événements

Prise en charge de la reconnexion de SSE Last-Event-ID via une relecture fondée sur les séquences :

// Reconnect from seq 42 — replays events 43, 44, ... then live tail
let stream = runtime.stream_events(&session, Some(42)).await?;

Parité entre fournisseurs

Un même ManagedAgentDef produit des séquences de types d’événements identiques octet par octet avec Gemini, OpenAI, Anthropic, Ollama et les fournisseurs compatibles avec OpenAI (fixture F-8).

Tests avec ScriptedLlm

ScriptedLlm est un double LLM déterministe qui exerce l’intégralité du pipeline d’exécution. Seul l’appel API du fournisseur est remplacé :

use adk_managed::testing::{ScriptedLlm, ScriptedTurn, ScriptedToolCall};
use serde_json::json;

let llm = ScriptedLlm::new("test", vec![
    ScriptedTurn {
        text: Some("I'll search for that.".into()),
        tool_calls: vec![ScriptedToolCall {
            name: "web_search".into(),
            input: json!({"query": "rust agents"}),
            id: Some("tc_1".into()),
        }],
    },
    ScriptedTurn {
        text: Some("Here are the results...".into()),
        tool_calls: vec![],
    },
]);

Référence de API

La documentation complète de API est disponible sur docs.rs :

Exemple de test de fumée

Un crate d’exemple autonome est fourni aux équipes de plateforme :

cargo run --manifest-path examples/managed_runtime_hello/Cargo.toml

Cela exécute le fixture F-1 de bout en bout avec ScriptedLlm (aucune clé API n’est requise).

Durabilité de l’état géré

L’état de session géré — le journal des événements, la position dans la séquence, les appels d’outils mis en attente et l’état du cycle de vie — réside dans un ManagedStateStore. Le stockage indique sa propre garantie :

DurabilitéSignification
ProcessLocalLa relecture et la reprise fonctionnent pendant l’exécution du processus. Un plantage entraîne la perte de l’état, et un autre processus ne peut pas reprendre la session.
CrashDurableL’état est écrit dans un magasin de stockage avant que l’écriture ne soit confirmée, afin qu’un autre processus puisse reconstruire la session.

Seul InMemoryManagedStateStore est livré, et il est ProcessLocal. Vérifiez la garantie plutôt que de la déduire de la présence de points de contrôle :

use adk_managed::{Durability, InMemoryManagedStateStore, ManagedStateStore};

let store = InMemoryManagedStateStore::new();
assert_eq!(store.durability(), Durability::ProcessLocal);
assert!(!store.durability().survives_process_loss());

Points de contrôle par rapport à l’écriture sur disque

CheckpointManager::checkpoint enregistre un événement et le nouvel état d’exécution ensemble, de sorte que la relecture ne voit jamais l’un sans l’autre. Il s’agit d’une écriture dans les propres champs du gestionnaire. flush écrit l’instantané dans le stockage configuré, et restore reconstruit un gestionnaire à partir de celui-ci :

use adk_managed::{CheckpointManager, InMemoryManagedStateStore, ManagedStateStore};
use std::sync::Arc;

# async fn example() -> Result<(), adk_managed::types::RuntimeError> {
let store: Arc<dyn ManagedStateStore> = Arc::new(InMemoryManagedStateStore::new());
let manager = CheckpointManager::new("session-1".to_string()).with_store(Arc::clone(&store));
manager.flush().await?;

let restored = CheckpointManager::restore("session-1".to_string(), store).await?;
assert_eq!(restored.session_id(), "session-1");
# Ok(())
# }

Rapport d’état

ManagedAgentRuntime::status lit le même handle dans lequel la boucle de session écrit, de sorte que les transitions normales sont visibles, et pas seulement celles du plan de contrôle :

TransitionCause
QueuedRunningUn tour commence
RunningIdleLe tour se termine et l'utilisation est enregistrée
quelconque → Pausedpause
quelconque → Archivedarchive ou delete_session

Remarque : auparavant, il s’agissait d’une poignée partagée, status signalait Queued pendant toute la durée de vie d’une session, y compris pendant l’exécution de ses tours. Les transitions du plan de contrôle (pause, reprise, archivage) étaient visibles, car elles écrivaient directement dans la poignée.

Sémantique de suppression

delete_session supprime les deux plans :

  1. Définit la session comme terminée et annule sa boucle.
  2. Supprime la poignée d’exécution.
  3. Supprime la conversation persistée via le SessionService injecté, sous la même identité start_session que celle avec laquelle elle a été créée.

Si l’étape 3 échoue, delete_session renvoie une erreur indiquant l’application, l’utilisateur et la session qui détiennent encore les données — la poignée a déjà été supprimée à ce stade ; l’appelant doit donc être informé de ce qui nécessite un nettoyage manuel, plutôt que d’être autorisé à supposer que l’opération a réussi.

runtime.delete_session(&session).await?;
// The handle is gone and the conversation is no longer in the session backend.

Important : la suppression retire la conversation de la session sous l’identité de son propriétaire. Voir Propriété de la session.

Propriété de la session

start_session nécessite un ManagedOwner. La session est persistée sous cette identité, et chaque appel du Runner effectué par la boucle de session l’utilise :

use adk_managed::{ManagedAgentRuntime, ManagedOwner};

# async fn start(runtime: &dyn ManagedAgentRuntime, agent: &adk_managed::AgentHandle)
# -> Result<(), adk_managed::RuntimeError> {
let owner = ManagedOwner::new("support-console", "user-42")?;
let session = runtime.start_session(agent, &owner, None).await?;
# Ok(())
# }

Important : checkpoint était documenté comme « persister atomiquement », avec la garantie que « la relecture verrait une vue cohérente après tout plantage », et le chargement était décrit comme renvoyant « tout ce qui est nécessaire pour reconstruire une session après un redémarrage ». Aucune de ces affirmations n’était vraie : les deux opérations utilisaient des champs en mémoire, sans transaction avec un magasin persistant. Avec le magasin fourni, restore dans un nouveau processus ne trouve rien.

Les deux composants sont requis et ne doivent pas être vides. Les sessions appartenant à différents propriétaires sont adressées séparément ; la recherche et la suppression sont donc limitées à un seul propriétaire et ne peuvent pas atteindre les données d’un autre.

Remarque : chaque session gérée était auparavant persistée sous les constantes managed / managed_user, de sorte qu’elles partageaient toutes un même espace de noms logique : rien ne pouvait être limité à un appelant et aucune session ne pouvait lui être attribuée.

Configuration de l’environnement

EnvironmentConfig contient env_vars et working_dir. Cet environnement d’exécution rejette une configuration qui demande l’un ou l’autre :

invalid request: EnvironmentConfig cannot be honoured by this runtime: sessions run
in-process, so per-session environment variables and working directories would have to mutate
process-global state shared with other sessions. Pass `None`, or configure a sandboxed runtime.

Les sessions s’exécutent dans le processus, donc l’application de variables d’environnement par session ou d’un répertoire de travail modifierait l’état partagé avec toutes les autres sessions. Le refus est l’issue honnête ; une limite d’exécution isolée est ce qui rendrait la demande satisfaisable.

Remarque : l’argument s’appelait auparavant _env et était ignoré, de sorte qu’un appelant fournissant une configuration d’environnement recevait une session qui l’ignorait silencieusement.