Skip to main content
BlogAI

Handoff vs Agents as Tools di OpenAI Agents SDK Dijelaskan

Handoff menyerahkan percakapan ke agent spesialis; agent.as_tool() hanya meminjam spesialis dan tetap memegang kendali. Pilihan ini menentukan history, guardrail dan tracing.

Matthews Wong

Software Engineer

Oktober 202611 menit baca
Handoff vs Agents as Tools di OpenAI Agents SDK Dijelaskan

Ringkasan Utama

Di OpenAI Agents SDK, handoff memindahkan percakapan ke agent spesialis, yang lalu melihat seluruh history dan menjawab user secara langsung. Agent.as_tool() menjalankan spesialis sebagai nested run dengan input yang dihasilkan orchestrator, lalu mengembalikan output-nya ke orchestrator yang tetap memegang kendali. Guardrail juga berbeda: input guardrail hanya berjalan untuk agent pertama, output guardrail hanya untuk agent terakhir.

Bayangkan versi pertama agent helpdesk ERP yang umum: satu triage agent dan dua spesialis, sales dan finance, yang disambung dengan handoff. Routing-nya berjalan baik. Lalu input guardrail ditambahkan ke finance agent supaya ia menolak membahas akun selain akun customer yang sedang login, seseorang bertanya tentang invoice milik customer lain, dan finance agent tetap menjawabnya. Guardrail itu tidak pernah berjalan. Tidak ada yang rusak; primitive komposisinya dipilih tanpa membaca aturan guardrail-nya.

OpenAI Agents SDK menyediakan dua cara agar beberapa agent bekerja sama. Handoff memindahkan kepemilikan percakapan ke agent lain. Agent.as_tool() mengubah agent menjadi tool yang bisa dipanggil, sehingga agent pemanggil tetap memegang kendali dan memutuskan apa yang dilakukan dengan hasilnya. Tulisan ini membandingkan keduanya dari sisi yang berubah di production: siapa yang menjawab user, history apa yang dilihat tiap agent, di mana guardrail berjalan, dan bagaimana run tampil di trace. Satu contoh triage sales versus finance dipakai dari awal sampai akhir, dengan Python SDK serta dokumentasi dan source SDK itu sendiri sebagai rujukan.

Cara handoff di OpenAI Agents SDK memindahkan kendali

Handoff ditampilkan ke model sebagai tool, secara default bernama transfer_to_ diikuti nama agent. Saat model memanggilnya, runner tidak mengembalikan apa pun ke triage agent. Runner mengganti agent aktif, mempertahankan input, lalu menjalankan ulang loop, sehingga spesialis menjadi agent aktif untuk sisa turn dan menulis jawaban akhirnya sendiri. Kamu bisa memasukkan Agent langsung ke handoffs, atau membungkusnya dengan handoff() untuk menambahkan callback on_handoff, schema input_type untuk metadata yang dihasilkan model, input_filter, atau saklar is_enabled.

from pydantic import BaseModel
from agents import Agent, Runner, RunContextWrapper, function_tool, handoff
from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX

@function_tool
async def get_invoice(invoice_no: str) -> str:
    """Return status, due date and open amount for one invoice."""
    return await erp.invoices.summary(invoice_no)

@function_tool
async def get_price_list(customer_code: str) -> str:
    """Return the price list and discount tier assigned to a customer."""
    return await erp.pricing.for_customer(customer_code)

class RouteReason(BaseModel):
    reason: str          # model-generated metadata, e.g. "payment_not_matched"
    customer_code: str

async def log_route(ctx: RunContextWrapper[None], data: RouteReason) -> None:
    # Runs BEFORE the specialist takes over. Raise here to stop the transfer;
    # returning normally lets it continue.
    await audit.write("helpdesk.route", reason=data.reason, customer=data.customer_code)

sales_agent = Agent(
    name="Sales agent",
    instructions=f"{RECOMMENDED_PROMPT_PREFIX}\nYou answer price lists, quotations and discounts.",
    tools=[get_price_list],
)
finance_agent = Agent(
    name="Finance agent",
    instructions=f"{RECOMMENDED_PROMPT_PREFIX}\nYou answer invoice status, due dates and payments.",
    tools=[get_invoice],
)

triage_agent = Agent(
    name="Helpdesk triage",
    instructions=f"{RECOMMENDED_PROMPT_PREFIX}\nRoute pricing to sales, invoices and payments to finance.",
    handoffs=[
        sales_agent,                                   # exposed as transfer_to_sales_agent
        handoff(finance_agent, on_handoff=log_route, input_type=RouteReason),
    ],
)

