目标:把第 0 章的简化伪代码展开成真实工程细节------消息格式、工具调用协议、streaming、错误处理与中断恢复。 受众:专业程序员。本机版本
2.1.220。
3.1 消息格式
回顾第 0 章伪代码
python
messages = [system_prompt, user_message]
while True:
a = llm.complete(messages)
if not a.tool_calls: break
...
里面的 system_prompt、user_message、a、tool_result 到底长什么样?本章拆开。
四种消息角色
| role | 谁产生的 | 内容 |
|---|---|---|
system |
runtime 组装 | 系统提示词(人格、规则、可用工具说明) |
user |
你(或 runtime 注入) | 你的话、工具执行结果、文件内容 |
assistant |
LLM | LLM 的回复(文字 + 工具调用) |
注意 :Anthropic API 没有独立的
toolrole 。工具结果(tool_result)是塞在user消息里的------role: "user"的消息 content 数组里放{"type": "tool_result", ...}块。这是 Anthropic 的设计选择(不同于 OpenAI 的toolrole)。所以严格说只有三种 role,但消息块类型有四种(text / tool_use / tool_result 等)。
反直觉点 :工具结果不是单独的 role,而是塞在 user 消息里。Anthropic API 的设计:工具结果作为一种特殊的 user 消息回传给 LLM。
本机实证:真实会话转录的 role 分布
makefile
文件: ~/.claude/projects/-Users-jsl-Desktop-thesis/a0bebaca-....jsonl
assistant: 89 ← LLM 的回复
user: 44 ← 你的话 + 工具结果
attachment: 11 ← 附件
system: 7 ← 系统提示
mode / permission-mode / file-history-snapshot / last-prompt / ai-title ← runtime 内部记录
关键观察:
-
工具结果全记在
userrole 下。本样本中 assistant: 89,user: 44------但注意 user = 你的话 + 所有工具结果。在工具调用密集的会话里 user 数量通常会反超 assistant,因为每调一次工具就多一条 user(tool_result)。这段样本工具调用少所以 user < assistant,不代表常态。 -
除四种标准 role,还有一堆 runtime 内部记录 :
attachment/mode/permission-mode/file-history-snapshot/last-prompt/ai-title。这些不是发给 LLM 的,是 Claude Code 自己存的会话元数据。
两层消息:发给 LLM 的 vs 存在本地的
sql
会话转录 jsonl 文件
├── 发给 LLM 的消息(system / user / assistant / tool_result)
│ └── 这些进 messages 数组,每轮 API 调用都发
└── runtime 内部记录(mode / permission-mode / file-history-snapshot / ...)
└── 这些不发给 LLM,只用于本地状态管理
消息流转图(一轮工具调用完整过程)
scss
┌──────────┐ ┌──────────┐
│ runtime │ │ LLM │
│ (本地) │ │ (云端API)│
└────┬─────┘ └────┬─────┘
│ │
│ ① messages 数组 + 所有工具 schema │
│ ────────────────────────────────────▶ │
│ │ (LLM 思考)
│ │
│ ② assistant 消息 (含 tool_use 块) │
│ ◀──────────────────────────────────── │
│ │
│ ③ runtime 解析 tool_use │
│ 查注册表 → 找到实现 │
│ ④ 执行工具 (副作用+权限+hook) │
│ ↓ │
│ ⑤ 结果包成 tool_result │
│ 塞进 user 消息 │
│ │
│ ⑥ 新的 messages (含 tool_result) │
│ ────────────────────────────────────▶ │
│ │ (LLM 继续想)
│ ... 循环 ... │
--resume 恢复会话时 runtime 要做:
- 读 jsonl
- 过滤出发给 LLM 的那部分(system/user/assistant/tool_result)
- 重建
messages数组 - 同时恢复本地状态(mode、permission-mode、file-history 等)
这就是为什么 --resume 后能看到之前对话、还在同一个权限模式、文件改动历史也在------转录里这两层都存了。
四种角色的具体结构
system 消息:
json
{
"type": "system",
"content": "你是 Claude Code...(几千字的系统提示)"
}
runtime 组装,整个会话只发一次(作为 system 参数传,不在 messages 数组里重复)。
user 消息(你的话):
json
{
"type": "user",
"message": {
"role": "user",
"content": "帮我重构这个函数"
}
}
assistant 消息(LLM 回复,含工具调用):
json
{
"type": "assistant",
"message": {
"role": "assistant",
"content": [
{"type": "text", "text": "我先读一下这个文件"},
{"type": "tool_use", "id": "toolu_xxx", "name": "Read", "input": {"file_path": "/abs/foo.js"}}
]
}
}
重点 :assistant 消息的 content 是个数组,可以同时含文字和多个工具调用。LLM 一轮能调多个工具。
user 消息(工具结果):
json
{
"type": "user",
"message": {
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_xxx", "content": "文件内容..."}
]
}
}
"工具结果塞在 user 里"的实证------role: "user",但 content 里是 tool_result 类型,用 tool_use_id 关联回之前的工具调用。
3.2 工具调用协议
一个工具调用的完整生命周期(五步)
注意:Ch0 将循环概括为 4 步("LLM 输出 → runtime 解析 → 执行 → 结果回传"),本章展开为 5 步------将 Ch0 的"执行"一步拆成"查注册表 → 执行实现"两步,以便展开讲解权限和 hook 的切入位置。两处编号不同但描述的是同一过程。
rust
┌─────────────────────────────────────────────────────────────────┐
│ ① LLM 输出 tool_use 块 │
│ {"type":"tool_use","id":"toolu_xxx","name":"Read", │
│ "input":{"file_path":"/abs/foo.js"}} │
└────────────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ ② runtime 解析 tool_use 块 │
│ 遍历 assistant.content,收集所有 tool_use │
└────────────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ ③ runtime 查工具注册表 │
│ TOOL_REGISTRY["Read"] → {schema, impl} │
└────────────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ ④ runtime 执行工具实现 ← 副作用+权限+hook 都在这层 │
│ ┌──────────────────────────────────────────┐ │
│ │ PreToolUse hook → 权限检查 → 执行 impl │ │
│ │ ↓ │ │
│ │ PostToolUse hook │ │
│ └──────────────────────────────────────────┘ │
└────────────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ ⑤ runtime 包成 tool_result 喂回 │
│ {"type":"tool_result","tool_use_id":"toolu_xxx", │
│ "content":"文件内容..."} │
│ 塞进 user 消息,回到循环顶部 │
└─────────────────────────────────────────────────────────────────┘
① LLM 输出 tool_use 块
json
{
"type": "tool_use",
"id": "toolu_01ABCdefGH",
"name": "Read",
"input": {"file_path": "/abs/path/foo.js"}
}
四个字段:
type: 固定"tool_use"id: 全局唯一 ID(toolu_开头),tool_result 用它关联回来name: 工具名input: 参数对象,schema 由工具定义决定
关键 :LLM 输出符合 schema 的结构化 JSON,不是自然语言。runtime 不需解析自然语言,直接拿结构化数据分发。
② runtime 解析 tool_use 块
python
for block in assistant_msg.content:
if block.type == "tool_use":
pending_tool_calls.append(block) # 收集所有工具调用
一个 assistant 消息可能含多个 tool_use 块------LLM 一轮能调多个工具(如同时读三个文件)。runtime 全收集,逐个执行。
③ runtime 查工具注册表
runtime 维护工具注册表:工具名 → 工具实现(含 input schema、执行函数)。
python
TOOL_REGISTRY = {
"Read": {"schema": FileReadInput, "impl": read_file},
"Edit": {"schema": FileEditInput, "impl": edit_file},
"Bash": {"schema": BashInput, "impl": run_bash},
"Glob": {"schema": GlobInput, "impl": glob_search},
# ...
}
注册表是动态的------这也是为什么 MCP 服务器、插件能"加新工具":注册时往表里加条目,LLM 下轮就能调到。第 12 章(MCP)细讲。
④ runtime 执行工具实现
python
result = TOOL_REGISTRY["Read"]["impl"](input={"file_path": "/abs/path/foo.js"})
这一步产生真实副作用------读文件、改文件、跑命令、发网络请求。权限、沙箱、hook 拦截全插在这一层:
执行工具前 → PreToolUse hook 检查(第 11 章)
执行工具 → 真实副作用
执行工具后 → PostToolUse hook(第 11 章)
权限检查也在这一层------runtime 看 settings.json 的 permissions,决定要不要问(第 5 章)。
⑤ runtime 包成 tool_result 喂回
json
{
"type": "user",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01ABCdefGH",
"content": "文件内容..."
}
]
}
}
tool_use_id 把结果关联回第①步的 id。然后加进 messages,回到循环顶部。
工具的 input schema 从哪来
每个工具自带 input schema。这就是 sdk-tools.d.ts(3807 行)的来历------所有内置工具的 schema 定义。
本机实证:Read 工具 schema (sdk-tools.d.ts:602):
typescript
export interface FileReadInput {
file_path: string; // 必填:绝对路径
offset?: number; // 可选:从第几行开始(大文件分块读)
limit?: number; // 可选:读几行
pages?: string; // 可选:PDF 页码范围
}
工程细节:
file_path必填(不带?),其他可选- 路径必须绝对路径(注释明说)------防止 LLM 用相对路径搞错工作目录
- 大文件支持
offset/limit分块读------防止一次性塞爆 context - PDF 单独有
pages参数------特殊文件类型特殊处理
工具注册表的真实形态
typescript
{
name: "Read", // LLM 看到的名字
inputSchema: FileReadInput, // 参数 schema(发给 LLM 让它知道怎么调)
impl: readFileFunction, // 实际执行函数
// 可能还有:权限规则、hook 配置、输出格式化器等
}
inputSchema 也会发给 LLM。每次 API 调用时 runtime 把所有可用工具的 schema 打包发给 LLM,LLM 才知道"我能调哪些工具、每个工具接受什么参数"。这就是为什么加新工具(MCP/插件)后 LLM 下一轮就能用------schema 已经发过去了。
本机实证:真实 tool_use 块
从会话转录里看到的 tool_use 块:
vbnet
id: call_00_kkvDgUBGb2VkBDDNUIcG4327
name: Bash
input: {"command": "ls -la /etc/resolver/ && echo \"---\" && cat ...", "description": "..."}
id: call_01_KXTdgIyl0BLvheyIS1fk0569
name: Bash
input: {"command": "dig @10.8.8.8 v.nuaa.edu.cn +short +time=3 2>&1", ...}
细节印证:
id格式 :call_00_xxx、call_01_xxx------同一轮里多个工具调用按序编号- 混合工具类型 :同一段会话里有
Bash/Read/Write/Edit------LLM 灵活组合 input符合 schema :Read是{"file_path": "..."},Edit是{"file_path": "...", "old_string": "...", "new_string": "...", "replace_all": false}- 每个 tool_use 是完整 JSON:转录里存的是流式生成完毕后的完整块,delta 片段没存
3.3 streaming 与流式输出
物理事实:LLM 生成是逐 token 的
LLM 生成文本的本质是自回归:每次预测下一个 token,基于已生成的内容接着预测,一个一个吐出来。
arduino
输入: "中国的首都是"
LLM 逐 token 生成:
→ "北" (基于"中国的首都是")
→ "京" (基于"中国的首都是北")
→ "。" (基于"中国的首都是北京")
生成一个 token 大概几十到几百毫秒。200 字回复可能要 5-10 秒。
两种 API 模式
非流式(non-streaming):
markdown
客户端 ──请求──→ LLM
(生成 5-10 秒,全部完成)
客户端 ←──完整响应── LLM
用户看到转圈圈,突然蹦出整段。
流式(streaming):
scss
客户端 ──请求──→ LLM
客户端 ←─token1─ LLM (0.1秒)
客户端 ←─token2─ LLM (0.2秒)
...边生成边传...
客户端 ←─tokenN─ LLM (10秒)
用户看到文字边生成边显示,"打字机效果"。
Claude Code 用的是流式。
为什么必须用流式
不是为了打字机好看,是两个硬需求:
需求一:长任务的可观测性
Agent 循环一轮可能跑 30 秒、调十几个工具。非流式时用户看到"转圈 30 秒然后突然一切完成"------中间发生了什么完全不知道。流式让你实时看到 AI 在干什么:读文件、搜索、改文件......出问题能立刻 Esc 打断。
需求二:提前打断
非流式要等整个响应生成完才能处理。但 Agent 循环里 LLM 可能生成到一半你就发现方向错了。流式允许在生成过程中就接收已生成的部分,runtime 可以随时中断。按 Esc 时 runtime 关闭流,已生成部分保留,未生成的不等了。
streaming 的数据格式:SSE
Claude API 用 SSE(Server-Sent Events) 推送流。每个事件是一行 event: xxx + data: {...}。关键事件:
csharp
event: message_start ← 消息开始
event: content_block_start ← 一个内容块开始(文字块 or 工具调用块)
event: content_block_delta ← 内容块增量(一个 token)
event: content_block_stop ← 内容块结束
event: message_stop ← 整个消息结束
SSE 事件流时序图(一轮含文字+工具调用的生成)
arduino
LLM 端 网络 runtime 端
│ │ │
│ message_start │ │
│─────────────────────▶│─────────────────────▶ │ 开始接收
│ │ │
│ content_block_start │ │
│ (text 块) │ │
│─────────────────────▶│─────────────────────▶ │ 文字块开始
│ │ │
│ content_block_delta │ │
│ ("我") │ │ 显示 "我"
│─────────────────────▶│─────────────────────▶ │
│ content_block_delta │ │ 显示 "先"
│ ("先") │ │
│─────────────────────▶│─────────────────────▶ │
│ ...更多 delta... │ │
│ │ │
│ content_block_stop │ │
│─────────────────────▶│─────────────────────▶ │ 文字块结束
│ │ │
│ content_block_start │ │
│ (tool_use 块) │ │ 工具块开始
│─────────────────────▶│─────────────────────▶ │
│ content_block_delta │ │ JSON 片段
│ ('{"type":"tool_u') │ │ 逐步显现
│─────────────────────▶│─────────────────────▶ │
│ ...更多 delta... │ │
│ │ │
│ content_block_stop │ │
│─────────────────────▶│─────────────────────▶ │ 工具块结束
│ │ │ → 解析+执行
│ message_stop │ │
│─────────────────────▶│─────────────────────▶ │ 整条消息完
runtime 收到这些事件流,边收边解析、边显示。关键在 content_block_delta------每收到一个 delta,就把新内容追加到屏幕上。
工具调用也是流式的
LLM 输出工具调用(tool_use 块)时,也是逐 token 流式生成的。
比如 LLM 要输出:
json
{"type":"tool_use","name":"Read","input":{"file_path":"/abs/foo.js"}}
流式过程:
json
delta 1: {"type":"tool_u
delta 2: se","name":"Re
delta 3: ad","input":{"
delta 4: file_path":"/ab
delta 5: s/foo.js"}}
runtime 要做的事:边收 delta 边拼接,等到 content_block 完整了才解析成 tool_use 块去执行。
这就解释了一个现象:工具调用时工具名和参数也是"逐步显现"的------因为它们就是流式生成的 JSON 片段。
多个内容块的流式
assistant 消息的 content 是数组、可含多个块(文字 + 多个工具调用)。流式时这些块顺序生成:
java
content_block_start (text 块) ← "我先读一下文件"
content_block_delta × N ← 文字逐 token
content_block_stop
content_block_start (tool_use 块) ← Read 工具调用
content_block_delta × N ← JSON 逐 token
content_block_stop
content_block_start (tool_use 块) ← 另一个工具调用
content_block_delta × N
content_block_stop
message_stop
runtime 收完所有 content_block,才把整个 assistant 消息组装好,进入工具执行阶段。
Extended Thinking(推理令牌)
Claude 的 extended thinking 功能在 streaming 中引入额外的内容块类型:
scss
content_block_start (thinking 块) ← 模型开始内部推理
content_block_delta × N ← 推理 token 逐步输出
content_block_start (redacted_thinking) ← 被安全过滤的推理内容(不显示原文)
content_block_stop
content_block_start (text 块) ← 正式回复开始
signature 字段伴随推理块出现,用于验证推理完整性。这些块不计入常规 output token(单独计费),也不在普通 streaming 解析中显示------除非开启了推理可见性。
对 runtime 的影响:解析 content_block 时需多处理 thinking 和 redacted_thinking 两种类型,不能假设只有 text 和 tool_use。
实证:转录里存的是完整块
会话转录 jsonl 里存的是最终完整消息(不是 delta 流)。runtime 收完一个 content_block 才组装成完整 tool_use 存下来------所以你在转录里看到的每个 tool_use 都是完整 JSON,看不到 delta 片段。
3.4 错误处理与中断恢复
Agent 循环会出三类问题,每种有专门恢复机制。
错误处理决策图
sql
Agent 循环运行中
│
┌─────────────┼─────────────┐
▼ ▼ ▼
工具执行失败 用户打断 API 调用报错
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌─────────┐ ┌─────────────────┐
│ 包成 tool_ │ │ Esc: │ │ 可重试? │
│ result with │ │ 停输出 │ │ (网络/503/limit) │
│ is_error:true│ │ 保留已 │ └────┬───────┬────┘
│ │ │ 生成内 │ │ 是 │ 否
│ 喂回 LLM │ │ 容 │ ▼ ▼
│ 让它自愈 │ │ │ 指数退避 报错引导
│ │ │ Ctrl+C: │ 自动重试 (context超限
│ (换路径/换 │ │ 彻底中 │ → /compact
│ 命令/重试) │ │ 断循环 │ 鉴权失败
└──────────────┘ └─────────┘ → 重新认证)
第四类: 会话恢复 (--resume)
┌──────────────────────────────────────────────┐
│ 1. 找 jsonl 转录 │
│ 2. 过滤出发给 LLM 的消息(system/user/assistant│
│ /tool_result)重建 messages │
│ 3. 恢复本地状态(mode/permission-mode 等) │
│ 4. 清理残缺块(半截 tool_use 无对应 result 等)│
│ 5. 进入 REPL 等下一句 │
└──────────────────────────────────────────────┘
第一类:工具执行失败
工具执行(循环 step 3)可能失败------读不存在的文件、命令返回非零、网络超时。
关键认知:工具失败不终止循环,而是把错误当成 tool_result 喂回 LLM。
失败 tool_result 长这样:
json
{
"type": "user",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "call_00_xxx",
"content": "Error: ENOENT: no such file or directory, open '/abs/foo.js'",
"is_error": true ← 标记为错误
}
]
}
}
is_error: true 告诉 LLM "这次调用失败了"。LLM 收到错误后自己决定怎么办:
- 文件不存在 → 改用 Glob 搜正确路径
- 命令失败 → 看错误信息,换命令试
- 网络超时 → 重试或换方案
这就是 Agent 的"自愈"能力------失败不是终点,是新的输入。LLM 基于错误信息调整策略继续循环。
实证:会话里 LLM 自己加 2>/dev/null 吞掉错误输出------它知道文件可能不存在,预先处理。如果文件真不存在,命令返回非零,LLM 收到 tool_result 后判断"哦文件不在,换方案"。
第二类:用户中途打断
Esc:只停输出,循环状态保留
arduino
LLM 正在流式生成第 N 个 token
↓
你按 Esc
↓
runtime 关闭 SSE 流,停止接收后续 token
↓
已生成的部分保留在 messages 里
↓
循环停在"等待下一轮用户输入"状态
关键:已生成的内容没丢。你打断了 AI 的废话,但前面说的话、调的工具都还在。可以接着说"别说了,直接给我结果",循环继续。
CHANGELOG 2.1.219 修过相关 bug:
Fixed spurious "Request interrupted by user" messages after interrupted tool calls, and an unpaired
tool_useblock left in the transcript when a tool aborted mid-response
工具调用到一半被打断,可能留下"半截 tool_use 块"(没有对应 tool_result)。runtime 要清理这种残缺状态,否则下一轮 LLM 会困惑"我有个工具调用没收到结果"。
Ctrl+C:彻底中断
比 Esc 更暴力。Agent 循环本身被打断,不只是当前这一轮。通常退出 REPL 或回到空提示状态。
第三类:API 调用报错
可重试错误(网络抖动、503、rate limit) :runtime 自动重试,带指数退避:
第 1 次调用 → 503
等 1 秒
第 2 次调用 → 503
等 2 秒
第 3 次调用 → 成功
用户看不到重试,只看到最终结果。
不可重试错误:
- Context 超限 :messages 太长超过模型 context window。runtime 提示用
/compact压缩历史。 - 鉴权失败:API key 无效。runtime 报错让你重新认证。
CHANGELOG 2.1.219 修过:
Fixed a retry loop that re-sent identical doomed requests after a context-overflow error with a large thinking budget
以前 context 超限后 runtime 会傻乎乎重试同样请求(注定失败)。修复后不再重试这种不可恢复的错误。
第四类:会话恢复(--resume)
这不是"出错",但是中断的一种------昨天聊到一半关了终端,今天想接着聊。
--resume 做的事:
- 找到对应的 jsonl 转录文件
- 逐行解析
- 过滤出"发给 LLM 的消息"(system/user/assistant/tool_result)
- 重建 messages 数组
- 恢复本地状态(mode、permission-mode、file-history 等)
- 进入 REPL,等你下一句
工程难点:转录里可能有残缺状态:
- 最后一条是 assistant 消息含 tool_use,但没有对应 tool_result(上次中断时工具没执行完)
- 中间被打断的 content_block
CHANGELOG 2.1.219:
Fixed a resumed session failing every turn, or crashing on resume, when its history held a malformed delta attachment
resume 要处理"转录里的畸形数据"------runtime 要能识别并跳过残缺块,否则恢复后每一轮都崩。
错误处理的总体设计哲学
Agent 循环的容错原则:能自愈的就自愈(工具失败喂回 LLM),能恢复的就恢复(resume 清理残缺),不能自愈的明确报错(不可重试错误),用户打断要保留已做的工作(Esc 不丢内容)。
这跟传统脚本编程的"出错即停"完全不同。Agent 循环把错误当成信息源,让 LLM 自己消化------这是 Agent 模式相对传统自动化的核心优势之一。
本章核心带走
- 四种消息角色:system(人格/规则)、user(你的话 + 工具结果)、assistant(LLM 回复 + 工具调用)、tool_result(塞在 user 里)。转录里还有 runtime 内部记录(mode/permission-mode 等)不发给 LLM。
- 工具调用五步 :LLM 输出 tool_use(结构化 JSON)→ runtime 解析 → 查注册表找实现 → 执行(副作用+权限+hook)→ 包成 tool_result 喂回。每个工具的 input schema 来自
sdk-tools.d.ts,既指导 LLM 怎么调,也指导 runtime 怎么验。 - streaming 用 SSE:LLM 逐 token 生成,runtime 边收 delta 边显示,收完一个 content_block 才组装成完整块执行。流式是为了长任务可观测性和可中断,不是为了好看。
- 错误处理四类 :工具失败喂回 LLM 自愈;Esc 停输出保留内容,Ctrl+C 彻底中断;API 错误分可重试(退避)和不可重试(报错);
--resume要清理转录残缺状态。 - 容错哲学:把错误当信息源,让 LLM 消化------与传统"出错即停"根本不同。
本机实证命令汇总
bash
# 1. 找会话转录
find ~/.claude/projects -name "*.jsonl" -type f
# 2. 统计 role 分布
cat <转录文件> | python3 -c "
import sys, json
roles = {}
for line in sys.stdin:
try:
obj = json.loads(line)
r = obj.get('type') or obj.get('role') or 'unknown'
roles[r] = roles.get(r, 0) + 1
except: pass
for r, c in sorted(roles.items(), key=lambda x:-x[1]):
print(f' {r}: {c}')
"
# 3. 找 tool_use 块
cat <转录文件> | python3 -c "
import sys, json
for line in sys.stdin:
try:
obj = json.loads(line)
if obj.get('type') == 'assistant':
for block in obj.get('message',{}).get('content',[]):
if isinstance(block, dict) and block.get('type') == 'tool_use':
print(block['id'], block['name'], json.dumps(block['input'], ensure_ascii=False)[:80])
except: pass
"
# 4. 看工具 schema
grep -A 15 "export interface FileReadInput" .../sdk-tools.d.ts