feat: clean up block return object [LET-5784] (#5641)

* fix: fix deep research agent

* chore: update blocks response

* add message

* update agents

* update

* use blockresponse

* undo merge conflict

* add internal agents and blocks

* remove unnecessary internal agent route

* fix utils server test

---------

Co-authored-by: christinatong01 <christina@letta.com>
This commit is contained in:
Sarah Wooders
2025-10-24 16:03:15 -07:00
committed by Caren Thomas
parent 0ae3739af4
commit 85ed29274c
10 changed files with 1349 additions and 43 deletions

153
fern/assets/leaderboard.js Normal file
View File

@@ -0,0 +1,153 @@
/* ──────────────────────────────────────────────────────────
assets/leaderboard.js
Load via docs.yml → js: - path: assets/leaderboard.js
(strategy: lazyOnload is fine)
────────────────────────────────────────────────────────── */
import yaml from 'https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/+esm';
console.log('🏁 leaderboard.js loaded on', location.pathname);
const COST_CAP = 120;
/* ---------- helpers ---------- */
const pct = (v) => Number(v).toPrecision(3) + '%';
const cost = (v) => '$' + Number(v).toFixed(2);
const ready = (cb) =>
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', cb)
: cb();
/* ---------- main ---------- */
ready(async () => {
// const host = document.getElementById('letta-leaderboard');
// if (!host) {
// console.warn('LB-script: #letta-leaderboard not found - bailing out.');
// return;
// }
/* ---- wait for the leaderboard container to appear (SPA nav safe) ---- */
const host = await new Promise((resolve, reject) => {
const el = document.getElementById('letta-leaderboard');
if (el) return resolve(el); // SSR / hard refresh path
const obs = new MutationObserver(() => {
const found = document.getElementById('letta-leaderboard');
if (found) {
obs.disconnect();
resolve(found); // CSR navigation path
}
});
obs.observe(document.body, { childList: true, subtree: true });
setTimeout(() => {
obs.disconnect();
reject(new Error('#letta-leaderboard never appeared'));
}, 5000); // safety timeout
}).catch((err) => {
console.warn('LB-script:', err.message);
return null;
});
if (!host) return; // still no luck → give up
/* ----- figure out URL of data.yaml ----- */
// const path = location.pathname.endsWith('/')
// ? location.pathname
// : location.pathname.replace(/[^/]*$/, ''); // strip file/slug
// const dataUrl = `${location.origin}${path}data.yaml`;
// const dataUrl = `${location.origin}/leaderboard/data.yaml`; // one-liner, always right
// const dataUrl = `${location.origin}/assets/leaderboard.yaml`;
// const dataUrl = `./assets/leaderboard.yaml`; // one-liner, always right
// const dataUrl = `${location.origin}/data.yaml`; // one-liner, always right
const dataUrl =
'https://raw.githubusercontent.com/letta-ai/letta-evals/refs/heads/main/letta-leaderboard/leaderboard_results.yaml';
// const dataUrl = 'https://cdn.jsdelivr.net/gh/letta-ai/letta-evals@latest/letta-leaderboard/leaderboard_results.yaml';
console.log('LB-script: fetching', dataUrl);
/* ----- fetch & parse YAML ----- */
let rows;
try {
const resp = await fetch(dataUrl);
console.log(`LB-script: status ${resp.status}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
rows = yaml.load(await resp.text());
} catch (err) {
console.error('LB-script: failed to load YAML →', err);
return;
}
/* ----- wire up table ----- */
const dir = Object.create(null);
const tbody = document.getElementById('lb-body');
const searchI = document.getElementById('lb-search');
const headers = document.querySelectorAll('#lb-table thead th[data-key]');
searchI.value = ''; // clear any persisted filter
const render = () => {
const q = searchI.value.toLowerCase();
tbody.innerHTML = rows
.map((r) => {
const over = r.total_cost > COST_CAP;
const barW = over ? '100%' : (r.total_cost / COST_CAP) * 100 + '%';
const costCls = over ? 'cost-high' : 'cost-ok';
const warnIcon = over
? `<span class="warn" title="Cost exceeds $${COST_CAP} cap - bar is clipped to full width">⚠</span>`
: '';
return `
<tr class="${q && !r.model.toLowerCase().includes(q) ? 'hidden' : ''}">
<td style="padding:8px">${r.model}</td>
<td class="bar-cell avg metric">
<div class="bar-viz" style="width:${r.average}%"></div>
<span class="value">${pct(r.average)}</span>
</td>
<td class="bar-cell ${costCls} metric">
<div class="bar-viz" style="width:${barW}"></div>
<span class="value">${cost(r.total_cost)}</span>
${warnIcon}
</td>
</tr>`;
})
.join('');
};
const setIndicator = (activeKey) => {
headers.forEach((h) => {
h.classList.remove('asc', 'desc');
if (h.dataset.key === activeKey) h.classList.add(dir[activeKey]);
});
};
/* initial sort ↓ */
dir.average = 'desc';
rows.sort((a, b) => b.average - a.average);
setIndicator('average');
render();
/* search */
searchI.addEventListener('input', render);
/* column sorting */
headers.forEach((th) => {
const key = th.dataset.key;
th.addEventListener('click', () => {
const asc = dir[key] === 'desc';
dir[key] = asc ? 'asc' : 'desc';
rows.sort((a, b) => {
const va = a[key],
vb = b[key];
const cmp =
typeof va === 'number'
? va - vb
: String(va).localeCompare(String(vb));
return asc ? cmp : -cmp;
});
setIndicator(key);
render();
});
});
});

View File

@@ -1258,6 +1258,20 @@ paths:
/v1/_internal_runs/:
get:
x-fern-ignore: true
/v1/_internal_blocks/:
get:
x-fern-ignore: true
post:
x-fern-ignore: true
/v1/_internal_blocks/{block_id}:
delete:
x-fern-ignore: true
/v1/_internal_blocks/{block_id}/agents:
get:
x-fern-ignore: true
/v1/_internal_agents/{agent_id}/core-memory/blocks/{block_label}:
patch:
x-fern-ignore: true
/v1/projects:
get:
x-fern-sdk-group-name:

File diff suppressed because it is too large Load Diff

View File

@@ -21,9 +21,9 @@ class BaseBlock(LettaBase, validate_assignment=True):
project_id: Optional[str] = Field(None, description="The associated project id.")
# template data (optional)
template_name: Optional[str] = Field(None, description="Name of the block if it is a template.", alias="name")
template_name: Optional[str] = Field(None, description="Name of the block if it is a template.")
is_template: bool = Field(False, description="Whether the block is a template (e.g. saved human/persona options).")
template_id: Optional[str] = Field(None, description="The id of the template.", alias="name")
template_id: Optional[str] = Field(None, description="The id of the template.")
base_template_id: Optional[str] = Field(None, description="The base template id of the block.")
deployment_id: Optional[str] = Field(None, description="The id of the deployment.")
entity_id: Optional[str] = Field(None, description="The id of the entity within the template.")
@@ -102,6 +102,21 @@ class Block(BaseBlock):
last_updated_by_id: Optional[str] = Field(None, description="The id of the user that last updated this Block.")
class BlockResponse(Block):
template_name: Optional[str] = Field(
None, description="(Deprecated) The name of the block template (if it is a template).", deprecated=True
)
template_id: Optional[str] = Field(None, description="(Deprecated) The id of the template.", deprecated=True)
base_template_id: Optional[str] = Field(None, description="(Deprecated) The base template id of the block.", deprecated=True)
deployment_id: Optional[str] = Field(None, description="(Deprecated) The id of the deployment.", deprecated=True)
entity_id: Optional[str] = Field(None, description="(Deprecated) The id of the entity within the template.", deprecated=True)
preserve_on_migration: Optional[bool] = Field(
False, description="(Deprecated) Preserve the block on template migration.", deprecated=True
)
read_only: bool = Field(False, description="(Deprecated) Whether the agent has read-only access to the block.", deprecated=True)
hidden: Optional[bool] = Field(None, description="(Deprecated) If set to True, the block will be hidden.", deprecated=True)
class FileBlock(Block):
file_id: str = Field(..., description="Unique identifier of the file.")
source_id: str = Field(..., description="Unique identifier of the source.")
@@ -149,7 +164,7 @@ class CreateBlock(BaseBlock):
project_id: Optional[str] = Field(None, description="The associated project id.")
# block templates
is_template: bool = False
template_name: Optional[str] = Field(None, description="Name of the block if it is a template.", alias="name")
template_name: Optional[str] = Field(None, description="Name of the block if it is a template.")
@model_validator(mode="before")
@classmethod

View File

@@ -7,6 +7,8 @@ from letta.server.rest_api.routers.v1.folders import router as folders_router
from letta.server.rest_api.routers.v1.groups import router as groups_router
from letta.server.rest_api.routers.v1.health import router as health_router
from letta.server.rest_api.routers.v1.identities import router as identities_router
from letta.server.rest_api.routers.v1.internal_agents import router as internal_agents_router
from letta.server.rest_api.routers.v1.internal_blocks import router as internal_blocks_router
from letta.server.rest_api.routers.v1.internal_runs import router as internal_runs_router
from letta.server.rest_api.routers.v1.internal_templates import router as internal_templates_router
from letta.server.rest_api.routers.v1.jobs import router as jobs_router
@@ -32,6 +34,8 @@ ROUTERS = [
chat_completions_router,
groups_router,
identities_router,
internal_agents_router,
internal_blocks_router,
internal_runs_router,
internal_templates_router,
llm_router,

View File

@@ -32,7 +32,7 @@ from letta.otel.context import get_ctx_attributes
from letta.otel.metric_registry import MetricRegistry
from letta.schemas.agent import AgentRelationships, AgentState, CreateAgent, UpdateAgent
from letta.schemas.agent_file import AgentFileSchema
from letta.schemas.block import BaseBlock, Block, BlockUpdate
from letta.schemas.block import BaseBlock, Block, BlockResponse, BlockUpdate
from letta.schemas.enums import AgentType, RunStatus
from letta.schemas.file import AgentFileAttachment, FileMetadataBase, PaginatedAgentFiles
from letta.schemas.group import Group
@@ -915,7 +915,7 @@ async def retrieve_agent_memory(
return await server.get_agent_memory_async(agent_id=agent_id, actor=actor)
@router.get("/{agent_id}/core-memory/blocks/{block_label}", response_model=Block, operation_id="retrieve_core_memory_block")
@router.get("/{agent_id}/core-memory/blocks/{block_label}", response_model=BlockResponse, operation_id="retrieve_core_memory_block")
async def retrieve_block_for_agent(
block_label: str,
agent_id: AgentId,
@@ -930,7 +930,7 @@ async def retrieve_block_for_agent(
return await server.agent_manager.get_block_with_label_async(agent_id=agent_id, block_label=block_label, actor=actor)
@router.get("/{agent_id}/core-memory/blocks", response_model=list[Block], operation_id="list_core_memory_blocks")
@router.get("/{agent_id}/core-memory/blocks", response_model=list[BlockResponse], operation_id="list_core_memory_blocks")
async def list_blocks_for_agent(
agent_id: AgentId,
server: "SyncServer" = Depends(get_letta_server),
@@ -962,7 +962,7 @@ async def list_blocks_for_agent(
)
@router.patch("/{agent_id}/core-memory/blocks/{block_label}", response_model=Block, operation_id="modify_core_memory_block")
@router.patch("/{agent_id}/core-memory/blocks/{block_label}", response_model=BlockResponse, operation_id="modify_core_memory_block")
async def modify_block_for_agent(
block_label: str,
agent_id: AgentId,

View File

@@ -4,7 +4,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Query
from letta.orm.errors import NoResultFound
from letta.schemas.agent import AgentRelationships, AgentState
from letta.schemas.block import BaseBlock, Block, BlockUpdate, CreateBlock
from letta.schemas.block import BaseBlock, Block, BlockResponse, BlockUpdate, CreateBlock
from letta.server.rest_api.dependencies import HeaderParams, get_headers, get_letta_server
from letta.server.server import SyncServer
from letta.utils import is_1_0_sdk_version
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
router = APIRouter(prefix="/blocks", tags=["blocks"])
@router.get("/", response_model=List[Block], operation_id="list_blocks")
@router.get("/", response_model=List[BlockResponse], operation_id="list_blocks")
async def list_blocks(
# query parameters
label: Optional[str] = Query(None, description="Labels to include (e.g. human, persona)"),
@@ -117,7 +117,7 @@ async def count_blocks(
return await server.block_manager.size_async(actor=actor)
@router.post("/", response_model=Block, operation_id="create_block")
@router.post("/", response_model=BlockResponse, operation_id="create_block")
async def create_block(
create_block: CreateBlock = Body(...),
server: SyncServer = Depends(get_letta_server),
@@ -128,7 +128,7 @@ async def create_block(
return await server.block_manager.create_or_update_block_async(actor=actor, block=block)
@router.patch("/{block_id}", response_model=Block, operation_id="modify_block")
@router.patch("/{block_id}", response_model=BlockResponse, operation_id="modify_block")
async def modify_block(
block_id: BlockId,
block_update: BlockUpdate = Body(...),
@@ -149,7 +149,7 @@ async def delete_block(
await server.block_manager.delete_block_async(block_id=block_id, actor=actor)
@router.get("/{block_id}", response_model=Block, operation_id="retrieve_block")
@router.get("/{block_id}", response_model=BlockResponse, operation_id="retrieve_block")
async def retrieve_block(
block_id: BlockId,
server: SyncServer = Depends(get_letta_server),
@@ -214,7 +214,7 @@ async def list_agents_for_block(
return agents
@router.patch("/{block_id}/identities/attach/{identity_id}", response_model=Block, operation_id="attach_identity_to_block")
@router.patch("/{block_id}/identities/attach/{identity_id}", response_model=BlockResponse, operation_id="attach_identity_to_block")
async def attach_identity_to_block(
identity_id: str,
block_id: BlockId,
@@ -233,7 +233,7 @@ async def attach_identity_to_block(
return await server.block_manager.get_block_by_id_async(block_id=block_id, actor=actor)
@router.patch("/{block_id}/identities/detach/{identity_id}", response_model=Block, operation_id="detach_identity_from_block")
@router.patch("/{block_id}/identities/detach/{identity_id}", response_model=BlockResponse, operation_id="detach_identity_from_block")
async def detach_identity_from_block(
identity_id: str,
block_id: BlockId,

View File

@@ -4,7 +4,7 @@ from fastapi import APIRouter, Body, Depends, Header, Query
from letta.orm.errors import NoResultFound, UniqueConstraintViolationError
from letta.schemas.agent import AgentRelationships, AgentState
from letta.schemas.block import Block
from letta.schemas.block import Block, BlockResponse
from letta.schemas.identity import (
Identity,
IdentityCreate,
@@ -188,7 +188,7 @@ async def list_agents_for_identity(
)
@router.get("/{identity_id}/blocks", response_model=List[Block], operation_id="list_blocks_for_identity")
@router.get("/{identity_id}/blocks", response_model=List[BlockResponse], operation_id="list_blocks_for_identity")
async def list_blocks_for_identity(
identity_id: IdentityId,
before: Optional[str] = Query(

View File

@@ -0,0 +1,31 @@
from fastapi import APIRouter, Body, Depends
from letta.schemas.block import Block, BlockUpdate
from letta.server.rest_api.dependencies import HeaderParams, get_headers, get_letta_server
from letta.server.server import SyncServer
from letta.validators import AgentId
router = APIRouter(prefix="/_internal_agents", tags=["_internal_agents"])
@router.patch("/{agent_id}/core-memory/blocks/{block_label}", response_model=Block, operation_id="modify_internal_core_memory_block")
async def modify_block_for_agent(
block_label: str,
agent_id: AgentId,
block_update: BlockUpdate = Body(...),
server: "SyncServer" = Depends(get_letta_server),
headers: HeaderParams = Depends(get_headers),
):
"""
Updates a core memory block of an agent.
"""
actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
block = await server.agent_manager.modify_block_by_label_async(
agent_id=agent_id, block_label=block_label, block_update=block_update, actor=actor
)
# This should also trigger a system prompt change in the agent
await server.agent_manager.rebuild_system_prompt_async(agent_id=agent_id, actor=actor, force=True, update_timestamp=False)
return block

View File

@@ -0,0 +1,177 @@
from typing import TYPE_CHECKING, List, Literal, Optional
from fastapi import APIRouter, Body, Depends, Query
from letta.schemas.agent import AgentState
from letta.schemas.block import Block, CreateBlock
from letta.server.rest_api.dependencies import HeaderParams, get_headers, get_letta_server
from letta.server.server import SyncServer
from letta.utils import is_1_0_sdk_version
from letta.validators import BlockId
if TYPE_CHECKING:
pass
router = APIRouter(prefix="/_internal_blocks", tags=["_internal_blocks"])
@router.get("/", response_model=List[Block], operation_id="list_internal_blocks")
async def list_blocks(
# query parameters
label: Optional[str] = Query(None, description="Labels to include (e.g. human, persona)"),
templates_only: bool = Query(False, description="Whether to include only templates"),
name: Optional[str] = Query(None, description="Name of the block"),
identity_id: Optional[str] = Query(None, description="Search agents by identifier id"),
identifier_keys: Optional[List[str]] = Query(None, description="Search agents by identifier keys"),
project_id: Optional[str] = Query(None, description="Search blocks by project id"),
limit: Optional[int] = Query(50, description="Number of blocks to return"),
before: Optional[str] = Query(
None,
description="Block ID cursor for pagination. Returns blocks that come before this block ID in the specified sort order",
),
after: Optional[str] = Query(
None,
description="Block ID cursor for pagination. Returns blocks that come after this block ID in the specified sort order",
),
order: Literal["asc", "desc"] = Query(
"asc", description="Sort order for blocks by creation time. 'asc' for oldest first, 'desc' for newest first"
),
order_by: Literal["created_at"] = Query("created_at", description="Field to sort by"),
label_search: Optional[str] = Query(
None,
description=("Search blocks by label. If provided, returns blocks that match this label. This is a full-text search on labels."),
),
description_search: Optional[str] = Query(
None,
description=(
"Search blocks by description. If provided, returns blocks that match this description. "
"This is a full-text search on block descriptions."
),
),
value_search: Optional[str] = Query(
None,
description=("Search blocks by value. If provided, returns blocks that match this value."),
),
connected_to_agents_count_gt: Optional[int] = Query(
None,
description=(
"Filter blocks by the number of connected agents. "
"If provided, returns blocks that have more than this number of connected agents."
),
),
connected_to_agents_count_lt: Optional[int] = Query(
None,
description=(
"Filter blocks by the number of connected agents. "
"If provided, returns blocks that have less than this number of connected agents."
),
),
connected_to_agents_count_eq: Optional[List[int]] = Query(
None,
description=(
"Filter blocks by the exact number of connected agents. "
"If provided, returns blocks that have exactly this number of connected agents."
),
),
show_hidden_blocks: bool | None = Query(
False,
include_in_schema=False,
description="If set to True, include blocks marked as hidden in the results.",
),
server: SyncServer = Depends(get_letta_server),
headers: HeaderParams = Depends(get_headers),
):
actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
return await server.block_manager.get_blocks_async(
actor=actor,
label=label,
is_template=templates_only,
value_search=value_search,
label_search=label_search,
description_search=description_search,
template_name=name,
identity_id=identity_id,
identifier_keys=identifier_keys,
project_id=project_id,
before=before,
connected_to_agents_count_gt=connected_to_agents_count_gt,
connected_to_agents_count_lt=connected_to_agents_count_lt,
connected_to_agents_count_eq=connected_to_agents_count_eq,
limit=limit,
after=after,
ascending=(order == "asc"),
show_hidden_blocks=show_hidden_blocks,
)
@router.post("/", response_model=Block, operation_id="create_internal_block")
async def create_block(
create_block: CreateBlock = Body(...),
server: SyncServer = Depends(get_letta_server),
headers: HeaderParams = Depends(get_headers),
):
actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
block = Block(**create_block.model_dump())
return await server.block_manager.create_or_update_block_async(actor=actor, block=block)
@router.delete("/{block_id}", operation_id="delete_internal_block")
async def delete_block(
block_id: BlockId,
server: SyncServer = Depends(get_letta_server),
headers: HeaderParams = Depends(get_headers),
):
actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
await server.block_manager.delete_block_async(block_id=block_id, actor=actor)
@router.get("/{block_id}/agents", response_model=List[AgentState], operation_id="list_agents_for_internal_block")
async def list_agents_for_block(
block_id: BlockId,
before: Optional[str] = Query(
None,
description="Agent ID cursor for pagination. Returns agents that come before this agent ID in the specified sort order",
),
after: Optional[str] = Query(
None,
description="Agent ID cursor for pagination. Returns agents that come after this agent ID in the specified sort order",
),
limit: Optional[int] = Query(50, description="Maximum number of agents to return"),
order: Literal["asc", "desc"] = Query(
"desc", description="Sort order for agents by creation time. 'asc' for oldest first, 'desc' for newest first"
),
order_by: Literal["created_at"] = Query("created_at", description="Field to sort by"),
include_relationships: list[str] | None = Query(
None,
description=(
"Specify which relational fields (e.g., 'tools', 'sources', 'memory') to include in the response. "
"If not provided, all relationships are loaded by default. "
"Using this can optimize performance by reducing unnecessary joins."
"This is a legacy parameter, and no longer supported after 1.0.0 SDK versions."
),
),
include: List[str] = Query(
[],
description=("Specify which relational fields to include in the response. No relationships are included by default."),
),
server: SyncServer = Depends(get_letta_server),
headers: HeaderParams = Depends(get_headers),
):
"""
Retrieves all agents associated with the specified block.
Raises a 404 if the block does not exist.
"""
actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
if include_relationships is None and is_1_0_sdk_version(headers):
include_relationships = [] # don't default include all if using new SDK version
agents = await server.block_manager.get_agents_for_block_async(
block_id=block_id,
before=before,
after=after,
limit=limit,
ascending=(order == "asc"),
include_relationships=include_relationships,
include=include,
actor=actor,
)
return agents