result = await Runner.run(triage_agent, "INV-2026-0912 still shows unpaid, we transferred last week")
print(result.last_agent.name)   # "Finance agent" - it now owns the conversation

Ada dua detail penting. Pertama, result.last_agent adalah agent yang biasanya menangani turn user berikutnya, jadi chat loop yang mengoper agent ini kembali ke Runner.run membuat customer tetap berbicara dengan finance tanpa triage ulang di setiap pesan. Kedua, input_type adalah metadata tentang perpindahan, misalnya alasan atau prioritas, bukan cara memilih tujuan: handoff() selalu memindahkan ke agent yang kamu bungkus, jadi daftarkan satu handoff per spesialis dan biarkan model memilih. Agent penerima tetap melihat history percakapan kecuali kamu mengubahnya.

Apa yang dilakukan agent.as_tool()

Agent.as_tool() mengembalikan sebuah FunctionTool. Saat orchestrator memanggilnya, SDK memulai Runner.run baru dengan spesialis sebagai starting agent, memberinya argumen tool, lalu mengembalikan final output ke orchestrator sebagai hasil tool. Orchestrator kemudian melanjutkan: memanggil spesialis lain, menggabungkan kedua jawaban, atau bertanya lagi. Docstring SDK menyebutkan dua perbedaannya dengan jelas. Pada handoff, agent baru menerima history percakapan dan mengambil alih; sebagai tool, agent baru menerima input yang di-generate dan agent asal tetap melanjutkan percakapan.

helpdesk_manager = Agent(
    name="Helpdesk manager",
    instructions=(
        "You own the reply to the customer. Ask the specialists for facts, "
        "then answer in one message. Never forward a specialist's text verbatim."
    ),
    tools=[
        sales_agent.as_tool(
            tool_name="ask_sales",
            tool_description="Prices, quotations and discount rules for one customer.",
        ),
        finance_agent.as_tool(
            tool_name="ask_finance",
            tool_description="Invoice status, due dates and open balance for one customer.",
            max_turns=4,                  # the nested run gets its own budget (default is 10)
            failure_error_function=None,  # raise, instead of handing the model an error string
        ),
    ],
)

# The specialist does NOT see this message. It sees whatever the manager
# writes into the tool call, by default {"input": "..."}.
result = await Runner.run(
    helpdesk_manager,
    "Can CUST-0418 get the Q4 distributor discount, and is INV-2026-0912 still open?",
)
print(result.last_agent.name)   # "Helpdesk manager" - control never moved

Karena run-nya terpisah, nested agent punya pengaturan sendiri. Ia mendapat max_turns sendiri, yang kembali ke default SDK yaitu 10 jika tidak diisi, run_config dan hooks sendiri, dan tidak mewarisi history parent kecuali kamu mengoper session, conversation_id atau previous_response_id secara eksplisit. Penanganan kegagalan juga berbeda dari handoff: secara default, kegagalan di dalam nested run diubah menjadi pesan error untuk model orchestrator melalui failure_error_function, dan mengisinya dengan None membuatnya raise. Untuk lookup finance, saya lebih suka melihat exception daripada model yang berimprovisasi menutupi error.

Handoff vs agents as tools: perbandingan langsung

Panduan multi-agent SDK merangkum pilihannya dalam satu kalimat untuk masing-masing. Agents as tools cocok untuk manager yang harus memegang jawaban akhir, menggabungkan beberapa spesialis atau menerapkan guardrail bersama. Handoff cocok untuk tahap triage yang spesialis pilihannya harus menjawab langsung dengan instruksi yang fokus. Tabel di bawah menambahkan konsekuensi mekanisnya.

AspekHandoffAgent sebagai tool
Siapa yang menjawab userSpesialis, secara langsungOrchestrator, setelah membaca output spesialis
Agent aktif untuk turn berikutnyaSpesialis, lewat result.last_agentTetap orchestrator
Yang dilihat spesialisSeluruh history percakapan, kecuali difilterHanya argumen tool, default-nya satu string input, atau model bertipe lewat parameters
Beberapa spesialis dalam satu turnTidak, satu perpindahan mengakhiri bagian triage agentBisa, orchestrator memanggil beberapa lalu menggabungkan hasilnya
Input guardrailHanya milik agent pertama, biasanya triage agentMilik orchestrator untuk pesan user; milik spesialis untuk input yang di-generate, karena ia memulai run sendiri
Output guardrailHanya milik spesialis yang mengakhiri turnMilik orchestrator selalu mencakup jawaban akhir
Tool guardrail pada pemanggilannyaTidak berlaku, handoff punya pipeline sendiriBelum diekspos langsung oleh as_tool() saat ini
Bentuk traceSebuah handoff_span, lalu agent span milik spesialisPemanggilan function tool yang membungkus nested agent run
Approval sebelum berjalanLakukan otorisasi di on_handoff dan raise untuk menghentikanneeds_approval menjeda run dengan sebuah interruption

