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

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.
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.
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.
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.
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.

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.
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 conversationAda 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.
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 movedKarena 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.
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.
| Aspek | Handoff | Agent sebagai tool |
|---|---|---|
| Siapa yang menjawab user | Spesialis, secara langsung | Orchestrator, setelah membaca output spesialis |
| Agent aktif untuk turn berikutnya | Spesialis, lewat result.last_agent | Tetap orchestrator |
| Yang dilihat spesialis | Seluruh history percakapan, kecuali difilter | Hanya argumen tool, default-nya satu string input, atau model bertipe lewat parameters |
| Beberapa spesialis dalam satu turn | Tidak, satu perpindahan mengakhiri bagian triage agent | Bisa, orchestrator memanggil beberapa lalu menggabungkan hasilnya |
| Input guardrail | Hanya milik agent pertama, biasanya triage agent | Milik orchestrator untuk pesan user; milik spesialis untuk input yang di-generate, karena ia memulai run sendiri |
| Output guardrail | Hanya milik spesialis yang mengakhiri turn | Milik orchestrator selalu mencakup jawaban akhir |
| Tool guardrail pada pemanggilannya | Tidak berlaku, handoff punya pipeline sendiri | Belum diekspos langsung oleh as_tool() saat ini |
| Bentuk trace | Sebuah handoff_span, lalu agent span milik spesialis | Pemanggilan function tool yang membungkus nested agent run |
| Approval sebelum berjalan | Lakukan otorisasi di on_handoff dan raise untuk menghentikan | needs_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.
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.
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.
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.
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.
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.
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.