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 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.
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 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.
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.
| 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.
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.
- 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.
- 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.
- 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.
- Jika sebuah aksi butuh approval manusia dengan argumen yang terlihat, ekspos sebagai agent tool dengan needs_approval, bukan sebagai handoff.
- 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.