Baris guardrail layak mendapat bagian sendiri, karena di situlah kedua pola berhenti bisa saling menggantikan. Baris trace juga perlu disinggung: dengan handoff, trace terbaca seperti estafet, satu agent setelah yang lain, sedangkan dengan agents as tools terbaca sebagai satu agent yang melakukan beberapa pemanggilan, yang lebih mudah dipindai saat manager berkonsultasi dengan tiga spesialis dalam satu turn.

Guardrail berperilaku berbeda pada handoff

Halaman guardrails menyebutkan kedua aturannya di satu tempat. Input guardrail hanya berjalan untuk agent pertama dalam rantai, karena tujuannya memeriksa input user. Output guardrail hanya berjalan untuk agent yang menghasilkan final output. Dalam rantai handoff, agent pertama adalah triage agent dan agent terakhir adalah spesialis mana pun yang menjawab, jadi guardrail yang dipasang di agent yang salah adalah kode valid yang tidak pernah dieksekusi. Itulah bug saya.

from agents import (
    Agent, GuardrailFunctionOutput, InputGuardrailTripwireTriggered,
    RunContextWrapper, Runner, input_guardrail,
)

@input_guardrail
async def one_customer_only(ctx: RunContextWrapper[None], agent: Agent, input) -> GuardrailFunctionOutput:
    codes = extract_customer_codes(input)          # regex over CUST-xxxx
    allowed = ctx.context.session_customer_code    # who is actually logged in
    leaked = [c for c in codes if c != allowed]
    return GuardrailFunctionOutput(output_info=leaked, tripwire_triggered=bool(leaked))

# Wrong: the guardrail sits on a specialist that is only reached by a handoff.
finance_agent = Agent(name="Finance agent", tools=[get_invoice],
                      input_guardrails=[one_customer_only])
triage_agent = Agent(name="Helpdesk triage", handoffs=[sales_agent, finance_agent])
await Runner.run(triage_agent, text)   # one_customer_only never runs

# Right for handoffs: guard the entry point, and put the output check on
# EVERY agent that can end the turn - any specialist may be the last one.
triage_agent = Agent(name="Helpdesk triage", handoffs=[sales_agent, finance_agent],
                     input_guardrails=[one_customer_only])
finance_agent.output_guardrails = [no_internal_margin]
sales_agent.output_guardrails = [no_internal_margin]

try:
    result = await Runner.run(triage_agent, text, context=ctx)
except InputGuardrailTripwireTriggered:
    reply = "I can only discuss the account you are signed in with."

Dengan agents as tools, cakupannya berbeda. Manager adalah agent pertama sekaligus terakhir dari outer run, jadi input guardrail-nya melihat setiap pesan user dan output guardrail-nya melihat setiap balasan, spesialis mana pun yang dikonsultasikan. Setiap spesialis juga memulai nested run sendiri, sehingga guardrail miliknya berjalan terhadap input yang di-generate manager untuknya. String itu berbeda dari yang diketik user, dan ini penting untuk pemeriksaan seperti one_customer_only: manager bisa saja memparafrasekan kode customer sampai hilang. Letakkan pemeriksaan yang berhadapan dengan user di manager, dan pemeriksaan data di function tool.

Guardrail pada target handoff bukan error dan tidak menghasilkan warning. Jika sebuah pemeriksaan harus berlaku apa pun agent yang akhirnya menjawab, pasang sebagai tool guardrail di function tool yang menyentuh data, misalnya get_invoice. Tool guardrail berjalan di setiap pemanggilan yang dijaga, pada kedua pola, karena function tool dieksekusi di dalam agent mana pun yang sedang aktif.

Mengendalikan apa yang dilihat spesialis

Secara default handoff meneruskan seluruh percakapan. Ini praktis, tapi juga cara sales agent akhirnya membaca output tool finance yang bukan urusannya. SDK memberi empat tuas, dan keempatnya saling memengaruhi.

  • input_filter pada handoff, atau handoff_input_filter di RunConfig sebagai default global, menerima HandoffInputData berisi input_history, pre_handoff_items, new_items dan run_context, lalu mengembalikan versi yang boleh dilihat agent berikutnya.
  • handoff_filters.remove_all_tools adalah filter siap pakai. Filter ini membuang item tool yang terstruktur, tetapi tidak membuang argumen atau hasil tool yang sudah tersalin ke pesan biasa.
  • nest_handoff_history adalah fitur beta opt-in, nonaktif secara default, yang memadatkan history sebelumnya menjadi segmen ringkasan assistant bertanda CONVERSATION HISTORY. Fitur ini hanya berlaku jika tidak ada input filter, dan handoff_history_mapper memungkinkan kamu menyuplai daftar item yang persis.
  • History yang dikelola server lewat conversation_id atau previous_response_id sama sekali tidak mendukung handoff input filter, jadi spesialis yang tidak boleh mewarisinya butuh run terpisah dengan input yang dipilih secara eksplisit.
from agents import RunConfig, handoff
from agents.extensions import handoff_filters

# Handoff: drop structured tool items the triage agent produced. This does NOT
# redact tool text that was already copied into ordinary messages.
faq_handoff = handoff(faq_agent, input_filter=handoff_filters.remove_all_tools)

# Opt-in beta: summarise earlier history into <CONVERSATION HISTORY> segments.
# Ignored when an input_filter is set on the handoff or on the RunConfig.
config = RunConfig(nest_handoff_history=True)

# Agent as tool: the specialist only ever sees the arguments, so make them typed.
class InvoiceQuery(BaseModel):
    invoice_no: str
    customer_code: str

ask_finance = finance_agent.as_tool(
    tool_name="ask_finance",
    tool_description="Look up one invoice for one customer.",
    parameters=InvoiceQuery,
    include_input_schema=True,
)

Agents as tools menghindari sebagian besar masalah ini, karena spesialis hanya melihat apa yang ditulis orchestrator ke dalam pemanggilan. Konsekuensinya, model orchestrator yang menentukan isi tulisan itu. Memberi tool schema bertipe lewat parameters dan include_input_schema mengubah permintaan teks bebas menjadi field yang bisa diandalkan spesialis, dan nested agent bisa membaca nilai yang sudah di-parse dari RunContextWrapper.tool_input.

Nested handoff history hanya mengubah cara transkrip direpresentasikan; dokumentasinya menegaskan bahwa fitur ini tidak meredaksi apa pun. Argumen dan output tool bisa tetap ada di dalam ringkasan yang dihasilkan. Jika data finance tidak boleh sampai ke sales agent, tulis input filter yang membersihkan input_history, pre_handoff_items dan new_items, bukan hanya item yang diteruskan.

Approval, otorisasi dan routing dinamis

Kedua pola menaruh hook otorisasi di tempat berbeda. Untuk handoff, is_enabled dievaluasi saat SDK menyiapkan handoff yang tersedia, sebelum model menghasilkan argumen apa pun, jadi ia bisa menyembunyikan rute finance dari user tanpa akses finance, tetapi tidak bisa memeriksa kode customer di dalam permintaan. Dokumentasinya menyarankan pemeriksaan itu dilakukan di awal on_handoff dan memakai raise alih-alih return, karena perpindahan tetap berlanjut begitu on_handoff selesai dengan sukses.

Agent tool menerima needs_approval, berupa boolean atau callable policy. Saat terpicu, run berhenti sementara dan pemanggilan yang tertunda muncul di result.interruptions, lalu kamu melanjutkannya dengan approve atau reject pada run state. Ini lebih cocok untuk aksi ERP yang berdampak, misalnya spesialis yang menyusun credit note, karena manusia bisa melihat argumen persisnya sebelum nested run dimulai. Kedua pola menerima is_enabled, jadi kamu bisa menampilkan spesialis hanya untuk role yang memang boleh mengaksesnya.

Awali instruksi setiap agent dalam graf handoff dengan RECOMMENDED_PROMPT_PREFIX dari agents.extensions.handoff_prompt, atau panggil prompt_with_handoff_instructions. Tanpa itu, spesialis cenderung tidak paham bahwa ia menerima percakapan di tengah jalan, lalu menyapa user lagi atau menanyakan ulang hal yang sudah dipastikan triage.

Cara saya memilih di antara keduanya

Setelah membangun ulang helpdesk dengan kedua cara, saya menetapkan prosedur keputusan yang singkat. Prosedur ini mengasumsikan kamu sudah yakin tugasnya memang butuh lebih dari satu agent, yang belum tentu benar: satu agent dengan tool yang baik sering kali sudah cukup.

  1. Jika satu permintaan bisa butuh dua spesialis, misalnya pertanyaan diskon dan pertanyaan invoice dalam satu pesan, gunakan agents as tools. Handoff hanya bisa menuju satu tempat.
  2. Jika spesialis harus memegang percakapan selama beberapa turn dengan instruksinya sendiri, misalnya rekonsiliasi pembayaran yang panjang, gunakan handoff dan oper result.last_agent kembali di turn berikutnya.
  3. Jika satu output policy harus mencakup setiap balasan, misalnya tidak pernah menyebut margin internal, pasang di manager yang memanggil agents as tools, atau duplikasikan ke setiap target handoff yang mungkin.
  4. Jika sebuah aksi butuh approval manusia dengan argumen yang terlihat, ekspos sebagai agent tool dengan needs_approval, bukan sebagai handoff.
  5. Apa pun pilihannya, letakkan pemeriksaan akses data di function tool, tempat pemeriksaan itu berjalan tanpa peduli agent mana yang aktif.

Keduanya tidak saling meniadakan. Versi yang saya pertahankan adalah manager yang memanggil sales dan finance sebagai tool untuk pertanyaan biasa, ditambah satu handoff ke agent eskalasi manusia yang mengambil alih percakapan saat sebuah sengketa butuh orang sungguhan. Routing yang merupakan bagian dari workflow adalah handoff; meminjam keahlian untuk satu sub-pertanyaan adalah tool call.

Pilih primitive berdasarkan siapa yang seharusnya memiliki balasan. Jika spesialis harus berbicara dengan user dan memegang percakapan, gunakan handoff, lalu jaga agent pertama dan setiap agent yang bisa mengakhiri turn. Jika spesialis hanya menyuplai fakta, panggil ia sebagai tool dan biarkan satu manager memiliki jawaban beserta guardrail-nya. Apa pun pilihannya, letakkan pemeriksaan yang tidak boleh terlewat di function tool.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Apa perbedaan handoff dan agent.as_tool() di OpenAI Agents SDK?

Handoff memindahkan kepemilikan percakapan ke agent lain, yang kemudian melihat history dan menjawab user secara langsung. Agent.as_tool() menjalankan agent lain sebagai nested run dengan input yang di-generate, lalu mengembalikan output-nya ke agent pemanggil yang tetap memegang kendali dan menulis balasan.

Kenapa input guardrail saya tidak berjalan setelah handoff?

Input guardrail hanya berjalan untuk agent pertama dalam rantai, karena tujuannya memeriksa input user. Guardrail yang dipasang di spesialis yang hanya dicapai lewat handoff tidak pernah dieksekusi. Pasang di agent pertama, atau pindahkan pemeriksaannya ke function tool sebagai tool guardrail.

Apakah agent penerima melihat seluruh history percakapan setelah handoff?

Ya, secara default agent baru melihat seluruh percakapan sebelumnya. Kamu bisa mengubahnya dengan input_filter pada handoff, handoff_input_filter global di RunConfig, atau fitur beta nest_handoff_history. Tidak satu pun dari ketiganya meredaksi konten dengan sendirinya, jadi output tool yang sensitif butuh filter yang membuangnya secara eksplisit.

Kapan sebaiknya memakai agents as tools daripada handoff?

Gunakan agents as tools ketika satu agent harus memiliki jawaban akhir, menggabungkan hasil beberapa spesialis dalam satu turn, atau menerapkan satu set output guardrail. Gunakan handoff ketika spesialis harus berbicara langsung dengan user dan memegang percakapan selama beberapa turn dengan instruksinya sendiri.

Bisakah handoff dan agents as tools digabung dalam satu aplikasi OpenAI Agents SDK?

Bisa. Bentuk yang umum adalah manager agent yang memanggil spesialis sebagai tool untuk pertanyaan biasa, ditambah satu handoff ke agent eskalasi yang mengambil alih saat percakapan butuh pemilik lain. Kedua primitive menjawab pertanyaan berbeda: siapa yang meminjam keahlian, dan siapa yang memiliki balasan.